plan(v0.2): Sprint-1 Assessment + Plan + Testplan — 6 workstreams, 18 new tests

v0.2 'Polish' planned in parallel with v0.1 implementation:
- W1: MS Entra ID provider
- W2: Email magic-link provider
- W3: LoginEventSink SPI + real JavaMailSender mailers
- W4: RFC 7807 Problem Details + configurable invitation expiration
- W5: TypeScript types + Zod schemas + Edge-runtime safety
- W6: Wire-version assessment + tests + publish v0.2.0
Key prediction: no wire-version bump, no new Flyway migration, purely additive
Effort: ~6 days with GLM-5.2+Lumen
Patrick Plate
2026-06-24 21:59:30 +02:00
parent ae25f07225
commit 419d29c1d2
3 changed files with 1385 additions and 0 deletions
+253
@@ -0,0 +1,253 @@
# Sprint 1 — Assessment
**Status:** Draft v1
**Date:** 2026-06-24
**Owner:** Patrick (plate-software)
**Purpose:** Assess what v0.1 delivered, predict what consumers (InspectFlow + Sparkboard) will
need in their first month, classify each v0.2 feature by priority, and surface the risks of the
polish sprint. This document is the basis for [`Sprint-1-Plan.md`](Sprint-1-Plan.md).
---
## 1. v0.1 baseline — what shipped
Sprint 0 (W1W12) extracted InspectFlow's auth/membership system into a reusable two-artifact
library. The table below is the *delivered* surface at the `v0.1.0` tag, cross-referenced to the
[Architecture](Architecture.md) tiers.
### 1.1 Backend — `de.platesoft:plate-auth-starter:0.1.0`
| Layer | Delivered | Reference |
|---|---|---|
| Auto-config | `PlateAuthAutoConfiguration` (explicit `@Import`, no `@ComponentScan`) + `PlateAuthProperties` (`plate.auth.*`) + `META-INF/spring/...AutoConfiguration.imports` | [Architecture §3](Architecture.md) |
| Security | `SecurityConfig` scoped via `securityMatcher(...)` at `@Order(100)` — only plate-auth endpoints | Q13 (decided) |
| Filter | `JwtAuthenticationFilter`, `OrgContextResolver` | [Architecture §56](Architecture.md) |
| Service | `JwtService`, `ExchangeService`, `OAuthService`, `MembershipService`, `InvitationService`, `AccessRequestService`, `LoginEventService` | [Architecture §3.2](Architecture.md) |
| Controller | `OAuthController`, `AuthController`, `InvitationController`, `AccessRequestController`, `AdminAuditController` | [Architecture §3.1](Architecture.md) |
| Entity | `User`, `UserIdentity`, `Membership`, `Invitation`, `AccessRequest`, `LoginEvent`, `RevInfo` | [Architecture §7](Architecture.md) |
| SPI | `OrgValidator`, `OrgDisplayNameResolver`, `InvitationMailer`, `AccessRequestMailer`, `OnboardingHook` (5 interfaces + 5 no-op/defaults via `@ConditionalOnMissingBean`) | [Architecture §3.4](Architecture.md) |
| Migration | Flyway V1V6 under `classpath:db/migration/auth/` + separate `flyway_schema_history_auth` table | [Architecture §8.1](Architecture.md) |
| Wire contract | `WIRE_VERSION=1` constant in exchange envelope, backend rejects mismatch | Q06 (decided) |
| Provider | Google OAuth (enabled by default); Microsoft + email toggles exist but are **off** | Q02/Q04 (deferred) |
### 1.2 Frontend — `@platesoft/auth@0.1.0`
| Module | Delivered | Reference |
|---|---|---|
| `config/` | `createAuthConfig(opts)` factory → NextAuth v5 config with Google provider | [Architecture §4.1](Architecture.md) |
| `exchange/` | `signEnvelope()` / `verifyEnvelope()` using Web Crypto (Edge-safe) + `exchangeWithBackend()` | [Architecture §4.1](Architecture.md) |
| `proxy/` | `createProxyHandlers(opts)``auth()` → Bearer injection + hop-by-hop strip + `duplex:"half"` | [Architecture §4.1](Architecture.md) |
| `middleware/` | `createAuthMiddleware(opts)` with configurable `publicPaths` | [Architecture §4.1](Architecture.md) |
| `client/` | `useAccessToken()`, `useMemberships()`, re-export of `useSession`/`signIn`/`signOut` | [Architecture §4.1](Architecture.md) |
| Build | `tsup` dual ESM/CJS + `.d.ts` | Q09 (decided) |
### 1.3 What v0.1 explicitly left behind (deferred backlog)
These items were tracked in [Sprint-0-Plan §11](Sprint-0-Plan.md) and now form the v0.2 input
backlog. Each was a *deliberate* deferral, not an oversight:
| # | Deferred item | Deferred because | Target |
|---|---|---|---|
| D1 | MS Entra ID provider | Extraction discipline — never in InspectFlow 14.x | v0.2 |
| D2 | Email magic-link provider | Same — InspectFlow 14.1 deferred it | v0.2 |
| D3 | `LoginEventSink` SPI | DB rows + Envers sufficient for 2 consumers | v0.2 |
| D4 | Real `InvitationMailer` (JavaMailSender) | No-op logger is enough to extract; real mail is polish | v0.2 |
| D5 | `AccessRequestMailer` template | Same | v0.2 |
| D6 | Configurable invitation expiration | Hardcoded 7d is fine for extraction | v0.2 |
| D7 | RFC 7807 Problem Details | v0.1 errors are plain Spring Boot defaults | v0.2 |
| D8 | TS type exports (`Membership`, `Invitation`, `AccessRequest`) | Consumers can cast in v0.1 | v0.2 |
| D9 | Zod schemas for envelope + DTOs | Nice-to-have | v0.2 |
| D10 | Edge-runtime safe `useAccessToken()` | Current uses `getSession()` (App-Router-only) | v0.2 |
| D11 | Refresh-token rotation hardening | Stateless JWT refresh accepted for v0.1 | v0.3 (Roadmap) |
---
## 2. Consumer forecast — first-month needs
The [Roadmap](Roadmap.md) says v0.2 is "triggered by feedback from consumers during their first
month using v0.1." We cannot wait for that feedback to plan — Sparkboard is greenfield and will hit
its needs on day 1. This section predicts the pain points based on what each consumer *is*.
```mermaid
flowchart LR
V01[v0.1.0 ships] --> IF[InspectFlow migrates<br/>~1 week]
V01 --> SB[Sparkboard adopts<br/>~3 weeks]
IF --> F1[Feedback wave 1<br/>operational pain]
SB --> F2[Feedback wave 2<br/>greenfield friction]
F1 --> V02[v0.2 backlog<br/>confirmed/adjusted]
F2 --> V02
```
### 2.1 InspectFlow — operational pain (week 12)
InspectFlow is migrating *existing* users from in-tree auth onto the library. Its pain points are
operational, not greenfield:
| Predicted pain | Why | v0.2 feature that fixes it |
|---|---|---|
| **MS Entra ID sign-in stops working** | InspectFlow's V30 migration + `TenantAutoMapService` handled MS tenants. plate-auth v0.1 ships no MS provider — InspectFlow users with `provider=microsoft` identities will have broken sign-in until v0.2. | **W1 — MS Entra ID provider** |
| **Invitation emails silently fail** | v0.1's `LoggingInvitationMailer` logs the URL instead of sending mail. InspectFlow admins who invite users will think the invite sent, but nothing arrives. | **W3 — Real `InvitationMailer`** |
| **Error responses are opaque** | v0.1 returns Spring Boot's default JSON error body. InspectFlow's frontend has no structured way to distinguish "expired token" from "invalid credentials." | **W4 — RFC 7807 Problem Details** |
| **Invitations expire too fast / too slow** | InspectFlow's original 7d window is hardcoded. If they want 3d or 14d, they must fork. | **W4 — Configurable expiration** |
### 2.2 Sparkboard — greenfield friction (week 13)
Sparkboard starts from zero. Its pain is *developer experience* friction — things that are possible
but require more boilerplate than they should:
| Predicted pain | Why | v0.2 feature that fixes it |
|---|---|---|
| **No email sign-in option** | Sparkboard is a 4-person family app ("Family Spark"). Google-only auth is fine for Patrick, but his friend's sons may not have Google accounts. Email magic-link is the natural second option. | **W2 — Email magic-link** |
| **Edge middleware can't read access token** | `useAccessToken()` calls `getSession()` which is App-Router-only. If Sparkboard uses Edge-runtime middleware for route protection, the hook throws. | **W5 — Edge-runtime safety** |
| **TypeScript types are loose** | Sparkboard's frontend gets `any` from `useMemberships()` because `Membership` isn't exported. Runtime works, but DX is rough. | **W5 — TS improvements** |
| **No email UX page** | Email magic-link requires a callback page. Consumers build it themselves in v0.1. | **W2 — default UX page** |
| **Login events stuck in DB only** | Sparkboard may want to tee login events to a webhook or Loki for the homelab observability stack. | **W3 — `LoginEventSink` SPI** |
### 2.3 Hypothesis validation
Per [Roadmap § "Hypothesis-driven scoping"](Roadmap.md):
> **v0.2 hypothesis:** The SPI is enough — no consumer needs to fork.
We validate this by tracking whether InspectFlow or Sparkboard shadow-override plate-auth classes
instead of using the SPI. If they fork, the next minor becomes a "why do they fork" investigation.
v0.2 adds `LoginEventSink` as a **new** SPI specifically to prevent a fork around audit shipping.
---
## 3. v0.2 feature classification
Each v0.2 candidate from the [Roadmap](Roadmap.md) and the [Open-Questions](Open-Questions.md)
deferrals is classified into three tiers. Priority is driven by **consumer-blocking severity**, not
effort.
```mermaid
flowchart TB
subgraph Must[Must-have — blocks a consumer]
M1[W1: MS Entra ID<br/>blocks InspectFlow]
M2[W3: Real InvitationMailer<br/>blocks invitations]
M3[W4: RFC 7807 + config expiration<br/>blocks error UX]
end
subgraph Nice[Nice-to-have — smooths DX]
N1[W2: Email magic-link<br/>Sparkboard option]
N2[W3: LoginEventSink SPI<br/>audit shipping]
N3[W3: AccessRequestMailer template]
N4[W5: TS improvements<br/>typed exports + Zod]
N5[W5: Edge-runtime safety]
end
subgraph Stretch[Stretch — if time allows]
S1[W6: Wire-version bump<br/>if contract changes]
S2[WebAuthn / passkey]
S3[Per-app branding]
end
```
### 3.1 Must-have (blocks at least one consumer)
| # | Feature | Blocks whom | Justification |
|---|---|---|---|
| **W1** | MS Entra ID provider | InspectFlow | InspectFlow has live `provider=microsoft` identities. Without the provider, those users cannot sign in post-migration. This is a **regression**, not a missing feature. The SPI hooks (`OnboardingHook`, V5 tenant-index migration) are ready — the provider implementation is the only gap. |
| **W3** | Real `InvitationMailer` (JavaMailSender) | Both | v0.1's logging mailer means invitations silently fail in any non-dev environment. Any consumer that uses invitations (both do) hits this within week 1. |
| **W4** | RFC 7807 Problem Details | Both | Consumers cannot build robust error UX on v0.1's opaque error bodies. This is the #1 frontend DX complaint we predict. |
### 3.2 Nice-to-have (improves DX but has a workaround)
| # | Feature | Workaround if skipped | Justification |
|---|---|---|---|
| **W2** | Email magic-link provider | Google-only auth | Sparkboard's family users *can* use Google. But the friend's sons may not have accounts — email magic-link is the natural fallback. Predicted to become must-have if Sparkboard gets a non-Google user. |
| **W3** | `LoginEventSink` SPI | Query `login_events` table directly | DB rows work. The SPI lets consumers tee to Loki/SIEM without polling. Nice for homelab observability; not blocking. |
| **W3** | `AccessRequestMailer` template | Same logging fallback as invitation | Less critical than invitation mailer (access requests are less frequent), but should ship together for symmetry. |
| **W5** | TS improvements (typed exports + Zod) | Cast to `any` / manual types | Works, but rough DX. Zod schemas enable runtime validation on the frontend. Predicted to be the #2 frontend complaint. |
| **W5** | Edge-runtime safe `useAccessToken()` | Don't use Edge middleware for auth | `useAccessToken()` currently throws in Edge runtime. Workaround: use Node middleware. But this is a footgun — should be fixed proactively. |
### 3.3 Stretch (only if must-haves + nice-to-haves finish early)
| # | Feature | Why stretch |
|---|---|---|
| **W6** | Wire-version bump to `WIRE_VERSION=2` | Only needed if the envelope contract actually changes (e.g., adding provider-specific fields). If the MS/email providers fit the existing `ExchangeEnvelope` shape, no bump needed. Conditional. |
| — | WebAuthn / passkey provider | No consumer has asked. NextAuth supports it, but backend plumbing is non-trivial. Roadmap says "possibly v0.2." Defer unless a consumer explicitly requests. |
| — | Per-app branding on exchange endpoint | Custom user-agent string for audit. Low value for a 2-consumer library. Defer. |
### 3.4 Priority → workstream mapping
| Priority | Workstream | Effort (GLM-5.2+Lumen) |
|---|---|---|
| Must-have | W1 (MS Entra), W3 (mailers), W4 (RFC 7807 + config) | ~3 days |
| Nice-to-have | W2 (email), W3 (LoginEventSink), W5 (TS + Edge) | ~3 days |
| Stretch | W6 (wire-version + IT + publish) | ~1 day |
| **Total** | **W1W6** | **~7 days** (fits 3 calendar weeks per Roadmap) |
---
## 4. Risk register
| # | Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|---|
| R1 | MS Entra ID provider doesn't match InspectFlow's existing `TenantAutoMapService` behavior | Medium | **High** — InspectFlow MS users locked out | W1 must replicate the tenant→org auto-mapping via `OnboardingHook`. Test against InspectFlow's seeded MS identities. The V5 tenant-index migration is already shipped — use it. |
| R2 | Email magic-link introduces email enumeration vector | Medium | Medium | `allowDangerousEmailAccountLinking=false` (already enforced). Magic-link responses must be generic ("if the email exists, a link was sent") — no "user not found" leak. Test T-SEC11. |
| R3 | `JavaMailSender` default fails silently when SMTP misconfigured | Medium | Medium | The real `InvitationMailer` must **fail loudly** — throw on mail-send failure, not swallow. Integration test with a mock SMTP (GreenMail or Testcontainers mail). Consumer must set `spring.mail.host`. |
| R4 | RFC 7807 Problem Details breaks v0.1 consumers' error parsing | Low | Medium | Problem Details is *additive* (new `Content-Type: application/problem+json`). v0.1 consumers that parsed the old body will see a different shape. Document in CHANGELOG. If the wire-version changes, bump `WIRE_VERSION`. |
| R5 | Edge-runtime safety fix changes `useAccessToken()` return semantics | Low | Low | The fix makes the hook work in Edge (reads from cookie/token-store instead of `getSession()`). Non-Edge consumers are unaffected. Snapshot test guards the contract. |
| R6 | Wire-version bump to 2 forces a coordinated upgrade across both consumers | Medium (if triggered) | Medium | Per [versioning policy](Roadmap.md), wire-version change = MINOR bump (`0.2.0`). Both consumers upgrade lockstep. CHANGELOG documents the migration. |
| R7 | `LoginEventSink` SPI fires synchronously and slows login | Low | Medium | SPI must be **async** (annotated `@Async` or dispatched to an executor). The default no-op sink has zero overhead. Test T-UT16 measures login latency with a sink attached. |
| R8 | Zod schemas drift from backend DTOs | Low | Low | Generate Zod schemas from a single source (TS types) or use a contract test. The golden-vector approach from T-FE02 extends to schema validation. |
| R9 | MS Entra ID `common` tenant vs single-tenant mismatch | Medium | Medium | Provider config must expose `tenant-id` as a property (`plate.auth.providers.microsoft.tenant-id`). Default `common` (multi-tenant). InspectFlow may pin to their tenant. Document in [Integration-Guide.md](Integration-Guide.md). |
| R10 | Email magic-link token replay | Low | Medium | NextAuth Email provider generates single-use HMAC'd magic links. Backend `ExchangeService` already has nonce dedup. The magic-link flow goes through the same exchange envelope — reuse existing replay protection. |
| R11 | Configurable invitation expiration = 0 or negative breaks invitation creation | Low | Low | Bean-validate `plate.auth.invitation.expiration-days` with `@Min(1) @Max(90)`. Fails fast at startup if out of range. |
| R12 | v0.1 ships late, compressing v0.2 timeline | Medium | Medium | v0.2 is "as priorities allow" per [Roadmap](Roadmap.md). Must-haves (W1/W3/W4) can ship as v0.2.0 even if nice-to-haves slip to v0.2.1. No hard deadline except consumer pressure. |
---
## 5. Dependencies we are accepting
v0.2 adds these dependencies on top of v0.1's stack ([Sprint-0-Assessment §4](Sprint-0-Assessment.md)):
| Dep | Version | Added by | Notes |
|---|---|---|---|
| `spring-boot-starter-mail` | (Spring Boot managed) | W3 (real mailers) | Brings `JavaMailSender`. Already listed as a dep in v0.1's pom but unused — now activated. |
| `next-auth` Email provider | 5.x (beta tracking) | W2 (magic-link) | Part of NextAuth v5, no new package. Requires `nodemailer` or SMTP config on the consumer side. |
| `zod` | ^3.x | W5 (TS schemas) | Runtime validation. Ships as a **dependency** of `@platesoft/auth`, not a peer dep (consumers get it automatically). |
No new Spring Boot / Java / Postgres version changes. v0.2 stays on the v0.1 baseline.
---
## 6. Recommendation
**GO** — proceed to [`Sprint-1-Plan.md`](Sprint-1-Plan.md) with the following constraints:
1. **W1 (MS Entra ID) is the critical path.** It is the only must-have that fixes a *regression*
(broken sign-in for existing InspectFlow MS users). It must land first and be tested against
InspectFlow's seeded MS identities.
2. **W3 (mailers) and W4 (RFC 7807 + config) are must-haves but lower-risk.** They can proceed in
parallel with W1.
3. **Nice-to-haves (W2, W5) are planned but not promised.** If v0.1 consumer feedback redirects
priorities, these can slip to v0.2.1 without blocking the v0.2.0 release.
4. **Wire-version bump (W6) is conditional.** Only bump `WIRE_VERSION` if the envelope contract
actually changes. If MS/email providers fit the existing shape, `WIRE_VERSION` stays at 1 and
v0.2.0 is a pure additive release.
5. **WebAuthn + per-app branding stay deferred.** No consumer has asked. Revisit at v0.3.
6. **No new Flyway migrations in v0.2.** The V1V6 schema is sufficient. If MS Entra needs a new
column (e.g., `microsoft_graph_id`), add V7 — but only if the existing `user_identities.subject`
field is insufficient. Predicted: no new migration needed.
The Roadmap says v0.2 is "triggered by feedback." This assessment plans proactively so the
must-haves are ready the moment v0.1 ships. Consumer feedback adjusts the nice-to-have / stretch
split, not the must-have core.
---
## 7. Cross-references
- [Roadmap.md](Roadmap.md) — v0.2 section + hypothesis-driven scoping
- [Architecture.md](Architecture.md) — SPI model, wire contract, threat model
- [Open-Questions.md](Open-Questions.md) — Q02 (MS Entra deferred), Q04 (email deferred), Q12 (audit DB-only)
- [Sprint-0-Plan.md](Sprint-0-Plan.md) — §11 deferred items (v0.2 backlog source)
- [Sprint-0-Assessment.md](Sprint-0-Assessment.md) — format reference + v0.1 source inventory
- [Sprint-1-Plan.md](Sprint-1-Plan.md) — implementation plan based on this assessment
- [Sprint-1-Testplan.md](Sprint-1-Testplan.md) — test matrix for v0.2
---
*Assessment ready for Plan Reviewer. Sprint-1-Plan v1 will be drafted assuming all Section 6
recommendations hold; any Plan Reviewer pushback re-opens the priority split.*
+750
@@ -0,0 +1,750 @@
# Sprint 1 — Implementation Plan
**Status:** Draft v1
**Date:** 2026-06-24
**Owner:** Patrick (plate-software)
**Based on:** [`Sprint-1-Assessment.md`](Sprint-1-Assessment.md), [`Architecture.md`](Architecture.md), [`Roadmap.md`](Roadmap.md) v0.2 section
**Target version:** `0.2.0` (both Maven + npm, lockstep)
**Theme:** *Polish — make the consumer experience pleasant*
---
## Reading guide
This plan follows the same structure as [`Sprint-0-Plan.md`](Sprint-0-Plan.md):
1. **Scope + ground rules** (this section)
2. **Workstream overview** (6 workstreams, W1W6)
3. **Step-by-step per workstream** (goal, steps, done-when)
4. **Security review checklist additions** (incremental over v0.1's §9)
5. **Rollout + acceptance criteria**
Each step is numbered so the Code-mode worker can check off progress without ambiguity.
---
## 1. Scope + ground rules
### 1.1 In scope (Sprint 1 / v0.2.0)
- **W1** — MS Entra ID provider (backend OAuth + frontend NextAuth provider)
- **W2** — Email magic-link provider (backend verification + frontend NextAuth Email provider + default UX page)
- **W3** — `LoginEventSink` SPI + real `InvitationMailer` (JavaMailSender) + `AccessRequestMailer` admin template
- **W4** — RFC 7807 Problem Details on `/api/auth/*` + configurable invitation expiration
- **W5** — TypeScript improvements (typed exports, Zod schemas) + Edge-runtime safe `useAccessToken()`
- **W6** — Wire-version assessment + integration tests + publish `v0.2.0`
### 1.2 Out of scope (deferred — see [Roadmap](Roadmap.md))
- Multi-replica nonce store (v0.3)
- Refresh-token rotation table (v0.3)
- JWT secret rotation via `kid` (v0.3)
- WebAuthn / passkeys (possibly v0.2 — see §1.3)
- Per-app branding on exchange endpoint (possibly v0.2 — see §1.3)
- SAML, SCIM, OIDC server, mobile SDKs (post-1.0)
### 1.3 Stretch (only if must-haves + nice-to-haves finish early)
- WebAuthn / passkey provider — no consumer has asked; defer unless explicitly requested
- Per-app branding — low value for a 2-consumer library; defer
- ADR-style docs auto-published to wiki
### 1.4 Ground rules
- **v0.2 is additive.** No removal of v0.1 public API. Any breaking change is documented in
CHANGELOG with a migration recipe (per [deprecation policy](Roadmap.md)).
- **Lockstep versions.** `@platesoft/auth@0.2.0` and `de.platesoft:plate-auth-starter:0.2.0` ship
together from the same `v0.2.0` git tag.
- **Wire-version discipline.** If the exchange envelope contract changes, bump `WIRE_VERSION` to 2.
If it doesn't (providers fit existing shape), `WIRE_VERSION` stays at 1 and v0.2 is purely additive.
- **Branch policy:** `feature/sprint-1/<workstream>` branches → PR → squash-merge to `main`.
- **GLM-5.2 + Lumen working model.** Effort estimates reflect AI-assisted speed — the provider
implementations follow the same OAuth pattern as Google (already proven in v0.1).
---
## 2. Workstream overview
```mermaid
flowchart LR
W1[W1: MS Entra ID provider] --> W6[W6: Wire-version<br/>+ IT + publish]
W2[W2: Email magic-link] --> W6
W3[W3: LoginEventSink SPI<br/>+ real mailers] --> W6
W4[W4: RFC 7807 Problem Details<br/>+ config expiration] --> W6
W5[W5: TS improvements<br/>+ Edge safety] --> W6
W6 --> TAG[v0.2.0 tag]
```
| # | Workstream | Priority | Depends on | Est. (AI-assisted) |
|---|---|---|---|---|
| W1 | MS Entra ID provider (backend + frontend) | **Must-have** | v0.1 shipped | ~1 day |
| W2 | Email magic-link provider | Nice-to-have | v0.1 shipped | ~1 day |
| W3 | LoginEventSink SPI + real mailers | **Must-have** (mailers) / Nice (SPI) | v0.1 shipped | ~1.5 days |
| W4 | RFC 7807 + configurable expiration | **Must-have** | v0.1 shipped | ~0.5 days |
| W5 | TS improvements + Edge safety | Nice-to-have | v0.1 shipped | ~1 day |
| W6 | Wire-version + IT + publish v0.2.0 | Required | W1W5 | ~1 day |
| | **Total** | | | **~6 days** |
W1W5 are parallelizable. W6 is the integration + publish gate.
---
## 3. W1 — MS Entra ID provider
**Goal:** Add Microsoft Entra ID as a first-class OAuth provider, symmetric with Google. This fixes
the regression where InspectFlow's existing `provider=microsoft` identities have no sign-in path in
v0.1.
**Branch:** `feature/sprint-1/w1-ms-entra`
### 3.1 Backend
**Steps:**
1. **W1-1** Create `service/MicrosoftEntraService.java` (or extend `OAuthService`) to handle MS
Entra ID token verification. Same pattern as Google:
- Accept `id_token` from the frontend exchange envelope
- Verify signature against MS Entra JWKS endpoint
- Extract `oid` (Microsoft's stable user id), `email`, `name`, `tid` (tenant id)
- Find-or-create `User` + `UserIdentity(provider=MICROSOFT, subject=oid, tenant_id=tid)`
- Call `OnboardingHook.onFirstSignIn(...)` for new users
2. **W1-2** Update `LoginProvider` enum — add `MICROSOFT` (if not already present from v0.1's
`email` toggle). The enum is already provider-agnostic.
3. **W1-3** Add `Microsoft` provider config to `PlateAuthProperties.Providers`:
```java
@Data public static class Microsoft {
private boolean enabled = false;
private String tenantId = "common"; // "common", "organizations", or a specific GUID
}
```
4. **W1-4** The exchange envelope already has a `provider` field. MS Entra payloads use
`provider="microsoft"`. No envelope schema change needed — the existing `ExchangeEnvelope` is
provider-agnostic.
5. **W1-5** Register the provider conditionally:
```java
@Bean
@ConditionalOnProperty(prefix = "plate.auth.providers.microsoft", name = "enabled", havingValue = "true")
public MicrosoftEntraService microsoftEntraService(...) { ... }
```
Fail-fast if enabled without `spring.security.oauth2.client.registration.microsoft.*` configured.
**Done when:** A POST to `/api/auth/exchange` with `provider=microsoft` and a valid MS Entra
`id_token` returns `{access_token, refresh_token, user, memberships}`. Existing Google flow is
unaffected (T-IT10).
### 3.2 Frontend
**Steps:**
1. **W1-6** Implement `packages/auth/src/config/providers/microsoft.ts`:
```ts
export function microsoftProvider(opts: {
clientId: string;
clientSecret: string;
tenantId?: string; // default "common"
}): NextAuthConfig.Provider;
```
Uses NextAuth v5's `MicrosoftEntraID` provider.
2. **W1-7** Wire into `createAuthConfig(opts)` — add `microsoft?: MicrosoftOpts` to
`PlateAuthConfigOptions.providers`. If present, add the MS provider to the provider list.
3. **W1-8** Update `ExchangeEnvelope` type — the `provider` union type becomes
`'google' | 'microsoft' | 'email' | 'password'` (already in v0.1's type, now *exercised*).
4. **W1-9** Document env vars in [Architecture §4.2](Architecture.md):
```
MICROSOFT_ENTRA_CLIENT_ID=...
MICROSOFT_ENTRA_CLIENT_SECRET=...
MICROSOFT_ENTRA_TENANT_ID=common
```
**Done when:** A Next.js app with `createAuthConfig({ providers: { microsoft: {...} } })` shows a
"Sign in with Microsoft" button and completes the OAuth flow → exchange → JWT. Snapshot test
verifies the provider is present in the config (T-FE06).
### 3.3 InspectFlow regression test
1. **W1-10** Verify against InspectFlow's seeded MS identities. The V5 migration
(`V5__add_microsoft_tenant_id_index.sql`) already indexes `user_identities.microsoft_tenant_id`.
A user with `provider=microsoft, subject=<oid>, tenant_id=<tid>` must sign in successfully and
get their existing memberships.
**Done when:** InspectFlow MS user sign-in is green post-v0.2.0 migration. T-E2E07 validates this.
---
## 4. W2 — Email magic-link provider
**Goal:** Add email magic-link sign-in as a second auth option. Users enter their email, receive a
one-time link, click it, and are authenticated. No password required.
**Branch:** `feature/sprint-1/w2-email-magic-link`
### 4.1 Backend
**Steps:**
1. **W2-1** The exchange envelope already supports `provider="email"`. The backend
`ExchangeService.consume(...)` path is provider-agnostic — it verifies the HMAC envelope and
find-or-creates the user. No backend changes needed for the *exchange* itself.
2. **W2-2** Add email-provider config to `PlateAuthProperties.Providers`:
```java
@Data public static class EmailMagicLink {
private boolean enabled = false;
private String fromAddress; // e.g., "noreply@plate-software.de"
@Min(60) private int tokenTtlSeconds = 600; // 10 min magic-link TTL
}
```
3. **W2-3** The magic-link flow is handled by NextAuth's Email provider on the *frontend* side —
it sends the magic link email via SMTP. The backend's role is to receive the verified identity
via the exchange envelope (same as Google). No new backend endpoint.
**Done when:** Backend accepts `provider="email"` envelopes. Config validation fails-fast if
`email-magic-link.enabled=true` without SMTP config.
### 4.2 Frontend
**Steps:**
1. **W2-4** Implement `packages/auth/src/config/providers/email.ts`:
```ts
export function emailProvider(opts: {
server?: SMTPConfig; // or connection string
from: string;
}): NextAuthConfig.Provider;
```
Uses NextAuth v5's `Email` provider (nodemailer-backed).
2. **W2-5** Wire into `createAuthConfig(opts)` — add `email?: EmailOpts` to
`PlateAuthConfigOptions.providers`.
3. **W2-6** Create a **default magic-link callback page** component that consumers can drop in:
`packages/auth/src/components/MagicLinkCallback.tsx`. This handles the NextAuth verify-callback
route. Consumers import it or build their own — documented in
[Integration-Guide.md](Integration-Guide.md).
4. **W2-7** Security: verify `allowDangerousEmailAccountLinking=false` is enforced in the email
provider config. Snapshot test (T-FE07).
**Done when:** A Next.js app with `createAuthConfig({ providers: { email: {...} } })` shows an email
input, sends a magic link, and the callback completes the sign-in → exchange → JWT flow.
### 4.3 Email enumeration guard
1. **W2-8** The email-provider response must be **generic**: "If an account exists for this email,
a sign-in link has been sent." No "user not found" error. This prevents email enumeration.
Test: T-SEC12.
**Done when:** Magic-link request for a non-existent email returns the same response as an existing
email. No information leak.
---
## 5. W3 — LoginEventSink SPI + real mailers
**Goal:** Two deliverables bundled here because they share the SPI/mailer architecture:
1. **`LoginEventSink` SPI** — new extension point for consumers to tee login events to external
systems (Loki, SIEM, webhooks) instead of (or in addition to) DB rows.
2. **Real `InvitationMailer` + `AccessRequestMailer`** — replace v0.1's no-op loggers with
`JavaMailSender`-backed implementations that actually send email.
**Branch:** `feature/sprint-1/w3-spi-mailers`
### 5.1 LoginEventSink SPI
**Steps:**
1. **W3-1** Create the SPI interface under `de.platesoft.auth.spi`:
```java
public interface LoginEventSink {
/**
* Called after a login event is recorded. Implementations may ship to
* Loki, OpenSearch, Kafka, or any external sink. Must be non-blocking —
* called asynchronously by LoginEventService.
*
* @param event the recorded login event (never null)
*/
void emit(LoginEvent event);
}
```
2. **W3-2** Create the default no-op implementation:
```java
@Component
@ConditionalOnMissingBean(LoginEventSink.class)
public class NoOpLoginEventSink implements LoginEventSink {
@Override
public void emit(LoginEvent event) {
// no-op — default. Override to ship externally.
}
}
```
3. **W3-3** Wire `LoginEventSink` into `LoginEventService`:
- After `loginEventRepository.save(event)`, call `loginEventSink.emit(event)` **asynchronously**
(`@Async` or a dedicated executor).
- The sink must **not** block the login flow. A failing sink logs a WARN but never fails the
login.
- Wrap the `emit()` call in a try-catch that logs at WARN level on failure.
4. **W3-4** Register the SPI in `PlateAuthAutoConfiguration` `@Import` list.
5. **W3-5** Update [Architecture §3.4](Architecture.md) SPI table — add `LoginEventSink` as the 6th
SPI. Default = `NoOpLoginEventSink`.
**Done when:** A consumer can `@Bean` a `LoginEventSink` and receive every login event. The default
no-op sink has zero overhead. Login latency is unaffected (async dispatch). T-UT16 validates.
### 5.2 Real InvitationMailer
**Steps:**
1. **W3-6** Create `de.platesoft.auth.spi.defaults.MailInvitationMailer`:
```java
@Component
@ConditionalOnMissingBean(InvitationMailer.class)
@ConditionalOnProperty(prefix = "plate.auth.mail", name = "enabled", havingValue = "true")
public class MailInvitationMailer implements InvitationMailer {
private final JavaMailSender mailSender;
private final PlateAuthProperties properties;
@Override
public void sendInvitation(Invitation invitation, String acceptUrl) {
MimeMessage msg = mailSender.createMimeMessage();
// build HTML + plain-text multipart from template
// subject: "You're invited to join {orgDisplayName}"
// body: link to acceptUrl, expiration info
mailSender.send(msg);
}
}
```
2. **W3-7** The **default** `InvitationMailer` when `plate.auth.mail.enabled=false` (or unset)
remains the existing `LoggingInvitationMailer`. The new `MailInvitationMailer` activates only
when mail is explicitly enabled. This preserves v0.1's "boots green with zero config" guarantee.
3. **W3-8** Create email templates:
- `src/main/resources/templates/invitation.html` — HTML body with accept-url placeholder
- `src/main/resources/templates/invitation.txt` — plain-text fallback
- Use Spring's `MimeMessageHelper` for multipart.
4. **W3-9** Fail-loudly: if `plate.auth.mail.enabled=true` but `spring.mail.host` is unset, fail
fast at startup with a clear error: `"plate.auth.mail.enabled=true but spring.mail.host is not configured"`.
**Done when:** With `plate.auth.mail.enabled=true` + SMTP config, inviting a user sends a real email
with the accept link. Without mail config, the logging fallback activates. T-UT17 + T-IT11 validate.
### 5.3 AccessRequestMailer admin template
**Steps:**
1. **W3-10** Create `de.platesoft.auth.spi.defaults.MailAccessRequestMailer` — same pattern as
`MailInvitationMailer`. Activates with `plate.auth.mail.enabled=true`.
2. **W3-11** Two notification methods:
- `notifyAdmins(request)` — sends to configured admin email list
- `notifyRequester(request)` — sends approval/denial notification to the requester
3. **W3-12** Templates:
- `templates/access-request-admin.html` — "New access request from {email}"
- `templates/access-request-decision.html` — "Your access request was {approved/denied}"
4. **W3-13** Admin email list from config: `plate.auth.mail.admin-recipients` (comma-separated).
**Done when:** Access request submission → admins notified. Approval/denial → requester notified.
T-UT18 validates.
### 5.4 Mail config additions
Add to `PlateAuthProperties`:
```java
@Data public static class Mail {
private boolean enabled = false;
private String fromAddress = "noreply@plate-software.de";
private List<String> adminRecipients = new ArrayList<>();
}
```
```yaml
plate:
auth:
mail:
enabled: false # opt-in; default is logging fallback
from-address: noreply@plate-software.de
admin-recipients:
- admin@plate-software.de
```
---
## 6. W4 — RFC 7807 Problem Details + configurable expiration
**Goal:** Two small must-haves bundled:
1. **RFC 7807 Problem Details** — structured error responses (`application/problem+json`) on all
`/api/auth/*` endpoints so consumers can build robust error UX.
2. **Configurable invitation expiration** — `plate.auth.invitation.expiration-days` (currently
hardcoded 7d).
**Branch:** `feature/sprint-1/w4-problem-details-config`
### 6.1 RFC 7807 Problem Details
**Steps:**
1. **W4-1** Create `config/PlateAuthProblemDetailHandler` — extends Spring Boot 4's
`ProblemDetailExceptionHandler` (Spring 6 / Boot 4 has built-in RFC 7807 support via
`@ControllerAdvice` + `ProblemDetail`). Wire it as a `@ControllerAdvice` for the
`de.platesoft.auth.controller` package.
2. **W4-2** Define custom problem types for plate-auth-specific errors:
```java
public enum PlateAuthProblems {
EXCHANGE_HMAC_INVALID("https://plate-software.de/errors/exchange-hmac-invalid", 401),
EXCHANGE_EXPIRED("https://plate-software.de/errors/exchange-expired", 401),
EXCHANGE_REPLAY("https://plate-software.de/errors/exchange-replay", 409),
ORG_VALIDATION_FAILED("https://plate-software.de/errors/org-validation-failed", 400),
INVITATION_EXPIRED("https://plate-software.de/errors/invitation-expired", 410),
INVITATION_REVOKED("https://plate-software.de/errors/invitation-revoked", 410);
// ... each maps to a `type` URI + status code
}
```
3. **W4-3** Ensure all v0.1 custom exceptions map to `ProblemDetail`:
- `ExchangeHmacInvalidException` → 401 + `EXCHANGE_HMAC_INVALID` type
- `ExchangeReplayException` → 409 + `EXCHANGE_REPLAY` type
- `ExchangeExpiredException` → 401 + `EXCHANGE_EXPIRED` type
- `OrgValidationException` → 400 + `ORG_VALIDATION_FAILED` type
- Generic `BadCredentials` → 401 + `https://plate-software.de/errors/bad-credentials`
4. **W4-4** Security: Problem Details responses must **not** leak stack traces, SQL fragments, or
internal class names. The `detail` field is consumer-safe; the `instance` field is the request
path. Test T-SEC13.
5. **W4-5** Set `Content-Type: application/problem+json` on all error responses from plate-auth
endpoints. v0.1's default Spring Boot error body is replaced.
**Done when:** A failed login returns `application/problem+json` with `type`, `title`, `status`,
`detail`, `instance`. A replayed exchange envelope returns 409 with the `EXCHANGE_REPLAY` type.
T-UT19 + T-IT12 validate.
### 6.2 Configurable invitation expiration
**Steps:**
1. **W4-6** Add to `PlateAuthProperties`:
```java
@Data public static class Invitation {
@Min(1) @Max(90)
private int expirationDays = 7;
}
```
2. **W4-7** Update `InvitationService.create(...)` — replace hardcoded `Duration.ofDays(7)` with
`Duration.ofDays(properties.getInvitation().getExpirationDays())`.
3. **W4-8** Bean-validate: `@Min(1) @Max(90)` prevents 0/negative/absurd values. Fails fast at
startup.
4. **W4-9** Document the config in [Architecture §3.3](Architecture.md):
```yaml
plate:
auth:
invitation:
expiration-days: 7 # default; range 190
```
**Done when:** Setting `plate.auth.invitation.expiration-days=3` produces invitations that expire
in 3 days. Setting it to 0 or 100 fails startup validation. T-UT20 validates.
---
## 7. W5 — TypeScript improvements + Edge-runtime safety
**Goal:** Two frontend DX improvements:
1. **TypeScript type exports + Zod schemas** — export proper types for `Membership`, `Invitation`,
`AccessRequest` and add runtime-validation Zod schemas for the exchange envelope + DTOs.
2. **Edge-runtime safe `useAccessToken()`** — replace the App-Router-only `getSession()` call with
an Edge-compatible approach.
**Branch:** `feature/sprint-1/w5-ts-edge`
### 7.1 TypeScript type exports + Zod schemas
**Steps:**
1. **W5-1** Create `packages/auth/src/types/index.ts` — export domain types mirroring backend entities:
```ts
export interface Membership {
id: string;
userId: string;
orgType: string;
orgId: string;
role: MembershipRole;
status: MembershipStatus;
}
export type MembershipRole = 'OWNER' | 'ADMIN' | 'MEMBER' | 'VIEWER';
export type MembershipStatus = 'ACTIVE' | 'SUSPENDED' | 'REVOKED';
export interface Invitation {
id: string;
email: string;
orgType: string;
orgId: string;
role: MembershipRole;
status: InvitationStatus;
expiresAt: string; // ISO-8601
}
export type InvitationStatus = 'PENDING' | 'ACCEPTED' | 'REVOKED' | 'EXPIRED';
export interface AccessRequest {
id: string;
requesterId: string;
orgType: string;
orgId: string;
requestedRole: MembershipRole;
status: AccessRequestStatus;
}
export type AccessRequestStatus = 'PENDING' | 'APPROVED' | 'DENIED' | 'EXPIRED';
export interface TokenResponse {
accessToken: string;
refreshToken: string;
user: PlateAuthUser;
memberships: Membership[];
}
export interface PlateAuthUser {
id: string;
email: string;
role: 'USER' | 'ADMIN';
firstName?: string;
lastName?: string;
}
```
2. **W5-2** Re-export from `packages/auth/src/index.ts` so consumers can
`import { Membership, Invitation, AccessRequest } from '@platesoft/auth'`.
3. **W5-3** Update `useMemberships()` return type from implicit `any` to `Membership[]`.
Update `useAccessToken()` return type to explicit `string | null`.
4. **W5-4** Add Zod schemas in `packages/auth/src/schemas/index.ts`:
```ts
import { z } from 'zod';
export const ExchangeEnvelopeSchema = z.object({
wireVersion: z.literal(1).or(z.literal(2)),
provider: z.enum(['google', 'microsoft', 'email', 'password']),
providerSubject: z.string(),
email: z.string().email(),
name: z.string().optional(),
inviteToken: z.string().optional(),
nonce: z.string().uuid(),
iat: z.number().int().positive(),
});
export const TokenResponseSchema = z.object({
accessToken: z.string(),
refreshToken: z.string(),
user: z.object({
id: z.string().uuid(),
email: z.string().email(),
role: z.enum(['USER', 'ADMIN']),
firstName: z.string().optional(),
lastName: z.string().optional(),
}),
memberships: z.array(z.object({
orgType: z.string(),
orgId: z.string().uuid(),
role: z.enum(['OWNER', 'ADMIN', 'MEMBER', 'VIEWER']),
status: z.enum(['ACTIVE', 'SUSPENDED', 'REVOKED']),
})),
});
```
5. **W5-5** Use `TokenResponseSchema.parse()` in the exchange client to validate backend response
at runtime — catches contract drift early.
6. **W5-6** Add `zod` as a dependency (not peer dep) in `packages/auth/package.json`.
**Done when:** `import { Membership, Invitation, ExchangeEnvelopeSchema } from '@platesoft/auth'`
resolves with full type safety. The exchange client validates the backend response via Zod.
T-FE08 + T-FE09 validate.
### 7.2 Edge-runtime safe useAccessToken()
**Steps:**
1. **W5-7** The current `useAccessToken()` calls `getSession()` which uses Node-only APIs. This
throws in the Edge runtime. Fix: detect runtime and use a cookie-based fallback:
```ts
export function useAccessToken(): string | null {
// Edge-runtime safe: read from a cookie set by the proxy handler
const cookieValue = getCookie('plate-auth-token');
return cookieValue ?? null;
}
```
Or, if called in a client component (browser), use `useSession()` as before.
2. **W5-8** Document the two modes:
- **Client component (browser):** `useSession()` — token in NextAuth JWT (encrypted cookie)
- **Edge middleware:** read `plate-auth-token` cookie (set by proxy handler)
3. **W5-9** Add an Edge-runtime test (`@edge-runtime/vm` or `vitest` with Edge env) that imports
`useAccessToken()` and verifies it does not throw (T-FE10).
**Done when:** `useAccessToken()` works in both browser and Edge runtime without throwing. No
Node-only API imported in the Edge entry point. T-FE10 validates.
---
## 8. W6 — Wire-version assessment + integration tests + publish v0.2.0
**Goal:** Assess whether the wire-version needs bumping, write the v0.2 integration tests, and
publish `v0.2.0` to the Gitea Package Registry.
**Branch:** `feature/sprint-1/w6-publish`
### 8.1 Wire-version assessment
**Steps:**
1. **W6-1** Review whether any v0.2 change alters the exchange envelope contract:
- MS Entra ID: `provider="microsoft"` — already in the v0.1 `provider` union. **No change.**
- Email magic-link: `provider="email"` — already in the v0.1 `provider` union. **No change.**
- LoginEventSink: backend-only, no envelope impact. **No change.**
- RFC 7807: response-only, no request/envelope change. **No change.**
- TS types/Zod: frontend-only, no wire impact. **No change.**
- Config expiration: backend-only. **No change.**
2. **W6-2** **Decision point:** If all changes fit the existing `ExchangeEnvelope` shape, then
`WIRE_VERSION` stays at **1**. v0.2.0 is a pure additive release.
- **If** a provider-specific field is added to the envelope, then bump `WIRE_VERSION` to **2**.
- **Prediction:** No bump needed. The envelope is provider-agnostic by design.
**Done when:** Wire-version decision is documented in CHANGELOG.
### 8.2 Integration tests
**Steps:**
1. **W6-3** Implement **T-IT10..13** (new integration tests for v0.2):
- T-IT10: MS Entra exchange flow — mock MS JWKS, verify token → user → JWT
- T-IT11: Invitation mailer IT — `plate.auth.mail.enabled=true` + GreenMail → email sent
- T-IT12: RFC 7807 error responses — failed login returns `application/problem+json`
- T-IT13: Multi-provider IT — Google + MS + email all configured, each completes exchange
2. **W6-4** Run full regression: `mvn -pl plate-auth-starter verify` + `mvn -pl it verify` + all
v0.1 tests still pass.
**Done when:** All v0.1 + v0.2 tests green against Testcontainers Postgres.
### 8.3 Publish v0.2.0
**Steps:**
1. **W6-5** Update `CHANGELOG.md` with v0.2.0 release notes (new features + migration notes).
2. **W6-6** Bump version: `pom.xml` revision → `0.2.0`, `packages/auth/package.json` → `0.2.0`.
3. **W6-7** Cut `v0.2.0` git tag. Gitea Actions publishes both artifacts.
4. **W6-8** Verify: `mvn dependency:get de.platesoft:plate-auth-starter:0.2.0` + `npm view
@platesoft/auth@0.2.0` from a fresh machine.
**Done when:** `v0.2.0` tag publishes both artifacts to the Gitea Package Registry.
---
## 9. Security review checklist additions
Incremental over v0.1's security checklist ([Sprint-0-Plan §9](Sprint-0-Plan.md)). Items marked
`[v0.2-new]` are new for this sprint.
### 9.1 MS Entra ID provider [v0.2-new]
- [ ] MS Entra `id_token` signature verified against MS JWKS endpoint (not just decoded).
- [ ] Tenant ID (`tid`) extracted and stored in `user_identities.tenant_id`.
- [ ] Provider is `@ConditionalOnProperty`. Default disabled. Fail-fast without client-id/secret.
- [ ] `tenantId` config exposed: `common`, `organizations`, or a specific GUID.
### 9.2 Email magic-link [v0.2-new]
- [ ] `allowDangerousEmailAccountLinking = false` — verified by snapshot test (T-FE07).
- [ ] Magic-link request response is **generic** — no email enumeration (T-SEC12).
- [ ] Magic-link token TTL configurable (default 600s/10min). Token is single-use.
- [ ] Magic-link emails do not contain PII beyond the targeted email address.
### 9.3 LoginEventSink SPI [v0.2-new]
- [ ] SPI invocation is **asynchronous** — does not block login flow (T-UT16).
- [ ] Failing sink logs WARN but never propagates exception to the login path.
- [ ] `LoginEvent` passed to sink does not contain the password or full JWT.
### 9.4 Real mailers [v0.2-new]
- [ ] `MailInvitationMailer` fails loudly on SMTP failure (throws, does not swallow).
- [ ] Invitation email does not leak the org's internal ID — uses `OrgDisplayNameResolver`.
- [ ] Accept URL uses HTTPS (validated by config).
- [ ] `plate.auth.mail.enabled=false` (default) → logging fallback. No SMTP connection attempted.
### 9.5 RFC 7807 Problem Details [v0.2-new]
- [ ] Error responses include no stack traces, no SQL fragments, no internal class names (T-SEC13).
- [ ] `detail` field is consumer-safe (human-readable, no secrets).
- [ ] `type` URIs are stable and documented.
- [ ] Problem Details responses set `Content-Type: application/problem+json`.
- [ ] Login-failure Problem Detail says "invalid credentials" — no user-exists leak.
### 9.6 Wire-version [v0.2-new] (only if bumped)
- [ ] If `WIRE_VERSION` bumped to 2: backend rejects version-1 envelopes with a clear error.
- [ ] If bumped: both consumers upgrade lockstep per versioning policy.
- [ ] If not bumped: no action needed — v0.2 is additive.
---
## 10. Rollout plan
### 10.1 Consumer upgrade path
v0.2.0 is **additive** (assuming no wire-version bump). Upgrade for each consumer:
**InspectFlow:**
1. Change `pom.xml`: `plate-auth-starter` version `0.1.0` → `0.2.0`
2. Change `package.json`: `@platesoft/auth` version `0.1.0` → `0.2.0`
3. Enable MS Entra: `plate.auth.providers.microsoft.enabled=true` + MS client-id/secret env vars
4. Enable mail: `plate.auth.mail.enabled=true` + SMTP config
5. Optionally: configure `plate.auth.invitation.expiration-days`
6. Optionally: add a `LoginEventSink` bean to tee events to Loki/SIEM
7. Frontend: update types — `useMemberships()` now returns `Membership[]`
8. Run full E2E suite — must pass
**Sparkboard:**
1. Same version bumps
2. Enable email magic-link if needed: `plate.auth.providers.email-magic-link.enabled=true` + SMTP
3. Optionally: import Zod schemas for runtime validation
4. Optionally: use the default `MagicLinkCallback` component
### 10.2 Rollback strategy
v0.2.0 rollback is **safe** — no Flyway migration is added (prediction). If a consumer needs to
revert to v0.1.0:
1. Revert dependency versions in `pom.xml` + `package.json`
2. Redeploy
3. Database is unaffected (no schema change)
---
## 11. Acceptance criteria
| # | Criterion | How verified |
|---|---|---|
| B1 | MS Entra ID provider works end-to-end | T-IT10 + T-E2E07 |
| B2 | Email magic-link provider works end-to-end | T-FE07 + manual test |
| B3 | `LoginEventSink` SPI fires on login events | T-UT16 |
| B4 | Real `InvitationMailer` sends email | T-UT17 + T-IT11 |
| B5 | RFC 7807 Problem Details on `/api/auth/*` | T-UT19 + T-IT12 |
| B6 | Configurable invitation expiration works | T-UT20 |
| B7 | TS types + Zod schemas exported | T-FE08 + T-FE09 |
| B8 | Edge-runtime safe `useAccessToken()` | T-FE10 |
| B9 | Both artifacts published at 0.2.0 | `mvn dependency:get` + `npm view` |
| B10 | All v0.1 tests still pass (no regression) | Full `mvn verify` + `pnpm test` |
---
## 12. Items deferred to v0.3+
- Multi-replica nonce store (Redis/Postgres UPSERT)
- Refresh-token rotation table + family-tracking (T-SEC10 from v0.1)
- JWT secret rotation via JWK Set + `kid` header
- Session sliding vs absolute expiration toggle
- `requireRoles(['ADMIN'])` middleware helper
- WebAuthn / passkey provider (if consumer demand emerges)
- Per-app branding on exchange endpoint
- Account lockout after N failed logins
- 2FA / TOTP
---
## 13. Cross-references
- Assessment: [`Sprint-1-Assessment.md`](Sprint-1-Assessment.md)
- Test plan: [`Sprint-1-Testplan.md`](Sprint-1-Testplan.md)
- v0.1 plan: [`Sprint-0-Plan.md`](Sprint-0-Plan.md)
- Architecture: [`Architecture.md`](Architecture.md)
- Roadmap: [`Roadmap.md`](Roadmap.md) (v0.2 section)
- Open questions: [`Open-Questions.md`](Open-Questions.md)
---
*End of plan v1. Ready for Plan Reviewer.*
+382
@@ -0,0 +1,382 @@
# Sprint 1 — Test Plan
**Status:** Draft v1
**Date:** 2026-06-24
**Owner:** Patrick
**Scope:** Validates Sprint 1 deliverable: `de.platesoft:plate-auth-starter:0.2.0` + `@platesoft/auth@0.2.0`
**Basis:** [Sprint-1-Plan.md](Sprint-1-Plan.md)
**Continuation:** Test IDs continue from v0.1 (Sprint-0-Testplan: T-UT15, T-IT09, T-SEC10, T-FE05, T-E2E06, T-PERF03)
---
## 1. Reading guide
This test plan enumerates every **new** test case for v0.2. It does **not** re-list v0.1 tests —
those must still pass (regression gate). All v0.1 test IDs ([T-UT01..15](Sprint-0-Testplan.md),
T-IT01..09, T-SEC01..09, T-FE01..05, T-E2E01..06, T-PERF01..03) remain in effect and must be green.
Each test case has:
- **ID** (continuing the v0.1 numbering: T-UTxx, T-ITxx, T-SECxx, T-FExx, T-E2Exx)
- **Type** (Unit / Integration / Frontend-Unit / E2E / Security)
- **Class / spec file**
- **Scenarios** (Given / When / Then)
- **Expected result**
- **Acceptance criterion mapped** (B1..B10 from [Sprint-1-Plan.md §11](Sprint-1-Plan.md))
Status legend: ⬜ Open · 🟡 In progress · ✅ Passed · ❌ Failed · ⏭️ Skipped
---
## 2. Test overview (master table)
| ID | Type | Class / Spec | Maps to | Status |
|----|------|--------------|---------|--------|
| T-UT16 | Unit | `LoginEventSinkTest` (async dispatch) | B3 | ⬜ |
| T-UT17 | Unit | `MailInvitationMailerTest` | B4 | ⬜ |
| T-UT18 | Unit | `MailAccessRequestMailerTest` | B4 | ⬜ |
| T-UT19 | Unit | `PlateAuthProblemDetailTest` | B5 | ⬜ |
| T-UT20 | Unit | `InvitationExpirationConfigTest` | B6 | ⬜ |
| T-IT10 | Integration | `MicrosoftEntraExchangeIT` | B1 | ⬜ |
| T-IT11 | Integration | `InvitationMailerIT` (GreenMail) | B4 | ⬜ |
| T-IT12 | Integration | `ProblemDetailResponseIT` | B5 | ⬜ |
| T-IT13 | Integration | `MultiProviderExchangeIT` | B1, B2 | ⬜ |
| T-SEC11 | Security | Email enumeration guard (magic-link) | B2 | ⬜ |
| T-SEC12 | Security | Magic-link token replay rejected | B2 | ⬜ |
| T-SEC13 | Security | Problem Details no information leak | B5 | ⬜ |
| T-FE06 | Frontend-Unit | `microsoft-provider.test.ts` | B1 | ⬜ |
| T-FE07 | Frontend-Unit | `email-provider.test.ts` | B2 | ⬜ |
| T-FE08 | Frontend-Unit | `type-exports.test.ts` | B7 | ⬜ |
| T-FE09 | Frontend-Unit | `zod-schemas.test.ts` | B7 | ⬜ |
| T-FE10 | Frontend-Unit | `edge-runtime.test.ts` | B8 | ⬜ |
| T-E2E07 | E2E | InspectFlow MS Entra sign-in (regression) | B1, B10 | ⬜ |
**v0.2 new tests:** 18 — 5 Unit, 4 Integration, 3 Security, 5 Frontend-Unit, 1 E2E.
**Combined v0.1 + v0.2 total:** 61 test cases (43 from v0.1 + 18 from v0.2).
---
## 3. Unit tests (backend)
### T-UT16 — LoginEventSink fires asynchronously
- **Class:** `de.platesoft.auth.spi.LoginEventSinkTest`
- **Given:** A `LoginEventSink` that sleeps 500ms in `emit(...)` (simulating slow external sink).
`LoginEventService` configured with the sink.
- **When:** A successful login triggers `LoginEventService.recordSuccess(...)`.
- **Then:** The `recordSuccess(...)` method returns **immediately** (< 50ms wall clock) — the sink
dispatch is async. After 600ms, the sink's `emit(...)` was called exactly once with the correct
`LoginEvent`. A failing sink (throws `RuntimeException`) logs a WARN but does not propagate to
the login path.
### T-UT17 — MailInvitationMailer sends invitation email
- **Class:** `de.platesoft.auth.spi.defaults.MailInvitationMailerTest`
- **Given:** `plate.auth.mail.enabled=true`, `JavaMailSender` mocked. An `Invitation` with
`email=newuser@test.de`, `orgType=COMPANY`, `orgId=<uuid>`, `role=MEMBER`. Accept URL =
`https://app.example.com/invite/accept?token=abc123`.
- **When:** `sendInvitation(invitation, acceptUrl)` called.
- **Then:** `JavaMailSender.send(...)` invoked exactly once. The `MimeMessage` has:
- `To: newuser@test.de`
- `From: noreply@plate-software.de` (from config)
- Subject contains the org display name (via `OrgDisplayNameResolver`)
- Body (text + HTML multipart) contains the accept URL
- **Also:** If `JavaMailSender.send(...)` throws `MailSendException`, `sendInvitation(...)`
**re-throws** (does not swallow). Verified by a second scenario.
### T-UT18 — MailAccessRequestMailer sends admin + requester notifications
- **Class:** `de.platesoft.auth.spi.defaults.MailAccessRequestMailerTest`
- **Given:** `plate.auth.mail.enabled=true`, `admin-recipients=[admin1@test.de, admin2@test.de]`.
An `AccessRequest` with `status=PENDING`, `requester.email=applicant@test.de`.
- **When:** `notifyAdmins(request)` called.
- **Then:** `JavaMailSender.send(...)` invoked once with `To: admin1@test.de, admin2@test.de`.
Subject: "New access request from applicant@test.de".
- **When:** `notifyRequester(request)` called with `status=APPROVED`.
- **Then:** Email sent to `applicant@test.de`. Subject contains "approved".
### T-UT19 — Problem Details format for auth errors
- **Class:** `de.platesoft.auth.config.PlateAuthProblemDetailTest`
- **Given:** Spring MVC test with `PlateAuthProblemDetailHandler` active.
- **Scenarios (parameterized):**
- `ExchangeHmacInvalidException``ProblemDetail` with `status=401`,
`type=https://plate-software.de/errors/exchange-hmac-invalid`, `title="Exchange HMAC invalid"`
- `ExchangeReplayException``status=409`, `type=.../exchange-replay`
- `ExchangeExpiredException``status=401`, `type=.../exchange-expired`
- `OrgValidationException``status=400`, `type=.../org-validation-failed`
- `BadCredentialsException``status=401`, `type=.../bad-credentials`,
`detail="Invalid credentials"` (no "user not found" leak)
- **Then:** All responses have `Content-Type: application/problem+json`. Each `ProblemDetail` has
`type`, `title`, `status`, `detail`, `instance` fields populated. No stack trace, no SQL
fragment, no internal class name in any field.
### T-UT20 — Configurable invitation expiration
- **Class:** `de.platesoft.auth.config.InvitationExpirationConfigTest`
- **Scenarios (parameterized):**
- `plate.auth.invitation.expiration-days=3``InvitationService.create(...)` produces
`expires_at ≈ now + 3 days` (±2s)
- `plate.auth.invitation.expiration-days=14``expires_at ≈ now + 14 days`
- `plate.auth.invitation.expiration-days=0` → fails startup (`@Min(1)` violated)
- `plate.auth.invitation.expiration-days=100` → fails startup (`@Max(90)` violated)
- No config → default 7 days (v0.1 behavior preserved)
- **Then:** Each scenario produces the expected result. Startup failures throw
`BindValidationException` with the property path `plate.auth.invitation.expiration-days`.
---
## 4. Integration tests (backend, Testcontainers)
Same strategy as v0.1: `@SpringBootTest` + `PostgreSQLContainer` (Testcontainers Postgres 16).
### T-IT10 — MS Entra ID exchange flow
- **Class:** `de.platesoft.auth.exchange.MicrosoftEntraExchangeIT`
- **Given:** Starter booted with `plate.auth.providers.microsoft.enabled=true`. MS JWKS endpoint
mocked (wiremock or mock-server). A pre-seeded `UserIdentity(provider=MICROSOFT,
subject=<oid>, tenant_id=<tid>)` exists.
- **When:** POST `/api/auth/exchange` with envelope `{provider="microsoft", providerSubject=<oid>,
email=user@test.de, tenantId=<tid>, nonce=<uuid>, iat=<now>}` signed with the exchange secret.
- **Then:** 200 response with `accessToken` (valid JWT for the seeded user) + `refreshToken` +
`user` + `memberships`. The existing Google exchange flow (T-IT03) is unaffected — verified by
running both in the same test class.
### T-IT11 — Invitation mailer sends real email (GreenMail)
- **Class:** `de.platesoft.auth.invitation.InvitationMailerIT`
- **Given:** Starter booted with `plate.auth.mail.enabled=true` + GreenMail SMTP test server
(`greenmail-smtp` Testcontainer or embedded GreenMail). Admin invites `newuser@test.de`.
- **When:** `InvitationService.create(adminId, "newuser@test.de", orgType, orgId, MEMBER)`.
- **Then:** GreenMail inbox for `newuser@test.de` contains exactly one email. Subject contains the
org display name. Body contains the accept URL. HTML + plain-text multipart present.
- **Also:** With `plate.auth.mail.enabled=false` (default), no email is sent —
`LoggingInvitationMailer` logs instead. Verified by a second scenario.
### T-IT12 — RFC 7807 Problem Details on HTTP layer
- **Class:** `de.platesoft.auth.config.ProblemDetailResponseIT`
- **Given:** Full Spring Boot test context with Testcontainers Postgres.
- **When:**
- POST `/api/auth/exchange` with tampered HMAC signature
- POST `/api/auth/login` with wrong password
- POST `/api/auth/exchange` with replayed nonce
- **Then:** Each response has `Content-Type: application/problem+json`. JSON body contains `type`,
`title`, `status`, `detail`, `instance`. `status` matches expected HTTP code (401, 401, 409
respectively). No stack trace or SQL in the body. No `exception` field leaked.
### T-IT13 — Multi-provider exchange (Google + MS + email)
- **Class:** `de.platesoft.auth.exchange.MultiProviderExchangeIT`
- **Given:** Starter booted with all three providers enabled. MS JWKS mocked. Email provider
configured.
- **When:** Three separate exchange requests: `{provider="google"}`,
`{provider="microsoft"}`, `{provider="email"}`.
- **Then:** Each returns 200 with valid JWT. Three distinct `UserIdentity` rows created with
different `provider` values. Each user's `LoginEvent` row has the correct `provider` field.
---
## 5. Security tests
### T-SEC11 — Email enumeration guard (magic-link)
- **Test:** Request magic link for `existing@test.de` (user exists) and `nonexistent@test.de`
(no user). Both via the email magic-link flow.
- **Then:** **Both** responses are identical — same status code (200), same body
("If an account exists for this email, a sign-in link has been sent"). No timing difference
> 50ms between the two. A magic link is sent only for the existing email (verified via mailer
mock). No information leak that distinguishes existing from non-existing users.
### T-SEC12 — Magic-link token replay rejected
- **Test:** Complete a magic-link sign-in (receive token, click, exchange). Then attempt to reuse
the same magic-link token for a second sign-in.
- **Then:** Second use of the token is rejected — NextAuth Email provider tokens are single-use.
The exchange envelope nonce is also deduped by `ExchangeService` (v0.1 protection). No duplicate
`LoginEvent` row created.
### T-SEC13 — Problem Details no information leak
- **Test:** Trigger various error conditions (tampered HMAC, expired JWT, SQL-injection probe on
`/auth/login`, invalid invitation token). Capture the raw HTTP response body.
- **Then:** Response body (`application/problem+json`) contains **none** of:
- Stack traces (`at de.platesoft...`)
- SQL fragments (`SELECT`, `WHERE`, table names)
- Internal class names (`org.hibernate...`, `com.zaxxer...`)
- Environment variables or secrets
- Full JWT tokens
- **Method:** Regex scan of the response body against a denylist pattern. Fails if any pattern
matches.
---
## 6. Frontend unit tests (`@platesoft/auth`)
Vitest + jsdom (same as v0.1). v0.2 tests live alongside v0.1 tests in the same test directories.
### T-FE06 — Microsoft provider snapshot
- **Spec:** `microsoft-provider.test.ts`
- **Given:** `createAuthConfig({ providers: { microsoft: { clientId: 'x', clientSecret: 'y',
tenantId: 'common' } } })`.
- **Then:** Returned NextAuth config `providers` array contains a Microsoft Entra ID provider
entry. The `signIn` callback builds an envelope with `provider="microsoft"`. The provider has
`tenantId: 'common'`.
### T-FE07 — Email provider + email-linking guard
- **Spec:** `email-provider.test.ts`
- **Given:** `createAuthConfig({ providers: { email: { server: 'smtp://...', from:
'noreply@test.de' } } })`.
- **Then:** Returned config contains an Email provider entry.
`allowDangerousEmailAccountLinking` is `false` in the provider config — verified by reading the
provider object from the returned config. If a consumer tries to set it `true`, the factory
overrides it to `false` and logs a WARN.
### T-FE08 — Type exports resolve correctly
- **Spec:** `type-exports.test.ts`
- **Then:** All of the following resolve without TypeScript errors:
```ts
import { Membership, MembershipRole, MembershipStatus,
Invitation, InvitationStatus,
AccessRequest, AccessRequestStatus,
TokenResponse, PlateAuthUser } from '@platesoft/auth';
```
`useMemberships()` return type is `Membership[]` (not `any`). `useAccessToken()` return type is
`string | null`. Verified by `tsc --noEmit` against a test file that uses these types.
### T-FE09 — Zod schemas validate correctly
- **Spec:** `zod-schemas.test.ts`
- **Then:**
- `ExchangeEnvelopeSchema.parse(validEnvelope)` succeeds
- `ExchangeEnvelopeSchema.parse({ provider: 'invalid' })` throws (invalid enum)
- `ExchangeEnvelopeSchema.parse({ nonce: 'not-a-uuid' })` throws
- `TokenResponseSchema.parse(validTokenResponse)` succeeds
- `TokenResponseSchema.parse({ accessToken: 123 })` throws (wrong type)
- The exchange client (`exchangeWithBackend`) calls `TokenResponseSchema.parse()` on the
backend response — verified by mocking the fetch and checking that a malformed response throws.
### T-FE10 — Edge-runtime safe useAccessToken()
- **Spec:** `edge-runtime.test.ts`
- **Given:** Test environment configured with `@edge-runtime/vm` (or vitest Edge environment).
- **When:** `import { useAccessToken } from '@platesoft/auth/client'` in Edge runtime. Call
`useAccessToken()`.
- **Then:** Does **not** throw. Returns `string | null`. No Node-only API (`getSession()`,
`cookies()`, `headers()`) is imported in the module graph — verified by bundle analysis (the Edge
entry point contains no `import ... from 'next/headers'` or similar).
- **Also:** In browser/jsdom environment, `useAccessToken()` still works via `useSession()` (v0.1
behavior preserved).
---
## 7. End-to-end regression (InspectFlow)
### T-E2E07 — InspectFlow MS Entra sign-in (post-v0.2 upgrade)
- **Spec:** InspectFlow Playwright suite (new scenario added for v0.2)
- **Given:** InspectFlow migrated to plate-auth v0.2.0 with MS Entra ID enabled.
`plate.auth.providers.microsoft.enabled=true`.
- **When:** A user with an existing `provider=microsoft` identity (seeded from InspectFlow's
pre-migration data) signs in via Microsoft.
- **Then:** OAuth callback → exchange → JWT issued. User lands on dashboard with their existing
memberships. No data loss — the `UserIdentity` row from v0.1 (same `subject` + `tenant_id`) is
matched, not duplicated.
- **Pass criterion (B1 + B10):** This test must be green. If it fails, it means the MS Entra
provider doesn't match InspectFlow's existing identities — this is a **release blocker**.
**All v0.1 E2E tests (T-E2E01..06) must also remain green** — v0.2 must not regress InspectFlow.
---
## 8. Acceptance criteria → tests matrix
Mapping back to [Sprint-1-Plan.md §11](Sprint-1-Plan.md):
| B# | Acceptance criterion | Test IDs |
|----|---------------------|----------|
| B1 | MS Entra ID provider works | T-IT10, T-IT13, T-FE06, T-E2E07 |
| B2 | Email magic-link works | T-IT13, T-FE07, T-SEC11, T-SEC12 |
| B3 | LoginEventSink SPI fires async | T-UT16 |
| B4 | Real InvitationMailer sends email | T-UT17, T-UT18, T-IT11 |
| B5 | RFC 7807 Problem Details | T-UT19, T-IT12, T-SEC13 |
| B6 | Configurable invitation expiration | T-UT20 |
| B7 | TS types + Zod schemas exported | T-FE08, T-FE09 |
| B8 | Edge-runtime safe hooks | T-FE10 |
| B9 | Both artifacts published at 0.2.0 | `mvn dependency:get` + `npm view` |
| B10 | All v0.1 tests still pass | T-UT01..15, T-IT01..09, T-SEC01..09, T-FE01..05, T-E2E01..07 |
Every v0.2 acceptance criterion has at least one mapped test. **B10** is the regression gate —
all 43 v0.1 tests + the 18 new v0.2 tests must pass.
---
## 9. Test data + fixtures (incremental)
Building on v0.1's fixtures ([Sprint-0-Testplan §10](Sprint-0-Testplan.md)):
| Fixture | Purpose |
|---------|---------|
| MS JWKS mock (wiremock) | T-IT10, T-IT13 — mock the Microsoft Entra JWKS endpoint with a test key pair |
| GreenMail SMTP container | T-IT11 — in-memory mail server for real mailer tests |
| `RecordingLoginEventSink` | T-UT16 — test sink that captures `emit()` calls and can simulate delay/failure |
| Zod test fixtures | T-FE09 — valid + invalid envelope/token-response JSON for schema validation tests |
| Edge-runtime config | T-FE10 — vitest config with `@edge-runtime/vm` environment |
---
## 10. Test infrastructure (incremental)
| Layer | Tooling | v0.2 additions |
|-------|---------|----------------|
| Backend unit | JUnit 5 + Mockito + AssertJ | Same as v0.1 |
| Backend integration | `@SpringBootTest` + Testcontainers Postgres 16 + **GreenMail** | + GreenMail for mailer ITs |
| MS Entra mock | **WireMock** or mock-server | New — mocks MS JWKS endpoint |
| Frontend unit | Vitest + jsdom | Same |
| Frontend Edge | `@edge-runtime/vm` | New — Edge-runtime test environment |
| E2E | InspectFlow Playwright suite | + T-E2E07 MS Entra scenario |
| CI | Gitea Actions `ci.yml` | Runs v0.1 + v0.2 tests on every push |
---
## 11. Open issues / risks for the test plan
| ID | Issue | Mitigation |
|----|-------|-----------|
| TR-6 | MS Entra JWKS mock must match real MS token format | Use a test key pair + jose library to sign realistic JWTs. Verify against a real MS Entra test tenant in a manual smoke test before v0.2.0 tag |
| TR-7 | GreenMail SMTP in Testcontainers adds startup time | Share the GreenMail container across test classes (static container, same as PostgresContainer pattern) |
| TR-8 | Edge-runtime test environment may not exist for vitest yet | If `@edge-runtime/vm` is unavailable, use `vitest` with `environment: 'edge-runtime'` or fall back to a bundle-analysis test (check the output for Node-only imports) |
| TR-9 | InspectFlow MS Entra E2E requires a real MS Entra test tenant | Manual smoke test before the automated E2E. The automated test can mock MS Entra for CI; the manual test validates against the real provider |
| TR-10 | Wire-version bump (if triggered) changes exchange contract mid-sprint | Lock the wire-version decision early (W6-2). If bumped, update T-IT03 + T-IT10..13 to send the correct `wireVersion` field. |
---
## 12. Out of scope for Sprint 1
Deferred to v0.3+:
- Multi-replica nonce store tests (Redis-backed) → v0.3
- Refresh-token rotation tests (T-SEC10 from v0.1) → v0.3
- WebAuthn / passkey provider tests → possibly v0.2 stretch
- Per-app branding tests → deferred
- Load testing > 1000 req/s → not a v0.x concern
- Penetration testing (formal) → v1.0
---
## 13. Cross-references
- [Sprint-1-Plan.md](Sprint-1-Plan.md) — implementation plan
- [Sprint-1-Assessment.md](Sprint-1-Assessment.md) — priority classification + risks
- [Sprint-0-Testplan.md](Sprint-0-Testplan.md) — v0.1 test plan (regression baseline)
- [Architecture.md](Architecture.md) — SPI model, wire contract
- [Roadmap.md](Roadmap.md) — v0.2 scope
- [Open-Questions.md](Open-Questions.md) — Q02, Q04, Q12 deferrals
---
**End of Sprint-1-Testplan.md (v1).**