feat(portal-bff): absolute-timeout middleware + user_sessions index per ADR-0010 #115
Reference in New Issue
Block a user
Delete Branch "feat/portal-bff/session-absolute-timeout-user-index"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Hardens the BFF session per ADR-0010 §"TTL policy" and §"Revocation":
express-sessionruns through a new middleware that checksreq.session.absoluteExpiresAt. Past the 12 h hard ceiling, the middleware destroys the Redis-side session, clears theportal_sessioncookie, 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 newUserSessionIndexServicemaintains 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 failedSADD/SREMlogs a warning and the auth flow continues. No in-product consumer in this PR — the admin "logout everywhere" endpoint lands with the admin module.createdAtandabsoluteExpiresAtare now set on the session at the same moment asreq.session.user(in/auth/callback). Thesession.types.tsdeclaration merging exposes them as optionalSessionDatafields.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()—/mereturns 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 asSESSION_MIDDLEWARE: factory insideSessionModule, resolved from the application context inmain.tswithapp.get<RequestHandler>(SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE). Keeps the wiring co-located with the session middleware and avoids theAppModule.configure(consumer)boilerplate for a one-off enforcement layer.Best-effort index maintenance.
UserSessionIndexService.add/removecatch 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, matchesreq.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)
UserSessionIndexService.list(userId). Waits on the admin module +@RequireAdmin/@RequireMfaguards.session.absolute_timeoutanduser_session_index.*(ADR-0013). For now they're structured Pino logs.Test plan
pnpm nx test portal-bff→ 123/123 pass (was 110 before; +13 specs across newuser-session-index.service.spec.ts,absolute-timeout.middleware.spec.ts, and added cases inauth.controller.spec.ts).pnpm nx lint portal-bff→ clean.pnpm nx build portal-bff→ clean webpack build.session:<id>+user_sessions:<oid>SISMEMBER returns<id>.absoluteExpiresAtin Redis (or shortenSESSION_ABSOLUTE_TIMEOUT_SECONDS=5in.env) → next request after expiry returns 401 on/me, cookie cleared, index entry SREM-ed.