Commit Graph

2 Commits

Author SHA1 Message Date
Julien Gautier 37a97e2c31 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
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.
2026-05-12 12:13:47 +02:00
julien 0eb404d111 feat(portal-bff): /auth/login route — pkce flow start + signed cookie (#105)
CI / scan (push) Successful in 2m27s
CI / commits (push) Has been skipped
CI / check (push) Successful in 3m29s
CI / a11y (push) Successful in 1m58s
CI / perf (push) Successful in 4m3s
## Summary

Third step of ADR-0009 wiring. Adds the first OIDC route, `GET /api/auth/login`: it 302s the browser to Entra's authorize endpoint with a freshly-generated state + PKCE challenge, and stashes the matching `{state, codeVerifier}` payload in a short-lived signed cookie so the next-PR callback can verify the round-trip.

## What lands

- **Cookie infra**: `cookie-parser` + `@types/express` deps; `main.ts` mounts the cookie middleware with the `SESSION_SECRET` signing key. Signed cookies are now available via `req.signedCookies` for the upcoming callback.
- **[`.env.example`](apps/portal-bff/.env.example)** promotes `SESSION_SECRET` from a future-vars comment into an active section, with a one-liner showing how to generate 32 random bytes.
- **[`check-session-secret.ts`](apps/portal-bff/src/config/check-session-secret.ts)** — boot-time guard: refuses to start if `SESSION_SECRET` is unset, still the .env.example placeholder, or decodes below 32 bytes of entropy. Same family as `check-database-url` / `check-entra-config`.
- **[`auth.service.ts`](apps/portal-bff/src/auth/auth.service.ts)** — `beginAuthCodeFlow()` uses MSAL's `CryptoProvider` for canonical PKCE verifier / challenge generation and a fresh GUID state per call, calls `msal.getAuthCodeUrl()` with the configured redirect URI + OIDC scopes (`openid profile email` — no `offline_access` in v1), and returns `{ authUrl, preAuthPayload }`.
- **[`auth.cookie.ts`](apps/portal-bff/src/auth/auth.cookie.ts)** — `portal_pre_auth` name, 5-minute TTL, shared `CookieOptions`: `signed`, `httpOnly`, `sameSite: 'lax'` (lets Entra's cross-site top-level redirect back through), `secure` toggled by `NODE_ENV`.
- **[`auth.controller.ts`](apps/portal-bff/src/auth/auth.controller.ts)** — `@Controller('auth') @Get('login')`: writes the cookie then 302s. Thin shell around the service.
- **AuthModule** registers the new controller + service alongside the existing `ENTRA_CONFIG` and `MSAL_CLIENT` providers.

## Decisions worth flagging

- **Scope deliberately stops before the callback.** It's the next PR. Clicking `/auth/login` today round-trips through Entra and lands on a 404 — bounded mid-state, documented in the commit and here.
- **State + verifier in the cookie, not in Redis.** Keeps `/login` stateless (no server-side store), which means the BFF stays horizontally scalable from day one without sticky-session config. The next-PR callback reads `req.signedCookies` to recover the payload.
- **`portal_pre_auth`, not `__Host-portal_pre_auth`.** `__Host-` mandates `Secure`, and local dev is HTTP. The prefix + `Secure: true` lands together with the production TLS hardening ADR.
- **No `offline_access` scope.** Sessions are short-lived (per ADR-0010); the user re-authenticates through Entra rather than the BFF refreshing tokens behind their back. Smaller token footprint, less code to write, easier to reason about.
- **5-minute cookie TTL.** Enough for the Entra round-trip (including a fresh MFA prompt), short enough that a stale cookie can't be replayed long after the user abandoned the flow.

## Verification

- `nx run-many -t lint test build --projects=portal-bff` — green.
- **39 / 39 specs** (was 30; +9 across `check-session-secret`, `auth.service`, `auth.controller`).
- The service spec mocks `getAuthCodeUrl`, asserts the redirect URI / scopes / S256 method, the state-verifier identity between the cookie payload and what's sent to Entra, and fresh-per-call replay protection.
- The controller spec asserts the cookie name + options + serialized payload and the 302 redirect.

## Manual smoke test (next PR completes the loop)

1. `apps/portal-bff/.env` has real `ENTRA_*` + `SESSION_SECRET`.
2. `nx serve portal-bff`.
3. `curl -i http://localhost:3000/api/auth/login` → 302 with `Set-Cookie: portal_pre_auth=…; HttpOnly; SameSite=Lax; Path=/`, `Location: https://login.microsoftonline.com/<tenant>/oauth2/v2.0/authorize?...`.
4. Open the `Location` in a browser, authenticate, Entra redirects to `http://localhost:3000/api/auth/callback?code=…&state=…` → 404 today, will be the next PR.

## Next PR on the auth track

`GET /api/auth/callback` — reads the signed cookie, verifies `state` matches, calls `acquireTokenByCode` with the stored verifier, validates the ID token (issuer, audience, exp, nonce, `amr` per ADR-0011), clears the pre-auth cookie, logs the resolved user identity, redirects to `/` (SPA). Still no session — that's the PR after.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #105
2026-05-12 11:20:03 +02:00