Files
apf_portal/apps/portal-bff/src/session/session.module.ts
T
Julien Gautier f50d2d66c0
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
feat(portal-bff): distinct admin session + /api/admin/auth flow
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.
2026-05-14 02:00:54 +02:00

173 lines
6.6 KiB
TypeScript

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';
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,
} from './session.token';
// Side-effect import: brings the `req.session.user` declaration
// merging into the compile graph for every consumer of this module.
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.
*
* Two middlewares are provided, path-routed in `main.ts`:
*
* - {@link SESSION_MIDDLEWARE} — user-portal. Cookie
* `portal_session` / `__Host-portal_session`. Redis prefix
* `session:`. Bound to every path except `/api/admin/*`.
*
* - {@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],
providers: [
UserSessionIndexService,
{
provide: SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE,
inject: [UserSessionIndexService, Logger, AuditWriter],
useFactory: (
index: UserSessionIndexService,
logger: Logger,
audit: AuditWriter,
): RequestHandler => createAbsoluteTimeoutMiddleware(index, logger, audit),
},
{
provide: SESSION_MIDDLEWARE,
inject: [REDIS_CLIENT, Logger],
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,
ADMIN_SESSION_MIDDLEWARE,
SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE,
UserSessionIndexService,
],
})
export class SessionModule {}