feat(portal-bff): /auth/callback route — token exchange + amr check #107

Merged
julien merged 1 commits from feat/portal-bff/auth-callback-route into main 2026-05-12 12:16:40 +02:00
Owner

Summary

Fourth step of ADR-0009 wiring. Closes the OIDC round-trip on the BFF side (modulo session persistence — that's the next PR per ADR-0010). Entra now redirects the user back to GET /api/auth/callback; the BFF verifies the state, exchanges the code for tokens via MSAL's acquireTokenByCode, runs the ADR-0011 amr sanity-check, logs the resolved identity to Pino, clears the single-use pre-auth cookie, and 302s the user back to the SPA.

What lands

  • auth.errors.ts — discriminated-union AuthCodeFlowError (state-mismatch / flow-expired / amr-missing / token-exchange-failed) + AuthCodeFlowException wrapper. The kind field doubles as the ?auth_error=<code> query param on the SPA-bound redirect so the front-end can render an exact message without duplicating the string set.
  • AuthService.completeAuthCodeFlow(code, state, preAuth, now?) — verifies state binding, refuses cookies older than the 5-minute flow TTL, calls MSAL Node's acquireTokenByCode with the stored verifier, validates amr is non-empty (the BFF sanity-check per ADR-0011 — Entra Conditional Access on the org side does the real enforcement), extracts oid / tid / preferred_username / name / amr into an AuthenticatedUser shape.
  • auth.cookie.ts gains clearPreAuthCookieOptions() mirroring the set-options minus maxAge so the browser actually drops the cookie. (Cookies match by name + path + secure; getting any of those wrong leaves the old cookie in place.)
  • AuthController.callback()@Get('callback'). Always clears the cookie first (single-use). Bails on Entra-side errors (?error=), missing query params, missing or malformed cookie — each branch logs a structured Pino warning and redirects with the right auth_error code. On AuthCodeFlowException, logs + redirects with the typed kind. On success, logs an auth.signed_in event with oid, tid, username, amr (PII-sensitive bits only; no tokens), then 302s to entra.postLogoutRedirectUri.

Decisions worth flagging

  • postLogoutRedirectUri reused as the SPA root URL. Semantically a tiny stretch (its OIDC role is the post-logout destination) but the value is the same. Avoids one more env var until / unless the two URLs need to diverge.
  • Cookie cleared FIRST, before any branching. Single-use is a property we want guaranteed regardless of which path exits the handler — overlap with a parallel /login from the same browser session would otherwise leak a usable cookie.
  • auth.signed_in logged via Pino, not via the audit module. ADR-0013 wants this in the audit table; pairing audit with the session that ships in the next PR keeps the audit row carrying a session_id (otherwise it'd reference a "phantom" auth event with no follow-up).
  • amr non-empty is the BFF's check; the Conditional Access policy is what enforces "MFA happened". ADR-0011 explicitly factors it this way — empty amr would indicate a policy misconfiguration where MFA never fired.

Verification

  • nx run-many -t lint test build --projects=portal-bff — green.
  • 52 / 52 specs (was 39; +13 across the new completeFlow branches and callback branches).
  • Service spec covers happy path + 6 failure modes (state mismatch, flow expired, amr missing, MSAL throws, MSAL returns null, oid claim missing).
  • Controller spec covers happy redirect, Entra error, missing cookie, AuthCodeFlowException branch, missing query, malformed cookie.

Manual smoke test (end-to-end)

  1. apps/portal-bff/.env carries real ENTRA_* + SESSION_SECRET.
  2. nx serve portal-bff and nx serve portal-shell.
  3. Open http://localhost:3000/api/auth/login → redirects to Entra.
  4. Authenticate. Entra redirects to http://localhost:3000/api/auth/callback?code=…&state=….
  5. BFF processes; redirects to http://localhost:4200/. Pino log shows auth.signed_in with the user's oid, tid, username, amr.
  6. Tamper test: open the link again, hand-edit the state= in the callback URL → BFF redirects with ?auth_error=state-mismatch.

What this PR explicitly does NOT do

  • Persist a session. The user is "authenticated" from the BFF's point of view (identity resolved + logged) but the next request lands anonymous. Closes in the Redis sessions PR per ADR-0010.
  • Audit log entry. Pairs with sessions so the row carries a session_id.
  • Logout / /me. Land after sessions.
## Summary Fourth step of ADR-0009 wiring. Closes the OIDC round-trip on the BFF side (modulo session persistence — that's the next PR per ADR-0010). Entra now redirects the user back to `GET /api/auth/callback`; the BFF verifies the state, exchanges the code for tokens via MSAL's `acquireTokenByCode`, runs the ADR-0011 `amr` sanity-check, logs the resolved identity to Pino, clears the single-use pre-auth cookie, and 302s the user back to the SPA. ## What lands - **[`auth.errors.ts`](apps/portal-bff/src/auth/auth.errors.ts)** — discriminated-union `AuthCodeFlowError` (`state-mismatch` / `flow-expired` / `amr-missing` / `token-exchange-failed`) + `AuthCodeFlowException` wrapper. The `kind` field doubles as the `?auth_error=<code>` query param on the SPA-bound redirect so the front-end can render an exact message without duplicating the string set. - **[`AuthService.completeAuthCodeFlow(code, state, preAuth, now?)`](apps/portal-bff/src/auth/auth.service.ts)** — verifies state binding, refuses cookies older than the 5-minute flow TTL, calls MSAL Node's `acquireTokenByCode` with the stored verifier, validates `amr` is non-empty (the BFF sanity-check per ADR-0011 — Entra Conditional Access on the org side does the real enforcement), extracts `oid` / `tid` / `preferred_username` / `name` / `amr` into an `AuthenticatedUser` shape. - **[`auth.cookie.ts`](apps/portal-bff/src/auth/auth.cookie.ts)** gains `clearPreAuthCookieOptions()` mirroring the set-options minus `maxAge` so the browser actually drops the cookie. (Cookies match by name + path + secure; getting any of those wrong leaves the old cookie in place.) - **[`AuthController.callback()`](apps/portal-bff/src/auth/auth.controller.ts)** — `@Get('callback')`. Always clears the cookie first (single-use). Bails on Entra-side errors (`?error=`), missing query params, missing or malformed cookie — each branch logs a structured Pino warning and redirects with the right `auth_error` code. On `AuthCodeFlowException`, logs + redirects with the typed `kind`. On success, logs an `auth.signed_in` event with `oid`, `tid`, `username`, `amr` (PII-sensitive bits only; no tokens), then 302s to `entra.postLogoutRedirectUri`. ## Decisions worth flagging - **`postLogoutRedirectUri` reused as the SPA root URL.** Semantically a tiny stretch (its OIDC role is the post-logout destination) but the value is the same. Avoids one more env var until / unless the two URLs need to diverge. - **Cookie cleared FIRST**, before any branching. Single-use is a property we want guaranteed regardless of which path exits the handler — overlap with a parallel /login from the same browser session would otherwise leak a usable cookie. - **`auth.signed_in` logged via Pino, not via the audit module.** ADR-0013 wants this in the audit table; pairing audit with the session that ships in the next PR keeps the audit row carrying a `session_id` (otherwise it'd reference a "phantom" auth event with no follow-up). - **`amr` non-empty is the BFF's check; the Conditional Access policy is what enforces "MFA happened".** ADR-0011 explicitly factors it this way — empty `amr` would indicate a policy misconfiguration where MFA never fired. ## Verification - `nx run-many -t lint test build --projects=portal-bff` — green. - **52 / 52 specs** (was 39; +13 across the new completeFlow branches and callback branches). - Service spec covers happy path + 6 failure modes (state mismatch, flow expired, amr missing, MSAL throws, MSAL returns null, oid claim missing). - Controller spec covers happy redirect, Entra error, missing cookie, AuthCodeFlowException branch, missing query, malformed cookie. ## Manual smoke test (end-to-end) 1. `apps/portal-bff/.env` carries real `ENTRA_*` + `SESSION_SECRET`. 2. `nx serve portal-bff` and `nx serve portal-shell`. 3. Open `http://localhost:3000/api/auth/login` → redirects to Entra. 4. Authenticate. Entra redirects to `http://localhost:3000/api/auth/callback?code=…&state=…`. 5. BFF processes; redirects to `http://localhost:4200/`. Pino log shows `auth.signed_in` with the user's `oid`, `tid`, `username`, `amr`. 6. Tamper test: open the link again, hand-edit the `state=` in the callback URL → BFF redirects with `?auth_error=state-mismatch`. ## What this PR explicitly does NOT do - **Persist a session.** The user is "authenticated" from the BFF's point of view (identity resolved + logged) but the next request lands anonymous. Closes in the Redis sessions PR per ADR-0010. - **Audit log entry.** Pairs with sessions so the row carries a `session_id`. - **Logout / `/me`.** Land after sessions.
julien added 1 commit 2026-05-12 12:16:11 +02:00
feat(portal-bff): /auth/callback route — token exchange + amr check
CI / commits (pull_request) Successful in 3m15s
CI / scan (pull_request) Successful in 3m24s
CI / check (pull_request) Successful in 3m33s
CI / a11y (pull_request) Successful in 2m26s
CI / perf (pull_request) Successful in 5m48s
37a97e2c31
Fourth step of ADR-0009 wiring. Closes the OIDC round-trip on the
BFF side (modulo session persistence — that's the next PR per
ADR-0010). Entra redirects the user back to `/api/auth/callback`
with a `code` + `state`; the BFF verifies the state, exchanges the
code for tokens via MSAL Node's `acquireTokenByCode`, runs the
ADR-0011 `amr` sanity-check, logs the resolved identity to Pino,
clears the single-use pre-auth cookie, and 302s the user back to
the SPA.

What lands:

- `apps/portal-bff/src/auth/auth.errors.ts` — discriminated-union
  `AuthCodeFlowError` (state-mismatch / flow-expired / amr-missing
  / token-exchange-failed) + `AuthCodeFlowException` wrapper. The
  `kind` field doubles as the `?auth_error=<code>` query param on
  the SPA-bound redirect, so the front-end can render an exact
  message without duplicating the string set.
- `AuthService.completeAuthCodeFlow(code, state, preAuth, now?)` —
  verifies state binding, refuses cookies older than the 5-minute
  flow TTL, calls MSAL, validates `amr` is non-empty (ADR-0011 BFF
  sanity-check; Conditional Access on the org side is the real
  enforcement), extracts the four `oid` / `tid` /
  `preferred_username` / `name` claims plus the `amr` array into
  an `AuthenticatedUser` shape.
- `auth.cookie.ts` gains `clearPreAuthCookieOptions()` mirroring
  the set-options minus `maxAge` so the browser actually drops the
  cookie (cookies match by name + path + secure; getting any of
  those wrong leaves the old cookie in place).
- `AuthController.callback()` — `@Get('callback')`:
  1. Always `res.clearCookie(...)` first. The cookie is single-use
     by design; a parallel /login overlap should not survive.
  2. Bail on Entra-side errors (`?error=`), missing query params,
     missing or malformed pre-auth cookie. Each branch logs a
     structured Pino warning and redirects with the right
     `auth_error` code.
  3. Call the service. On `AuthCodeFlowException`, log + redirect
     with the typed `kind`.
  4. On success, log a `auth.signed_in` event with `oid`, `tid`,
     `username`, `amr` (PII-sensitive bits only; no tokens), then
     302 to `entra.postLogoutRedirectUri` (reused as the SPA root
     URL — a dedicated `SPA_BASE_URL` env var lands if the two
     URLs ever need to diverge).
- The controller now takes `Logger` + `ENTRA_CONFIG` via DI
  alongside `AuthService`.

Verification:

- `nx run-many -t lint test build --projects=portal-bff` — green.
- 52/52 specs (was 39; +13 across the new service-completeFlow spec
  branches and the controller-callback spec branches). Service
  spec covers happy path + 6 failure modes (state mismatch, flow
  expired, amr missing, MSAL throws, MSAL returns null, oid claim
  missing). Controller spec covers happy redirect, Entra error
  branch, missing cookie, AuthCodeFlowException branch, missing
  query, malformed cookie.

What this PR explicitly does NOT do:

- Persist a session. The user is "authenticated" in the BFF's
  point of view (we have their identity) but the next request lands
  anonymous. Closes in the Redis sessions PR (ADR-0010).
- Audit log entry. The audit module is in place; wiring the
  `auth.signed_in` event into it lands alongside sessions so the
  audit row carries a `session_id`.
- Logout / `/me`. Land after sessions.
julien merged commit c50794eceb into main 2026-05-12 12:16:40 +02:00
julien deleted branch feat/portal-bff/auth-callback-route 2026-05-12 12:16:43 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: julien/apf_portal#107