feat(portal-bff): /auth/callback route — token exchange + amr check #107
Reference in New Issue
Block a user
Delete Branch "feat/portal-bff/auth-callback-route"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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'sacquireTokenByCode, runs the ADR-0011amrsanity-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-unionAuthCodeFlowError(state-mismatch/flow-expired/amr-missing/token-exchange-failed) +AuthCodeFlowExceptionwrapper. Thekindfield 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'sacquireTokenByCodewith the stored verifier, validatesamris non-empty (the BFF sanity-check per ADR-0011 — Entra Conditional Access on the org side does the real enforcement), extractsoid/tid/preferred_username/name/amrinto anAuthenticatedUsershape.auth.cookie.tsgainsclearPreAuthCookieOptions()mirroring the set-options minusmaxAgeso 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 rightauth_errorcode. OnAuthCodeFlowException, logs + redirects with the typedkind. On success, logs anauth.signed_inevent withoid,tid,username,amr(PII-sensitive bits only; no tokens), then 302s toentra.postLogoutRedirectUri.Decisions worth flagging
postLogoutRedirectUrireused 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.auth.signed_inlogged 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 asession_id(otherwise it'd reference a "phantom" auth event with no follow-up).amrnon-empty is the BFF's check; the Conditional Access policy is what enforces "MFA happened". ADR-0011 explicitly factors it this way — emptyamrwould indicate a policy misconfiguration where MFA never fired.Verification
nx run-many -t lint test build --projects=portal-bff— green.Manual smoke test (end-to-end)
apps/portal-bff/.envcarries realENTRA_*+SESSION_SECRET.nx serve portal-bffandnx serve portal-shell.http://localhost:3000/api/auth/login→ redirects to Entra.http://localhost:3000/api/auth/callback?code=…&state=….http://localhost:4200/. Pino log showsauth.signed_inwith the user'soid,tid,username,amr.state=in the callback URL → BFF redirects with?auth_error=state-mismatch.What this PR explicitly does NOT do
session_id./me. Land after sessions.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.