feat(portal-bff): distinct admin session + /api/admin/auth flow #129
Reference in New Issue
Block a user
Delete Branch "feat/portal-bff-admin-session"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Phase-3a step per ADR-0020 §"Sessions — distinct from
portal-shell". Wires a secondexpress-sessionmiddleware on/api/admin/*carrying__Host-portal_admin_sessionover Redis prefixsession: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
SESSION_MIDDLEWAREportal_session/__Host-portal_sessionsession:/api/admin/*ADMIN_SESSION_MIDDLEWAREportal_admin_session/__Host-portal_admin_sessionsession:admin:/api/admin/*onlyImplemented 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 overwritereq.sessionfrom the first, collapsing the two surfaces.Distinct admin auth flow
AdminAuthControllermounts/api/admin/auth/{login,callback,me,logout}. Structurally identical toAuthControllerbut passesadminRedirectUri/adminPostLogoutRedirectUriand clears the admin session cookie on logout.meexposes therolesclaim (admin SPA needs it for conditional UI); the user-portalmeintentionally still doesn't.Shared
SessionEstablisher(no controller duplication)SessionEstablisherencapsulates the session lifecycle so both controllers stay thin:establish({ user, req, res, surface })— mints CSRF, populatesuser / createdAt / absoluteExpiresAt / csrfToken / mfaVerifiedAt, saves, sets the CSRF cookie, registers inuser_sessionsindex, emitsauth.sign_inaudit (blocking), logs with thesurfacetag.destroy({ actor, req })— whenactoris set, removes from index + emitsauth.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
EntraConfigaddsadminRedirectUri+adminPostLogoutRedirectUri, validated at boot in check-entra-config.ts. The validator refuses to start whenENTRA_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.AuthServiceAPI changebeginAuthCodeFlow(redirectUri),completeAuthCodeFlow(code, state, preAuth, redirectUri, now?), andbuildLogoutUrl(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.
The example values land in apps/portal-bff/.env.example for reference. The corresponding Entra app registration also needs
/api/admin/auth/callbackadded to its "Redirect URIs" list before any admin sign-in works end-to-end.Notes for the reviewer
postLogoutRedirectUri(existing quirk where the post-auth and post-logout landing happen to be the same URL). The admin callback mirrors the pattern foradminPostLogoutRedirectUri. Splitting these into dedicated post-login URIs is a separate ADR/PR.AdminModulenow importsAuthModuleto consumeAuthService,SessionEstablisher, andENTRA_CONFIG.AuditWriterandRequireMfaGuardcome through transitively.AuthControllerspec assertions are preserved through the refactor by constructing a realSessionEstablisherin 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.check-entra-config.tsline 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-bff— 278 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/_nextwarnings inrate-limit.middleware.tsare unrelated)./api/admin/meand/api/admin/auth/*see the admin session; everything else sees the user session./api/auth/login, seeportal_sessioncookie; clear cookies; sign in via/api/admin/auth/login, seeportal_admin_sessioncookie; verify/api/admin/meworks on the admin session and/api/auth/meworks on the user session — neither sees the other's session.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.