feat(portal-bff): close the auth loop — callback persists session, /me, RP-initiated /logout #112

Merged
julien merged 1 commits from feat/portal-bff/auth-session-me-logout into main 2026-05-12 19:46:39 +02:00
Owner

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

  • pnpm nx test portal-bff110/110 pass (was 99 before this PR; +11 specs across auth.controller.spec.ts and auth.service.spec.ts).
  • pnpm nx lint portal-bff → clean.
  • pnpm nx build portal-bff → webpack compiled successfully.
  • 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.
## 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. - [x] Manual end-to-end smoke test: - [x] `/api/auth/login` → Entra → back at `/api/auth/callback` → session cookie set, redirect to SPA. - [x] `/api/auth/me` → 200 JSON when authenticated, 401 when anonymous. - [x] `/api/auth/logout` → Redis key gone, cookie cleared, lands at SPA via Entra logout.
julien added 1 commit 2026-05-12 19:45:37 +02:00
feat(portal-bff): close the auth loop — callback persists session, /me, RP-initiated /logout
CI / commits (pull_request) Successful in 2m39s
CI / scan (pull_request) Successful in 2m48s
CI / check (pull_request) Successful in 2m57s
CI / a11y (pull_request) Successful in 2m14s
CI / perf (pull_request) Successful in 5m48s
b34a22f8c1
callback now writes the resolved AuthenticatedUser into
req.session.user and waits for req.session.save() before the 302 so
the spa lands on the post-login page with a populated session
already on redis. without the awaited save, connect-redis can race
the spa's subsequent /me call.

GET /auth/me returns the curated public subset of the session user
(oid / tid / username / displayName) or 401 with
{"error":"unauthenticated"}. amr and other internal claims stay
server-side — the @requireMfa() guard (adr-0011) and the audit
pipeline (adr-0013) consume them directly off the session.

GET /auth/logout destroys the redis-side session, clears the
session cookie (sessionCookieName() so it matches what the middleware
set — __Host-portal_session in prod, portal_session in dev), then
302s to entra's /oauth2/v2.0/logout?post_logout_redirect_uri=… so
the idp-side session is killed too (rp-initiated logout per
adr-0009). id_token_hint is deliberately omitted in v1: the
id_token isn't persisted in the session yet (adr-0014 dependency),
and the account-picker is a safer default in the interim.

logout is idempotent: anonymous users get the same redirect, the
session.destroy() is a no-op. a redis-side destroy failure is
logged via pino as event=session.destroy_failed and the cookie is
cleared anyway — orphan redis keys hit their idle ttl on their own.

a small typescript module augmentation in session/session.types.ts
declares req.session.user so every consumer sees a typed payload
instead of `unknown`. side-effect imported by session.module.ts to
keep it in the compile graph.

out of scope, landing in follow-ups:
- absolute-timeout interceptor (12 h hard ceiling, adr-0010)
- user_sessions:{userId} secondary index (admin "logout everywhere")
- encrypted tokens blob in the session (id_token, access_token,
  refresh_token — adr-0014)
- csrf middleware (phase-2 security)
julien merged commit 0464ce3ac8 into main 2026-05-12 19:46:39 +02:00
julien deleted branch feat/portal-bff/auth-session-me-logout 2026-05-12 19:46:41 +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#112