5c346820ead1eca15e6273d374a59f2ea880d87c
4 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
5c346820ea |
feat(portal-bff): absolute-timeout middleware + user_sessions index per ADR-0010
closes the ttl-policy + revocation pieces left from #110. absolute-timeout middleware: every request that survives express-session runs through a new middleware that checks req.session.absoluteExpiresAt. past the 12 h hard ceiling, it destroys the redis-side session, clears the portal_session cookie, drops the entry from the per-user index, and lets the request keep flowing anonymously. route-level guards (/me today, future @requireauth) turn that into a 401 where auth is actually needed — public routes stay accessible. not the "every route returns 401" reading of adr-0010 §"ttl policy" (validated with the project lead): "returns 401" is interpreted as "the user eventually sees a 401 when they touch something that needs auth", not as a hard refusal on the current in-flight request. user_sessions:{userId} secondary index (adr-0010 §"revocation"): a new UserSessionIndexService maintains a redis set of active session ids per user. wired on /auth/callback (sadd at sign-in, after session.save() so the session row exists before the index points at it) and on /auth/logout + the absolute-timeout middleware (srem on destroy). best-effort: a failed sadd/srem logs a pino warning and the auth flow continues — the index is a convenience for admin operations, not a security invariant. orphans (entries whose session:<id> redis key has expired on idle ttl) are tolerated per the adr; future consumer code filters them out. no in-product consumer ships in this pr — the admin "logout everywhere" endpoint lands with the admin module (+ @requireadmin / @requiremfa guards). today the index is write-only on the bff side. session payload extension: createdAt and absoluteExpiresAt are now set on the session at the same moment as req.session.user, in /auth/callback. the session.types.ts declaration merging exposes them as optional SessionData fields so every consumer sees a typed payload. wiring: - SessionModule provides UserSessionIndexService and a SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE token whose factory builds the middleware via createAbsoluteTimeoutMiddleware(index, logger). same pattern as the existing SESSION_MIDDLEWARE token. - AuthModule now imports SessionModule so AuthController can inject UserSessionIndexService. - main.ts mounts the absolute-timeout middleware immediately after the session middleware. per-user identifier is the entra `oid` claim (stable per-user inside the tenant, matches req.session.user.oid). future multi-tenant scenarios may want `${tid}:${oid}` — easy refactor when external id activation lands (adr-0008). not in scope here. out of scope, landing in follow-ups: - admin "logout everywhere" endpoint consuming UserSessionIndexService.list(userId) - audit-pipeline first-class events for session.absolute_timeout + user_session_index.* (adr-0013) - token blob persistence (id_token / access_token / refresh_token) in the encrypted session — adr-0014 dependency 123/123 tests pass (was 110); +13 specs across the two new files and the auth controller spec. |
||
|
|
0464ce3ac8 |
feat(portal-bff): close the auth loop — callback persists session, /me, RP-initiated /logout (#112)
## Summary
Closes the OIDC loop end-to-end on the BFF side:
- `/auth/callback` now writes the resolved `AuthenticatedUser` into `req.session.user` and waits for `req.session.save()` before redirecting, so the SPA reaches the landing page with a populated session.
- `GET /auth/me` returns the curated public view of the session user (`oid`, `tid`, `username`, `displayName`) or `401 {"error": "unauthenticated"}`. `amr` and other internal claims stay server-side.
- `GET /auth/logout` destroys the BFF session (Redis `DEL`), clears the session cookie, and 302s to Entra's `/oauth2/v2.0/logout` so the IdP-side session is killed too — RP-initiated logout per ADR-0009.
Scope intentionally stops here: the absolute-timeout interceptor (12 h hard ceiling) and the `user_sessions:{userId}` secondary index land in dedicated follow-ups.
## Notable choices
**`req.session.save()` is awaited before the redirect.** Express-session writes to its store on response end; emitting the 302 closes the response before `connect-redis` finishes the write, so without an explicit await the browser can race the SPA into requesting `/me` against a missing key. Awaiting `save()` is the documented fix.
**Logout via `GET`.** Matches `/login` (also `GET`) and keeps the UX a plain anchor / top-level navigation. The CSRF surface is mitigated by `SameSite=Lax` on the session cookie — cross-site subresource requests (`<img src>`, `fetch`) don't carry it. A dedicated CSRF middleware lands with phase-2 security; if we want POST-only logout earlier, easy follow-up.
**`/me` strips `amr`.** The session payload mirrors `AuthenticatedUser` (used internally by the future `@RequireMfa()` guard, ADR-0011), but the SPA only ever needs the curated subset. Mapping happens in the controller — no leak by default.
**Logout URL skips `id_token_hint`.** ADR-0009 mentions it for single-account logout UX, but v1 doesn't persist the `id_token` in the session yet (the encrypted `tokens` blob lands with downstream API support per ADR-0014). Without `id_token_hint`, Entra shows an account picker — the conservative default until token persistence ships.
**Cookie name in logout.** Uses `sessionCookieName()` from `session/session-cookie.ts` so logout clears the same cookie the middleware sets — `__Host-portal_session` in prod, `portal_session` in dev.
## Out of scope (next PRs)
- Absolute-timeout interceptor (12 h hard ceiling, ADR-0010).
- `user_sessions:{userId}` secondary index for admin "logout everywhere".
- Persisting the `id_token` / `access_token` / `refresh_token` blob in the encrypted session (ADR-0014 dependency).
- CSRF middleware (phase-2 security).
- Renaming `ENTRA_POST_LOGOUT_REDIRECT_URI` if we want a distinct post-login redirect target — for now both flows land on the same SPA URL.
## Test plan
- [x] `pnpm nx test portal-bff` → **110/110 pass** (was 99 before this PR; +11 specs across `auth.controller.spec.ts` and `auth.service.spec.ts`).
- [x] `pnpm nx lint portal-bff` → clean.
- [x] `pnpm nx build portal-bff` → webpack compiled successfully.
- [x] Prettier-clean on all touched files.
- [ ] Manual end-to-end smoke test:
- [ ] `/api/auth/login` → Entra → back at `/api/auth/callback` → session cookie set, redirect to SPA.
- [ ] `/api/auth/me` → 200 JSON when authenticated, 401 when anonymous.
- [ ] `/api/auth/logout` → Redis key gone, cookie cleared, lands at SPA via Entra logout.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #112
|
||
|
|
c50794eceb |
feat(portal-bff): /auth/callback route — token exchange + amr check (#107)
## 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.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #107
|
||
|
|
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
|