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

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.
This commit is contained in:
Julien Gautier
2026-05-14 02:00:54 +02:00
parent d51ccebe6a
commit f50d2d66c0
19 changed files with 1221 additions and 223 deletions
+16 -102
View File
@@ -1,11 +1,9 @@
import { randomBytes } from 'node:crypto';
import { Controller, Get, Inject, Query, Req, Res, UnauthorizedException } from '@nestjs/common';
import type { Request, Response } from 'express';
import { Logger } from 'nestjs-pino';
import { AuditWriter } from '../audit/audit.service';
import { csrfCookieName, csrfCookieOptions } from '../security/csrf-cookie';
import { readSessionTimeouts, sessionCookieName } from '../session/session-cookie';
import { UserSessionIndexService } from '../session/user-session-index.service';
import { csrfCookieName } from '../security/csrf-cookie';
import { sessionCookieName } from '../session/session-cookie';
import {
PRE_AUTH_COOKIE_NAME,
clearPreAuthCookieOptions,
@@ -14,6 +12,7 @@ import {
import { AuthCodeFlowException, type AuthCodeFlowError, authErrorCode } from './auth.errors';
import { AuthService, type AuthenticatedUser, type PreAuthPayload } from './auth.service';
import { ENTRA_CONFIG, type EntraConfig } from './entra-config.token';
import { SessionEstablisher } from './session-establisher.service';
/**
* OIDC routes mounted under `/api/auth/` per ADR-0009.
@@ -31,8 +30,8 @@ export class AuthController {
private readonly authService: AuthService,
private readonly logger: Logger,
@Inject(ENTRA_CONFIG) private readonly entra: EntraConfig,
private readonly userSessionIndex: UserSessionIndexService,
private readonly audit: AuditWriter,
private readonly sessionEstablisher: SessionEstablisher,
) {}
/**
@@ -47,7 +46,9 @@ export class AuthController {
*/
@Get('login')
async login(@Res() res: Response): Promise<void> {
const { authUrl, preAuthPayload } = await this.authService.beginAuthCodeFlow();
const { authUrl, preAuthPayload } = await this.authService.beginAuthCodeFlow(
this.entra.redirectUri,
);
res.cookie(PRE_AUTH_COOKIE_NAME, JSON.stringify(preAuthPayload), preAuthCookieOptions());
res.redirect(302, authUrl);
}
@@ -117,60 +118,13 @@ export class AuthController {
}
try {
const user = await this.authService.completeAuthCodeFlow(code, state, preAuth);
const now = Date.now();
const { idleSeconds, absoluteSeconds } = readSessionTimeouts();
const csrfToken = randomBytes(32).toString('base64url');
req.session.user = user;
req.session.createdAt = now;
// Hard ceiling per ADR-0010 §"TTL policy" — checked on every
// request by the absolute-timeout middleware, independent of
// idle TTL.
req.session.absoluteExpiresAt = now + absoluteSeconds * 1000;
// CSRF token per ADR-0009 §"Double-submit CSRF". Server-side
// source of truth lives on the session; the cookie below is
// the SPA's read-only mirror used to echo the value in the
// X-CSRF-Token header.
req.session.csrfToken = csrfToken;
// MFA freshness anchor per ADR-0011 §"Confirmation". Entra's
// CA policy decides whether MFA actually happened — the BFF
// does not re-validate factors. The stamp reflects "the
// session was just established with whatever assurance Entra
// returned"; `RequireMfaGuard` measures freshness against it.
// Refreshed by future step-up re-auth flows.
req.session.mfaVerifiedAt = now;
// Force the save before the redirect: express-session writes
// on response end, but the 302 we're about to emit closes the
// response before the async store-write would otherwise
// complete. Without this, the browser hits the SPA before
// Redis carries the new payload.
await saveSession(req);
// Mirror the CSRF token to a JS-readable cookie. maxAge
// matches the session's idle TTL so the cookie expires at
// the same time as the session (rolling, refreshed on each
// request alongside express-session's own cookie).
res.cookie(csrfCookieName(), csrfToken, csrfCookieOptions(idleSeconds * 1000));
// Register the freshly-minted session id in the per-user
// index so a future admin "logout everywhere" can enumerate
// and revoke. Best-effort: a Redis hiccup here doesn't fail
// sign-in (the service swallows + logs).
await this.userSessionIndex.add(user.oid, req.sessionID);
// First write to the audit trail — blocking, per ADR-0013.
// If this throws the user does NOT see a successful sign-in:
// the exception propagates and the controller emits a 5xx via
// Nest's default exception filter. Same posture as the
// session.save() above.
await this.audit.signIn({ actor: user, sessionId: req.sessionID });
this.logger.log(
{
event: 'auth.signed_in',
oid: user.oid,
tid: user.tid,
username: user.username,
amr: user.amr,
},
'AuthCallback',
const user = await this.authService.completeAuthCodeFlow(
code,
state,
preAuth,
this.entra.redirectUri,
);
await this.sessionEstablisher.establish({ user, req, res, surface: 'user' });
res.redirect(302, this.entra.postLogoutRedirectUri);
} catch (err) {
if (err instanceof AuthCodeFlowException) {
@@ -226,41 +180,13 @@ export class AuthController {
async logout(@Req() req: Request, @Res() res: Response): Promise<void> {
const user = req.session.user;
const wasAuthenticated = Boolean(user);
const sessionId = req.sessionID;
const logoutUrl = this.authService.buildLogoutUrl();
const logoutUrl = this.authService.buildLogoutUrl(this.entra.postLogoutRedirectUri);
// Drop the user_sessions index entry before destroy() removes
// the session id from `req`. Skipped on already-anonymous
// requests (nothing was ever added).
if (user) {
await this.userSessionIndex.remove(user.oid, sessionId);
// Audit the sign-out before tearing the session down — once
// destroy() runs we lose the actor id. Blocking per ADR-0013:
// if the audit row can't be written, the user does NOT get a
// "you're logged out" experience, because we can't certify
// the sign-out happened.
await this.audit.signOut({ actor: user, sessionId });
}
try {
await destroySession(req);
} catch (err) {
// The Redis DEL failed — log and continue. Clearing the
// cookie still gets the user effectively logged out from the
// BFF's point of view; the orphan Redis key will hit its idle
// TTL on its own.
this.logger.error(
{
event: 'session.destroy_failed',
message: err instanceof Error ? err.message : String(err),
},
'AuthLogout',
);
}
await this.sessionEstablisher.destroy({ actor: user, req });
res.clearCookie(sessionCookieName(), { path: '/' });
res.clearCookie(csrfCookieName(), { path: '/' });
this.logger.log({ event: 'auth.signed_out', wasAuthenticated }, 'AuthLogout');
this.logger.log({ event: 'auth.signed_out', surface: 'user', wasAuthenticated }, 'AuthLogout');
res.redirect(302, logoutUrl);
}
@@ -287,18 +213,6 @@ function toPublicUser(user: AuthenticatedUser): PublicUser {
};
}
function saveSession(req: Request): Promise<void> {
return new Promise((resolve, reject) => {
req.session.save((err) => (err ? reject(err) : resolve()));
});
}
function destroySession(req: Request): Promise<void> {
return new Promise((resolve, reject) => {
req.session.destroy((err) => (err ? reject(err) : resolve()));
});
}
function readPreAuthCookie(req: Request): PreAuthPayload | null {
const raw = (req.signedCookies as Record<string, unknown>)[PRE_AUTH_COOKIE_NAME];
if (typeof raw !== 'string') {