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
+120 -81
View File
@@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto';
import { Module } from '@nestjs/common';
import { RedisStore } from 'connect-redis';
import expressSession from 'express-session';
import type { CookieOptions } from 'express';
import { Logger } from 'nestjs-pino';
import { assertSessionEncryptionKey } from '../config/check-session-encryption-key';
import { assertSessionSecret } from '../config/check-session-secret';
@@ -9,10 +10,12 @@ import { RedisModule } from '../redis/redis.module';
import { REDIS_CLIENT, type Redis } from '../redis/redis.token';
import { AuditWriter } from '../audit/audit.service';
import { createAbsoluteTimeoutMiddleware } from './absolute-timeout.middleware';
import { adminSessionCookieName } from './admin-session-cookie';
import { adaptIoredisForConnectRedis } from './ioredis-connect-redis-adapter';
import { SessionDecryptError, decrypt, encrypt } from './session-crypto';
import { readSessionTimeouts, sessionCookieName, sessionCookieOptions } from './session-cookie';
import {
ADMIN_SESSION_MIDDLEWARE,
SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE,
SESSION_MIDDLEWARE,
type RequestHandler,
@@ -22,35 +25,108 @@ import {
import './session.types';
import { UserSessionIndexService } from './user-session-index.service';
/**
* Build a configured `express-session` middleware. Shared between
* the user-portal middleware ({@link SESSION_MIDDLEWARE}) and the
* admin-portal middleware ({@link ADMIN_SESSION_MIDDLEWARE}) per
* ADR-0020 §"Sessions — distinct from `portal-shell`".
*
* The two surfaces differ only on the cookie name and Redis key
* prefix; the TTL policy, encryption key, signing secret, session-id
* entropy, and error-handling all come from the same source so a
* future hardening (e.g. tighter idle TTL on admin) is a parameter
* change rather than a divergent code path.
*/
function buildSessionMiddleware(
redis: Redis,
logger: Logger,
opts: { cookieName: string; redisKeyPrefix: string; surfaceTag: 'user' | 'admin' },
): RequestHandler {
const secret = assertSessionSecret();
const key = assertSessionEncryptionKey();
const timeouts = readSessionTimeouts();
const store = new RedisStore({
// `connect-redis` v9 was rewritten against the `node-redis` v4
// command surface; the adapter shims `ioredis` to look the same
// for the handful of commands the store actually calls.
client: adaptIoredisForConnectRedis(redis) as unknown as never,
prefix: opts.redisKeyPrefix,
ttl: timeouts.idleSeconds,
serializer: {
stringify: (sess) => encrypt(JSON.stringify(sess), key),
parse: (payload) => {
try {
return JSON.parse(decrypt(payload, key));
} catch (err) {
// Tamper, wrong key, or unknown version. The phase-2
// audit pipeline (ADR-0013) will turn this into a
// first-class audit event; for now we surface a
// structured Pino log so ops can spot it. The
// `surface` tag lets dashboards split user-portal
// decrypt failures from admin-portal ones.
logger.warn(
{
event: 'session.decrypt_failed',
surface: opts.surfaceTag,
reason: err instanceof SessionDecryptError ? err.message : String(err),
},
'session',
);
throw err;
}
},
},
});
const cookie: CookieOptions = sessionCookieOptions(timeouts.idleSeconds);
return expressSession({
name: opts.cookieName,
secret,
// `crypto.randomBytes(32).toString('base64url')` ⇒ 256
// bits of entropy in the session id, per ADR-0010
// §"Confirmation".
genid: () => randomBytes(32).toString('base64url'),
store,
// `resave: false` — RedisStore.touch refreshes the TTL on
// every request; no need to rewrite the payload.
resave: false,
// `saveUninitialized: false` — don't create empty session
// keys for unauthenticated visitors browsing public
// routes. The session is born when the OIDC callback
// populates it.
saveUninitialized: false,
// `rolling: true` — the cookie's `expires` slides forward
// on every response, matching the sliding-idle policy.
rolling: true,
cookie,
});
}
/**
* Session module — wires `express-session` with a `connect-redis`
* store on top of the shared `ioredis` client, with AES-256-GCM
* encryption applied to the JSON payload before it lands in Redis.
*
* The configured middleware is exposed as a NestJS provider under
* the {@link SESSION_MIDDLEWARE} token. `main.ts` resolves it from
* the application context and mounts it once via `app.use(...)`
* after `cookie-parser`. Mounting the express-session middleware
* through DI (rather than constructing it in `main.ts`) keeps it on
* the same Redis client the rest of the BFF uses, instead of
* spinning up a second connection at the bootstrap layer.
* Two middlewares are provided, path-routed in `main.ts`:
*
* What's wired today:
* - Express-session middleware on every request (`SESSION_MIDDLEWARE`).
* - Absolute-timeout middleware (`SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE`)
* that enforces the 12 h hard ceiling per ADR-0010.
* - `UserSessionIndexService`: maintains the
* `user_sessions:{userId}` Redis set so a future admin endpoint
* can "log out user X everywhere" — write-side hooks live in
* the auth controller (create on `/auth/callback`, drop on
* `/auth/logout` or absolute-timeout).
* - {@link SESSION_MIDDLEWARE} — user-portal. Cookie
* `portal_session` / `__Host-portal_session`. Redis prefix
* `session:`. Bound to every path except `/api/admin/*`.
*
* Out of scope, landing in follow-ups:
* - The admin "logout everywhere" route that consumes the
* `UserSessionIndexService` (waits on the admin module + the
* `@RequireAdmin` / `@RequireMfa` guards).
* - Audit-pipeline wiring for `session.decrypt_failed` and
* `session.absolute_timeout` (ADR-0013).
* - {@link ADMIN_SESSION_MIDDLEWARE} — admin-portal per ADR-0020.
* Cookie `portal_admin_session` / `__Host-portal_admin_session`.
* Redis prefix `session:admin:`. Bound to `/api/admin/*` only.
*
* The {@link SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE} (ADR-0010 §"TTL
* policy") and {@link UserSessionIndexService} are shared across
* both surfaces — the absolute-timeout check reads `req.session`
* which the dispatch has already resolved to one surface or the
* other; the user-session index is keyed by Entra `oid`, so an
* admin sign-in and a user-portal sign-in by the same person
* naturally land in different Redis sets only because the session
* id namespaces differ.
*/
@Module({
imports: [RedisModule],
@@ -68,66 +144,29 @@ import { UserSessionIndexService } from './user-session-index.service';
{
provide: SESSION_MIDDLEWARE,
inject: [REDIS_CLIENT, Logger],
useFactory: (redis: Redis, logger: Logger): RequestHandler => {
const secret = assertSessionSecret();
const key = assertSessionEncryptionKey();
const timeouts = readSessionTimeouts();
const store = new RedisStore({
// `connect-redis` v9 was rewritten against the
// `node-redis` v4 command surface; the adapter shims
// `ioredis` to look the same for the handful of commands
// the store actually calls.
client: adaptIoredisForConnectRedis(redis) as unknown as never,
prefix: 'session:',
ttl: timeouts.idleSeconds,
serializer: {
stringify: (sess) => encrypt(JSON.stringify(sess), key),
parse: (payload) => {
try {
return JSON.parse(decrypt(payload, key));
} catch (err) {
// Tamper, wrong key, or unknown version. The phase-2
// audit pipeline (ADR-0013) will turn this into a
// first-class audit event; for now we surface a
// structured Pino log so ops can spot it.
logger.warn(
{
event: 'session.decrypt_failed',
reason: err instanceof SessionDecryptError ? err.message : String(err),
},
'session',
);
throw err;
}
},
},
});
return expressSession({
name: sessionCookieName(),
secret,
// `crypto.randomBytes(32).toString('base64url')` ⇒ 256
// bits of entropy in the session id, per ADR-0010
// §"Confirmation".
genid: () => randomBytes(32).toString('base64url'),
store,
// `resave: false` — RedisStore.touch refreshes the TTL on
// every request; no need to rewrite the payload.
resave: false,
// `saveUninitialized: false` — don't create empty session
// keys for unauthenticated visitors browsing public
// routes. The session is born when `/auth/callback`
// populates it.
saveUninitialized: false,
// `rolling: true` — the cookie's `expires` slides forward
// on every response, matching the sliding-idle policy.
rolling: true,
cookie: sessionCookieOptions(timeouts.idleSeconds),
});
},
useFactory: (redis: Redis, logger: Logger): RequestHandler =>
buildSessionMiddleware(redis, logger, {
cookieName: sessionCookieName(),
redisKeyPrefix: 'session:',
surfaceTag: 'user',
}),
},
{
provide: ADMIN_SESSION_MIDDLEWARE,
inject: [REDIS_CLIENT, Logger],
useFactory: (redis: Redis, logger: Logger): RequestHandler =>
buildSessionMiddleware(redis, logger, {
cookieName: adminSessionCookieName(),
redisKeyPrefix: 'session:admin:',
surfaceTag: 'admin',
}),
},
],
exports: [SESSION_MIDDLEWARE, SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE, UserSessionIndexService],
exports: [
SESSION_MIDDLEWARE,
ADMIN_SESSION_MIDDLEWARE,
SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE,
UserSessionIndexService,
],
})
export class SessionModule {}