feat(portal-bff): close the auth loop — callback persists session, /me, RP-initiated /logout #112
Reference in New Issue
Block a user
Delete Branch "feat/portal-bff/auth-session-me-logout"
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
Closes the OIDC loop end-to-end on the BFF side:
/auth/callbacknow writes the resolvedAuthenticatedUserintoreq.session.userand waits forreq.session.save()before redirecting, so the SPA reaches the landing page with a populated session.GET /auth/mereturns the curated public view of the session user (oid,tid,username,displayName) or401 {"error": "unauthenticated"}.amrand other internal claims stay server-side.GET /auth/logoutdestroys the BFF session (RedisDEL), clears the session cookie, and 302s to Entra's/oauth2/v2.0/logoutso 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 beforeconnect-redisfinishes the write, so without an explicit await the browser can race the SPA into requesting/meagainst a missing key. Awaitingsave()is the documented fix.Logout via
GET. Matches/login(alsoGET) and keeps the UX a plain anchor / top-level navigation. The CSRF surface is mitigated bySameSite=Laxon 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./mestripsamr. The session payload mirrorsAuthenticatedUser(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 theid_tokenin the session yet (the encryptedtokensblob lands with downstream API support per ADR-0014). Withoutid_token_hint, Entra shows an account picker — the conservative default until token persistence ships.Cookie name in logout. Uses
sessionCookieName()fromsession/session-cookie.tsso logout clears the same cookie the middleware sets —__Host-portal_sessionin prod,portal_sessionin dev.Out of scope (next PRs)
user_sessions:{userId}secondary index for admin "logout everywhere".id_token/access_token/refresh_tokenblob in the encrypted session (ADR-0014 dependency).ENTRA_POST_LOGOUT_REDIRECT_URIif we want a distinct post-login redirect target — for now both flows land on the same SPA URL.Test plan
pnpm nx test portal-bff→ 110/110 pass (was 99 before this PR; +11 specs acrossauth.controller.spec.tsandauth.service.spec.ts).pnpm nx lint portal-bff→ clean.pnpm nx build portal-bff→ webpack compiled successfully./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.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)