diff --git a/Sprint-1-Assessment.md b/Sprint-1-Assessment.md new file mode 100644 index 0000000..f70b213 --- /dev/null +++ b/Sprint-1-Assessment.md @@ -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 (W1–W12) 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 §5–6](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 V1–V6 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
~1 week] + V01 --> SB[Sparkboard adopts
~3 weeks] + IF --> F1[Feedback wave 1
operational pain] + SB --> F2[Feedback wave 2
greenfield friction] + F1 --> V02[v0.2 backlog
confirmed/adjusted] + F2 --> V02 +``` + +### 2.1 InspectFlow — operational pain (week 1–2) + +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 1–3) + +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
blocks InspectFlow] + M2[W3: Real InvitationMailer
blocks invitations] + M3[W4: RFC 7807 + config expiration
blocks error UX] + end + subgraph Nice[Nice-to-have — smooths DX] + N1[W2: Email magic-link
Sparkboard option] + N2[W3: LoginEventSink SPI
audit shipping] + N3[W3: AccessRequestMailer template] + N4[W5: TS improvements
typed exports + Zod] + N5[W5: Edge-runtime safety] + end + subgraph Stretch[Stretch — if time allows] + S1[W6: Wire-version bump
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** | **W1–W6** | **~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 V1–V6 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.* diff --git a/Sprint-1-Plan.md b/Sprint-1-Plan.md new file mode 100644 index 0000000..bdbc73f --- /dev/null +++ b/Sprint-1-Plan.md @@ -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, W1–W6) +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/` 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
+ IT + publish] + W2[W2: Email magic-link] --> W6 + W3[W3: LoginEventSink SPI
+ real mailers] --> W6 + W4[W4: RFC 7807 Problem Details
+ config expiration] --> W6 + W5[W5: TS improvements
+ 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 | W1–W5 | ~1 day | +| | **Total** | | | **~6 days** | + +W1–W5 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=, tenant_id=` 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 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 1–90 + ``` + +**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.* diff --git a/Sprint-1-Testplan.md b/Sprint-1-Testplan.md new file mode 100644 index 0000000..8101665 --- /dev/null +++ b/Sprint-1-Testplan.md @@ -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=`, `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=, tenant_id=)` exists. +- **When:** POST `/api/auth/exchange` with envelope `{provider="microsoft", providerSubject=, + email=user@test.de, tenantId=, nonce=, iat=}` 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).**