feat(portal-bff): absolute-timeout middleware + user_sessions index per ADR-0010 (#115)
CI / check (push) Failing after 1m28s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m3s
CI / a11y (push) Successful in 58s
CI / perf (push) Successful in 2m53s

## Summary

Hardens the BFF session per ADR-0010 §"TTL policy" and §"Revocation":

- **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, the middleware destroys the Redis-side session, clears the `portal_session` cookie, drops the entry from the per-user index, and lets the request continue anonymously. Route-level guards (`/me`, future `@RequireAuth`) turn that into a 401 where the user actually needs auth — public routes keep serving.
- **`user_sessions:{userId}` secondary index** — a new `UserSessionIndexService` maintains a Redis set of active session ids per user. Hooked into `/auth/callback` (SADD on sign-in) and `/auth/logout` + the absolute-timeout middleware (SREM on destroy). Best-effort: a failed `SADD`/`SREM` logs a warning and the auth flow continues. No in-product consumer in this PR — the admin "logout everywhere" endpoint lands with the admin module.
- **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.

## Notable choices

**Non-intrusive enforcement on expiry.** ADR-0010 says "returns 401"; we interpret that as "the user eventually sees a 401 when they touch something that needs auth", not "every route returns 401 the moment we notice the ceiling". The middleware destroys the session and calls `next()` — `/me` returns 401 on its own (no user on the session), public routes stay accessible. Validated with the project lead 2026-05-12.

**Express middleware exposed via DI, not a NestJS `MiddlewareConsumer`.** Same pattern as `SESSION_MIDDLEWARE`: factory inside `SessionModule`, resolved from the application context in `main.ts` with `app.get<RequestHandler>(SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE)`. Keeps the wiring co-located with the session middleware and avoids the `AppModule.configure(consumer)` boilerplate for a one-off enforcement layer.

**Best-effort index maintenance.** `UserSessionIndexService.add` / `remove` catch Redis errors and log a Pino warning instead of throwing. Rationale (per ADR-0010): the index is a convenience for admin operations, not a security invariant — a Redis hiccup must not break sign-in / sign-out. Orphans (entries pointing to keys that have expired idle-TTL on their own) are tolerated and will be filtered by future consumer code.

**Per-user index identifier = Entra `oid`.** Stable per-user inside the tenant, matches `req.session.user.oid`. Admin "logout user X" will work against this same key. Future multi-tenant scenarios may want `${tid}:${oid}` — easy refactor when External ID activation lands (ADR-0008).

## Out of scope (next PRs)

- Admin "logout everywhere" endpoint consuming `UserSessionIndexService.list(userId)`. Waits on the admin module + `@RequireAdmin` / `@RequireMfa` guards.
- Audit-pipeline first-class events for `session.absolute_timeout` and `user_session_index.*` (ADR-0013). For now they're structured Pino logs.
- Token blob persistence (id_token / access_token / refresh_token) in the encrypted session — ADR-0014 dependency.

## Test plan

- [x] `pnpm nx test portal-bff` → **123/123 pass** (was 110 before; +13 specs across new `user-session-index.service.spec.ts`, `absolute-timeout.middleware.spec.ts`, and added cases in `auth.controller.spec.ts`).
- [x] `pnpm nx lint portal-bff` → clean.
- [x] `pnpm nx build portal-bff` → clean webpack build.
- [x] Prettier-clean for all touched files.
- [ ] Manual smoke against running BFF:
  - [ ] Sign in normally → Redis has `session:<id>` + `user_sessions:<oid>` SISMEMBER returns `<id>`.
  - [ ] Logout → both keys gone.
  - [ ] Forge a past `absoluteExpiresAt` in Redis (or shorten `SESSION_ABSOLUTE_TIMEOUT_SECONDS=5` in `.env`) → next request after expiry returns 401 on `/me`, cookie cleared, index entry SREM-ed.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #115
This commit was merged in pull request #115.
This commit is contained in:
2026-05-12 23:23:14 +02:00
parent 0e4f0fc611
commit c3de2340e7
11 changed files with 607 additions and 24 deletions
+30 -15
View File
@@ -7,13 +7,19 @@ import { assertSessionEncryptionKey } from '../config/check-session-encryption-k
import { assertSessionSecret } from '../config/check-session-secret';
import { RedisModule } from '../redis/redis.module';
import { REDIS_CLIENT, type Redis } from '../redis/redis.token';
import { createAbsoluteTimeoutMiddleware } from './absolute-timeout.middleware';
import { adaptIoredisForConnectRedis } from './ioredis-connect-redis-adapter';
import { SessionDecryptError, decrypt, encrypt } from './session-crypto';
import { readSessionTimeouts, sessionCookieName, sessionCookieOptions } from './session-cookie';
import { SESSION_MIDDLEWARE, type RequestHandler } from './session.token';
import {
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';
/**
* Session module — wires `express-session` with a `connect-redis`
@@ -28,24 +34,33 @@ import './session.types';
* the same Redis client the rest of the BFF uses, instead of
* spinning up a second connection at the bootstrap layer.
*
* Scope of this PR — infrastructure only:
* - middleware mounted on every request
* - per-request `req.session` available downstream
* - cookie set on first write (`saveUninitialized: false`)
* - encryption-at-rest active
* 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).
*
* Out of scope, landing in follow-ups (per ADR-0010):
* - `/auth/callback` populating `req.session.user`
* - `/me`, `/auth/logout`
* - absolute-timeout interceptor
* - `user_sessions:{userId}` secondary index
* - JSON-decode error → audit event with `event:
* session.decrypt_failed` (the throw is in place; audit
* pipeline lands with ADR-0013).
* 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).
*/
@Module({
imports: [RedisModule],
providers: [
UserSessionIndexService,
{
provide: SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE,
inject: [UserSessionIndexService, Logger],
useFactory: (index: UserSessionIndexService, logger: Logger): RequestHandler =>
createAbsoluteTimeoutMiddleware(index, logger),
},
{
provide: SESSION_MIDDLEWARE,
inject: [REDIS_CLIENT, Logger],
@@ -109,6 +124,6 @@ import './session.types';
},
},
],
exports: [SESSION_MIDDLEWARE],
exports: [SESSION_MIDDLEWARE, SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE, UserSessionIndexService],
})
export class SessionModule {}