feat(portal-bff): absolute-timeout middleware + user_sessions index per ADR-0010
CI / commits (pull_request) Successful in 1m25s
CI / scan (pull_request) Successful in 1m30s
CI / check (pull_request) Failing after 1m34s
CI / a11y (pull_request) Successful in 1m6s
CI / perf (pull_request) Successful in 3m30s

closes the ttl-policy + revocation pieces left from #110.

absolute-timeout middleware:
  every request that survives express-session runs through a new
  middleware that checks req.session.absoluteExpiresAt. past the
  12 h hard ceiling, it destroys the redis-side session, clears the
  portal_session cookie, drops the entry from the per-user index,
  and lets the request keep flowing anonymously. route-level
  guards (/me today, future @requireauth) turn that into a 401
  where auth is actually needed — public routes stay accessible.
  not the "every route returns 401" reading of adr-0010 §"ttl
  policy" (validated with the project lead): "returns 401" is
  interpreted as "the user eventually sees a 401 when they touch
  something that needs auth", not as a hard refusal on the
  current in-flight request.

user_sessions:{userId} secondary index (adr-0010 §"revocation"):
  a new UserSessionIndexService maintains a redis set of active
  session ids per user. wired on /auth/callback (sadd at sign-in,
  after session.save() so the session row exists before the index
  points at it) and on /auth/logout + the absolute-timeout
  middleware (srem on destroy). best-effort: a failed sadd/srem
  logs a pino warning and the auth flow continues — the index is
  a convenience for admin operations, not a security invariant.
  orphans (entries whose session:<id> redis key has expired on
  idle ttl) are tolerated per the adr; future consumer code
  filters them out.

  no in-product consumer ships in this pr — the admin "logout
  everywhere" endpoint lands with the admin module (+ @requireadmin
  / @requiremfa guards). today the index is write-only on the bff
  side.

session payload extension:
  createdAt and absoluteExpiresAt are now set on the session at
  the same moment as req.session.user, in /auth/callback. the
  session.types.ts declaration merging exposes them as optional
  SessionData fields so every consumer sees a typed payload.

wiring:
  - SessionModule provides UserSessionIndexService and a
    SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE token whose factory builds
    the middleware via createAbsoluteTimeoutMiddleware(index,
    logger). same pattern as the existing SESSION_MIDDLEWARE token.
  - AuthModule now imports SessionModule so AuthController can
    inject UserSessionIndexService.
  - main.ts mounts the absolute-timeout middleware immediately
    after the session middleware.

per-user identifier is the entra `oid` claim (stable per-user
inside the tenant, matches req.session.user.oid). future
multi-tenant scenarios may want `${tid}:${oid}` — easy refactor
when external id activation lands (adr-0008). not in scope here.

out of scope, landing in follow-ups:
- admin "logout everywhere" endpoint consuming
  UserSessionIndexService.list(userId)
- audit-pipeline first-class events for session.absolute_timeout
  + user_session_index.* (adr-0013)
- token blob persistence (id_token / access_token / refresh_token)
  in the encrypted session — adr-0014 dependency

123/123 tests pass (was 110); +13 specs across the two new files
and the auth controller spec.
This commit is contained in:
Julien Gautier
2026-05-12 23:22:08 +02:00
parent 0e4f0fc611
commit 5c346820ea
11 changed files with 607 additions and 24 deletions
+25 -2
View File
@@ -1,7 +1,8 @@
import { Controller, Get, Inject, Query, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import { Logger } from 'nestjs-pino';
import { sessionCookieName } from '../session/session-cookie';
import { readSessionTimeouts, sessionCookieName } from '../session/session-cookie';
import { UserSessionIndexService } from '../session/user-session-index.service';
import {
PRE_AUTH_COOKIE_NAME,
clearPreAuthCookieOptions,
@@ -27,6 +28,7 @@ export class AuthController {
private readonly authService: AuthService,
private readonly logger: Logger,
@Inject(ENTRA_CONFIG) private readonly entra: EntraConfig,
private readonly userSessionIndex: UserSessionIndexService,
) {}
/**
@@ -106,13 +108,25 @@ export class AuthController {
try {
const user = await this.authService.completeAuthCodeFlow(code, state, preAuth);
const now = Date.now();
const { absoluteSeconds } = readSessionTimeouts();
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;
// 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);
// 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);
this.logger.log(
{
event: 'auth.signed_in',
@@ -168,9 +182,18 @@ export class AuthController {
*/
@Get('logout')
async logout(@Req() req: Request, @Res() res: Response): Promise<void> {
const wasAuthenticated = Boolean(req.session.user);
const user = req.session.user;
const wasAuthenticated = Boolean(user);
const sessionId = req.sessionID;
const logoutUrl = this.authService.buildLogoutUrl();
// 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);
}
try {
await destroySession(req);
} catch (err) {