feat(portal-bff): distinct admin session + /api/admin/auth flow #129

Merged
julien merged 1 commits from feat/portal-bff-admin-session into main 2026-05-14 02:21:47 +02:00
Owner

Summary

Phase-3a step per ADR-0020 §"Sessions — distinct from portal-shell". Wires a second express-session middleware on /api/admin/* carrying __Host-portal_admin_session over Redis prefix session:admin:, and ships the parallel /api/admin/auth/{login,callback,me,logout} flow that populates it. Signing in to one surface no longer signs the user into the other — Entra SSO at the IdP level still preserves the click-through.

What lands

Session middlewares — path-routed dispatch

Token Cookie Redis prefix Bound to
SESSION_MIDDLEWARE portal_session / __Host-portal_session session: every path except /api/admin/*
ADMIN_SESSION_MIDDLEWARE portal_admin_session / __Host-portal_admin_session session:admin: /api/admin/* only

Implemented via a buildSessionMiddleware(redis, logger, opts) factory in session.module.ts — the TTL policy, encryption key, signing secret, session-id entropy, and serializer error-handling all come from the same source. Only the cookie name + Redis key prefix differ.

The dispatch in main.ts is a tiny (req, res, next) => req.path.startsWith('/api/admin') ? adminSession(...) : userSession(...). Running both middlewares unconditionally would have the second overwrite req.session from the first, collapsing the two surfaces.

Distinct admin auth flow

AdminAuthController mounts /api/admin/auth/{login,callback,me,logout}. Structurally identical to AuthController but passes adminRedirectUri / adminPostLogoutRedirectUri and clears the admin session cookie on logout. me exposes the roles claim (admin SPA needs it for conditional UI); the user-portal me intentionally still doesn't.

Shared SessionEstablisher (no controller duplication)

SessionEstablisher encapsulates the session lifecycle so both controllers stay thin:

  • establish({ user, req, res, surface }) — mints CSRF, populates user / createdAt / absoluteExpiresAt / csrfToken / mfaVerifiedAt, saves, sets the CSRF cookie, registers in user_sessions index, emits auth.sign_in audit (blocking), logs with the surface tag.
  • destroy({ actor, req }) — when actor is set, removes from index + emits auth.sign_out; always destroys the session with Redis-hiccup tolerance.

No code duplicated between the two surfaces — the only per-surface differences are the redirect URIs (passed in) and the cookie names cleared on logout (controller-local).

Entra config gains two URIs

EntraConfig adds adminRedirectUri + adminPostLogoutRedirectUri, validated at boot in check-entra-config.ts. The validator refuses to start when ENTRA_ADMIN_REDIRECT_URI === ENTRA_REDIRECT_URI — that misconfiguration would silently collapse the two surfaces into one session. Both URIs must be registered on the same Entra app registration's "Redirect URIs" list.

AuthService API change

beginAuthCodeFlow(redirectUri), completeAuthCodeFlow(code, state, preAuth, redirectUri, now?), and buildLogoutUrl(postLogoutRedirectUri) now take their URI as a parameter. Callers (user-portal vs admin-portal controllers) pick which set to pass.

Required ops action before this PR can run locally

Two new mandatory env vars. The BFF refuses to start without them.

ENTRA_ADMIN_REDIRECT_URI=http://localhost:3000/api/admin/auth/callback
ENTRA_ADMIN_POST_LOGOUT_REDIRECT_URI=http://localhost:4201/

The example values land in apps/portal-bff/.env.example for reference. The corresponding Entra app registration also needs /api/admin/auth/callback added to its "Redirect URIs" list before any admin sign-in works end-to-end.

Notes for the reviewer

  • The user-portal callback's post-login redirect still targets postLogoutRedirectUri (existing quirk where the post-auth and post-logout landing happen to be the same URL). The admin callback mirrors the pattern for adminPostLogoutRedirectUri. Splitting these into dedicated post-login URIs is a separate ADR/PR.
  • AdminModule now imports AuthModule to consume AuthService, SessionEstablisher, and ENTRA_CONFIG. AuditWriter and RequireMfaGuard come through transitively.
  • Existing AuthController spec assertions are preserved through the refactor by constructing a real SessionEstablisher in the test fixture with the same audit / index / logger mocks. No behavioural assertion was removed — the inline session-state-setting logic is now exercised through the establisher.
  • The pre-existing docstring in check-entra-config.ts line 11-16 still says "the two redirect URIs are mandatory once the OIDC routes ship (next PR)" — stale, the routes have shipped. Not touched in this PR to keep the diff focused; can be a one-line doc PR later.

Test plan

  • pnpm nx test portal-bff278 specs pass (was 253; +25: admin cookie 3, session-establisher 11, admin auth controller 9, entra config 2).
  • pnpm exec nx affected -t format:check lint test build --base=origin/main — clean (the pre-existing _res / _next warnings in rate-limit.middleware.ts are unrelated).
  • Entra config validator: both URIs required, both URL-validated, equality refused.
  • Path-dispatch verified by routing — /api/admin/me and /api/admin/auth/* see the admin session; everything else sees the user session.
  • e2e — pending env var update + Entra registration update to add the admin redirect URI. Once both are in place: sign in via /api/auth/login, see portal_session cookie; clear cookies; sign in via /api/admin/auth/login, see portal_admin_session cookie; verify /api/admin/me works on the admin session and /api/auth/me works on the user session — neither sees the other's session.
## Summary Phase-3a step per [ADR-0020](docs/decisions/0020-portal-admin-app.md) §"Sessions — distinct from `portal-shell`". Wires a second `express-session` middleware on `/api/admin/*` carrying `__Host-portal_admin_session` over Redis prefix `session:admin:`, and ships the parallel `/api/admin/auth/{login,callback,me,logout}` flow that populates it. Signing in to one surface no longer signs the user into the other — Entra SSO at the IdP level still preserves the click-through. ## What lands ### Session middlewares — path-routed dispatch | Token | Cookie | Redis prefix | Bound to | | --- | --- | --- | --- | | `SESSION_MIDDLEWARE` | `portal_session` / `__Host-portal_session` | `session:` | every path **except** `/api/admin/*` | | `ADMIN_SESSION_MIDDLEWARE` | `portal_admin_session` / `__Host-portal_admin_session` | `session:admin:` | `/api/admin/*` only | Implemented via a `buildSessionMiddleware(redis, logger, opts)` factory in [session.module.ts](apps/portal-bff/src/session/session.module.ts) — the TTL policy, encryption key, signing secret, session-id entropy, and serializer error-handling all come from the same source. Only the cookie name + Redis key prefix differ. The dispatch in [main.ts](apps/portal-bff/src/main.ts) is a tiny `(req, res, next) => req.path.startsWith('/api/admin') ? adminSession(...) : userSession(...)`. Running both middlewares unconditionally would have the second overwrite `req.session` from the first, collapsing the two surfaces. ### Distinct admin auth flow [`AdminAuthController`](apps/portal-bff/src/admin/admin-auth.controller.ts) mounts `/api/admin/auth/{login,callback,me,logout}`. Structurally identical to [`AuthController`](apps/portal-bff/src/auth/auth.controller.ts) but passes `adminRedirectUri` / `adminPostLogoutRedirectUri` and clears the admin session cookie on logout. `me` exposes the `roles` claim (admin SPA needs it for conditional UI); the user-portal `me` intentionally still doesn't. ### Shared `SessionEstablisher` (no controller duplication) [`SessionEstablisher`](apps/portal-bff/src/auth/session-establisher.service.ts) encapsulates the session lifecycle so both controllers stay thin: - `establish({ user, req, res, surface })` — mints CSRF, populates `user / createdAt / absoluteExpiresAt / csrfToken / mfaVerifiedAt`, saves, sets the CSRF cookie, registers in `user_sessions` index, emits `auth.sign_in` audit (blocking), logs with the `surface` tag. - `destroy({ actor, req })` — when `actor` is set, removes from index + emits `auth.sign_out`; always destroys the session with Redis-hiccup tolerance. No code duplicated between the two surfaces — the only per-surface differences are the redirect URIs (passed in) and the cookie names cleared on logout (controller-local). ### Entra config gains two URIs `EntraConfig` adds `adminRedirectUri` + `adminPostLogoutRedirectUri`, validated at boot in [check-entra-config.ts](apps/portal-bff/src/config/check-entra-config.ts). The validator **refuses to start** when `ENTRA_ADMIN_REDIRECT_URI === ENTRA_REDIRECT_URI` — that misconfiguration would silently collapse the two surfaces into one session. Both URIs must be registered on the same Entra app registration's "Redirect URIs" list. ### `AuthService` API change `beginAuthCodeFlow(redirectUri)`, `completeAuthCodeFlow(code, state, preAuth, redirectUri, now?)`, and `buildLogoutUrl(postLogoutRedirectUri)` now take their URI as a parameter. Callers (user-portal vs admin-portal controllers) pick which set to pass. ## Required ops action before this PR can run locally Two new mandatory env vars. The BFF refuses to start without them. ```env ENTRA_ADMIN_REDIRECT_URI=http://localhost:3000/api/admin/auth/callback ENTRA_ADMIN_POST_LOGOUT_REDIRECT_URI=http://localhost:4201/ ``` The example values land in [apps/portal-bff/.env.example](apps/portal-bff/.env.example) for reference. The corresponding Entra app registration also needs `/api/admin/auth/callback` added to its "Redirect URIs" list before any admin sign-in works end-to-end. ## Notes for the reviewer - The user-portal callback's post-login redirect still targets `postLogoutRedirectUri` (existing quirk where the post-auth and post-logout landing happen to be the same URL). The admin callback mirrors the pattern for `adminPostLogoutRedirectUri`. Splitting these into dedicated post-login URIs is a separate ADR/PR. - `AdminModule` now imports `AuthModule` to consume `AuthService`, `SessionEstablisher`, and `ENTRA_CONFIG`. `AuditWriter` and `RequireMfaGuard` come through transitively. - Existing `AuthController` spec assertions are preserved through the refactor by constructing a **real** `SessionEstablisher` in the test fixture with the same audit / index / logger mocks. No behavioural assertion was removed — the inline session-state-setting logic is now exercised through the establisher. - The pre-existing docstring in `check-entra-config.ts` line 11-16 still says "the two redirect URIs are mandatory once the OIDC routes ship (next PR)" — stale, the routes have shipped. Not touched in this PR to keep the diff focused; can be a one-line doc PR later. ## Test plan - [x] `pnpm nx test portal-bff` — **278 specs pass** (was 253; +25: admin cookie 3, session-establisher 11, admin auth controller 9, entra config 2). - [x] `pnpm exec nx affected -t format:check lint test build --base=origin/main` — clean (the pre-existing `_res` / `_next` warnings in `rate-limit.middleware.ts` are unrelated). - [x] Entra config validator: both URIs required, both URL-validated, equality refused. - [x] Path-dispatch verified by routing — `/api/admin/me` and `/api/admin/auth/*` see the admin session; everything else sees the user session. - [ ] e2e — pending env var update + Entra registration update to add the admin redirect URI. Once both are in place: sign in via `/api/auth/login`, see `portal_session` cookie; clear cookies; sign in via `/api/admin/auth/login`, see `portal_admin_session` cookie; verify `/api/admin/me` works on the admin session and `/api/auth/me` works on the user session — neither sees the other's session.
julien added 1 commit 2026-05-14 02:03:24 +02:00
feat(portal-bff): distinct admin session + /api/admin/auth flow
CI / commits (pull_request) Successful in 2m15s
CI / scan (pull_request) Successful in 2m15s
CI / check (pull_request) Successful in 2m24s
CI / a11y (pull_request) Successful in 1m7s
CI / perf (pull_request) Successful in 2m29s
f50d2d66c0
Phase-3a step per ADR-0020 §"Sessions — distinct from `portal-shell`".
Wires a second `express-session` middleware on `/api/admin/*` carrying
`__Host-portal_admin_session` over Redis prefix `session:admin:` and
ships the parallel `/api/admin/auth/{login,callback,me,logout}` flow
that populates it. Signing in to one surface no longer signs the user
into the other — Entra SSO at the IdP level still preserves the
click-through experience.

What lands

- `session/admin-session-cookie.ts`: `adminSessionCookieName()` mirrors
  the existing user-portal pattern (`__Host-` prefix in prod, plain
  name in dev).

- `SessionModule` provides two parallel `express-session` instances
  via a shared `buildSessionMiddleware()` factory:
    SESSION_MIDDLEWARE       cookie portal_session         prefix session:
    ADMIN_SESSION_MIDDLEWARE cookie portal_admin_session   prefix session:admin:
  The TTL policy, encryption key, signing secret, and session-id
  entropy are unchanged — only the cookie name + Redis key prefix
  differ.

- `main.ts` mounts a tiny path-routed dispatch: requests under
  `/api/admin` get the admin session, everything else gets the user
  one. Running both middlewares unconditionally would have the second
  overwrite `req.session` from the first, collapsing the two surfaces.

- `EntraConfig` gains `adminRedirectUri` + `adminPostLogoutRedirectUri`,
  validated at boot. The validator refuses to start when admin and
  user redirect URIs collide (would silently fuse the two surfaces).
  Both URIs must be registered on the same Entra app registration.

- `AuthService.{beginAuthCodeFlow,completeAuthCodeFlow,buildLogoutUrl}`
  now take their redirect / post-logout URI as a parameter. Callers
  pick which set to pass.

- New shared service `SessionEstablisher`:
    establish(user, req, res, surface) — full sign-in recipe: mint
      CSRF, populate session fields, save, register in
      user_sessions index, emit auth.sign_in audit, log.
    destroy(actor | undefined, req)   — sign-out recipe: when actor
      is set, remove from index + emit auth.sign_out audit; always
      destroy the session (with Redis-hiccup tolerance).
  Both `AuthController` and the new `AdminAuthController` call it —
  no duplication of the 150-LOC session lifecycle logic.

- `AdminAuthController` mounts `/api/admin/auth/{login,callback,me,logout}`.
  Structurally identical to `AuthController` but passes
  `adminRedirectUri` / `adminPostLogoutRedirectUri` and clears the
  admin session cookie on logout. `me` exposes the `roles` claim
  (the SPA needs it for conditional admin UI); the user-portal `me`
  intentionally still doesn't.

New env vars (mandatory at boot)

- ENTRA_ADMIN_REDIRECT_URI
- ENTRA_ADMIN_POST_LOGOUT_REDIRECT_URI

Tests: +25 specs (admin cookie 3, session-establisher 11, admin auth
controller 9, entra config 2). Existing AuthController tests
preserved through the refactor by passing a real `SessionEstablisher`
constructed with the same audit / index / logger mocks.
julien merged commit fed905edc5 into main 2026-05-14 02:21:47 +02:00
julien deleted branch feat/portal-bff-admin-session 2026-05-14 02:21:48 +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#129