feat(portal-bff): absolute-timeout middleware + user_sessions index per ADR-0010 #115

Merged
julien merged 1 commits from feat/portal-bff/session-absolute-timeout-user-index into main 2026-05-12 23:23:15 +02:00
Owner

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 extensioncreatedAt 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

  • pnpm nx test portal-bff123/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).
  • pnpm nx lint portal-bff → clean.
  • pnpm nx build portal-bff → clean webpack build.
  • 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.
## 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.
julien added 1 commit 2026-05-12 23:22:59 +02:00
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
5c346820ea
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.
julien merged commit c3de2340e7 into main 2026-05-12 23:23:15 +02:00
julien deleted branch feat/portal-bff/session-absolute-timeout-user-index 2026-05-12 23:23:17 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: julien/apf_portal#115