37a97e2c3112d02cafaaa4a01cb19be5656cfc9f
2 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
37a97e2c31 |
feat(portal-bff): /auth/callback route — token exchange + amr check
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.
|
||
|
|
0eb404d111 |
feat(portal-bff): /auth/login route — pkce flow start + signed cookie (#105)
## 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
|