feat(portal-bff): session middleware with AES-256-GCM at rest per ADR-0010 #110

Merged
julien merged 1 commits from feat/portal-bff/session-express-middleware into main 2026-05-12 18:06:14 +02:00
Owner

Summary

Mounts express-session + connect-redis at bootstrap on top of the shared ioredis client, with AES-256-GCM applied to the full JSON payload before it lands in Redis (per ADR-0010). The configured middleware is exposed as a NestJS provider (SESSION_MIDDLEWARE) and main.ts mounts it through app.get(...) so it sits on the same Redis connection the rest of the BFF uses — no second client at the bootstrap layer.

Envelope is versioned (v1.<iv>.<tag>.<ciphertext>, all base64url) so the algorithm / key derivation can rotate without a flag-day re-encryption. Tamper / wrong-key / unknown-version all raise SessionDecryptError; for now the failure is logged via Pino with event: session.decrypt_failed — the first-class audit event lands with ADR-0013.

Scope is intentionally infrastructure only:

  • middleware mounted on every request, req.session available downstream
  • session id = crypto.randomBytes(32).toString('base64url') (256 bits per ADR-0010)
  • cookie name: __Host-portal_session in production, portal_session in dev (the __Host- prefix mandates Secure, which dev HTTP can't satisfy)
  • httpOnly + sameSite=lax + path=/; resave:false, saveUninitialized:false, rolling:true
  • cookie maxAge follows SESSION_IDLE_TIMEOUT_SECONDS (default 1800)
  • encryption-at-rest active end-to-end

Out of scope, landing in follow-ups: /auth/callback populating req.session.user, /me, /auth/logout, the absolute-timeout interceptor, and the user_sessions:{userId} secondary index.

Notable shape choices (ADR-0010 amended in the same commit)

Full-payload encryption vs. just the tokens field. The first draft of ADR-0010 scoped at-rest encryption to a tokens sub-field. The session also carries claims (oid, tid, preferred_username, …) that qualify as PII under GDPR — for an APF-Handicap portal handling health-adjacent data this matters. Encrypting the envelope is strictly stronger and removes the need to classify fields one by one. The ADR text is updated to match.

ioredis + adapter vs. switching the BFF to node-redis. connect-redis v9 was rewritten for node-redis v4 and no longer accepts ioredis directly. Two reasonable paths:

  1. Adapter (chosen) — keep the shared ioredis client; shim the six commands connect-redis actually calls (get, set with {expiration:{type:'EX',value}}, expire, del, mGet, scanIterator) to the node-redis shape. Smallest blast radius — RedisModule, OBO cache (ADR-0014), future pub/sub all stay on a single Redis library.
  2. Switch RedisModule to node-redis — clean alignment with connect-redis's expectations, but touches every Redis consumer and would itself require an ADR amendment.

The adapter is reversible: if we ever decide to standardise on node-redis, deleting one file removes it. Happy to switch if you'd rather take that path.

Env vars

  • SESSION_ENCRYPTION_KEYmandatory, AES-256-GCM key (32 bytes after base64url decode). New assertSessionEncryptionKey() validator wired in main.ts alongside the other pre-flight checks.
  • SESSION_IDLE_TIMEOUT_SECONDS — optional, default 1800.
  • SESSION_ABSOLUTE_TIMEOUT_SECONDS — optional, default 43200 (consumed by the absolute-timeout interceptor in a follow-up).

.env.example updated; the three variables are promoted from the "future vars" block to the active section.

Test plan

  • pnpm nx test portal-bff99/99 pass (was 62 before this PR; +37 new specs across the 5 new files).
  • pnpm nx build portal-bff — clean webpack build.
  • pnpm nx lint portal-bff — clean.
  • Prettier-clean for all PR source files.
  • Local smoke test once the next PR wires /auth/callbackreq.session.user; this PR has no user-visible behaviour to exercise on its own.
## Summary Mounts `express-session` + `connect-redis` at bootstrap on top of the shared `ioredis` client, with **AES-256-GCM applied to the full JSON payload before it lands in Redis** (per ADR-0010). The configured middleware is exposed as a NestJS provider (`SESSION_MIDDLEWARE`) and `main.ts` mounts it through `app.get(...)` so it sits on the same Redis connection the rest of the BFF uses — no second client at the bootstrap layer. Envelope is versioned (`v1.<iv>.<tag>.<ciphertext>`, all base64url) so the algorithm / key derivation can rotate without a flag-day re-encryption. Tamper / wrong-key / unknown-version all raise `SessionDecryptError`; for now the failure is logged via Pino with `event: session.decrypt_failed` — the first-class audit event lands with ADR-0013. Scope is intentionally **infrastructure only**: - middleware mounted on every request, `req.session` available downstream - session id = `crypto.randomBytes(32).toString('base64url')` (256 bits per ADR-0010) - cookie name: `__Host-portal_session` in production, `portal_session` in dev (the `__Host-` prefix mandates `Secure`, which dev HTTP can't satisfy) - `httpOnly + sameSite=lax + path=/`; `resave:false`, `saveUninitialized:false`, `rolling:true` - cookie `maxAge` follows `SESSION_IDLE_TIMEOUT_SECONDS` (default 1800) - encryption-at-rest active end-to-end Out of scope, landing in follow-ups: `/auth/callback` populating `req.session.user`, `/me`, `/auth/logout`, the absolute-timeout interceptor, and the `user_sessions:{userId}` secondary index. ## Notable shape choices (ADR-0010 amended in the same commit) **Full-payload encryption vs. just the `tokens` field.** The first draft of ADR-0010 scoped at-rest encryption to a `tokens` sub-field. The session also carries claims (`oid`, `tid`, `preferred_username`, …) that qualify as PII under GDPR — for an APF-Handicap portal handling health-adjacent data this matters. Encrypting the envelope is strictly stronger and removes the need to classify fields one by one. The ADR text is updated to match. **`ioredis` + adapter vs. switching the BFF to `node-redis`.** `connect-redis` v9 was rewritten for `node-redis` v4 and no longer accepts `ioredis` directly. Two reasonable paths: 1. **Adapter (chosen)** — keep the shared `ioredis` client; shim the six commands `connect-redis` actually calls (`get`, `set` with `{expiration:{type:'EX',value}}`, `expire`, `del`, `mGet`, `scanIterator`) to the node-redis shape. Smallest blast radius — RedisModule, OBO cache (ADR-0014), future pub/sub all stay on a single Redis library. 2. **Switch RedisModule to `node-redis`** — clean alignment with `connect-redis`'s expectations, but touches every Redis consumer and would itself require an ADR amendment. The adapter is reversible: if we ever decide to standardise on `node-redis`, deleting one file removes it. Happy to switch if you'd rather take that path. ## Env vars - `SESSION_ENCRYPTION_KEY` — **mandatory**, AES-256-GCM key (32 bytes after base64url decode). New `assertSessionEncryptionKey()` validator wired in `main.ts` alongside the other pre-flight checks. - `SESSION_IDLE_TIMEOUT_SECONDS` — optional, default `1800`. - `SESSION_ABSOLUTE_TIMEOUT_SECONDS` — optional, default `43200` (consumed by the absolute-timeout interceptor in a follow-up). `.env.example` updated; the three variables are promoted from the "future vars" block to the active section. ## Test plan - [x] `pnpm nx test portal-bff` — **99/99 pass** (was 62 before this PR; +37 new specs across the 5 new files). - [x] `pnpm nx build portal-bff` — clean webpack build. - [x] `pnpm nx lint portal-bff` — clean. - [x] Prettier-clean for all PR source files. - [ ] Local smoke test once the next PR wires `/auth/callback` → `req.session.user`; this PR has no user-visible behaviour to exercise on its own.
julien added 1 commit 2026-05-12 17:59:49 +02:00
feat(portal-bff): session middleware with AES-256-GCM at rest per ADR-0010
CI / commits (pull_request) Successful in 2m46s
CI / scan (pull_request) Failing after 2m47s
CI / check (pull_request) Successful in 4m24s
CI / a11y (pull_request) Successful in 1m51s
CI / perf (pull_request) Successful in 3m24s
ed6cca499d
mount express-session + connect-redis at bootstrap on top of the
shared ioredis client. the full json payload is encrypted with
aes-256-gcm before it reaches redis; envelope is versioned
(v1.<iv>.<tag>.<ciphertext>, base64url) so the algorithm or key
derivation can rotate without a flag-day re-encryption.

scope is intentionally infrastructure-only — middleware mounted,
req.session available downstream, cookie set on first write,
encryption-at-rest active. populating req.session.user from
/auth/callback, /me, /auth/logout, and the absolute-timeout
interceptor land in follow-ups.

notable shape choices captured in ADR-0010 (amended here):
- encryption at rest applies to the whole payload, not just a tokens
  sub-field. the session also carries pii claims (oid, tid,
  preferred_username) — encrypting the envelope removes the need to
  classify fields one by one and costs essentially the same.
- connect-redis v9 was rewritten for node-redis v4 and dropped
  ioredis. rather than swap the whole bff to node-redis, a small
  adapter shapes the six commands connect-redis actually calls
  (get / set with {expiration:{type:'EX',value}} / expire / del /
  mGet / scanIterator) to look like node-redis. the rest of the
  bff stays on a single shared ioredis client.

session id: crypto.randomBytes(32).toString('base64url')
cookie name: __Host-portal_session in production, portal_session in
dev (the __Host- prefix mandates Secure which dev http can't
provide). httpOnly + sameSite=lax + path=/. resave:false,
saveUninitialized:false, rolling:true; cookie maxAge follows
SESSION_IDLE_TIMEOUT_SECONDS (default 1800).

env: SESSION_ENCRYPTION_KEY is mandatory (32 bytes after base64url
decode); rejected at boot via a new assertSessionEncryptionKey()
mirroring the other pre-flight validators. SESSION_IDLE_TIMEOUT_SECONDS
and SESSION_ABSOLUTE_TIMEOUT_SECONDS are optional with adr-aligned
defaults (1800 / 43200).
julien merged commit 758d723744 into main 2026-05-12 18:06:14 +02:00
julien deleted branch feat/portal-bff/session-express-middleware 2026-05-12 18:06:15 +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#110