feat(portal-bff): session middleware with AES-256-GCM at rest per ADR-0010 #110
Reference in New Issue
Block a user
Delete Branch "feat/portal-bff/session-express-middleware"
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
Mounts
express-session+connect-redisat bootstrap on top of the sharedioredisclient, 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) andmain.tsmounts it throughapp.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 raiseSessionDecryptError; for now the failure is logged via Pino withevent: session.decrypt_failed— the first-class audit event lands with ADR-0013.Scope is intentionally infrastructure only:
req.sessionavailable downstreamcrypto.randomBytes(32).toString('base64url')(256 bits per ADR-0010)__Host-portal_sessionin production,portal_sessionin dev (the__Host-prefix mandatesSecure, which dev HTTP can't satisfy)httpOnly + sameSite=lax + path=/;resave:false,saveUninitialized:false,rolling:truemaxAgefollowsSESSION_IDLE_TIMEOUT_SECONDS(default 1800)Out of scope, landing in follow-ups:
/auth/callbackpopulatingreq.session.user,/me,/auth/logout, the absolute-timeout interceptor, and theuser_sessions:{userId}secondary index.Notable shape choices (ADR-0010 amended in the same commit)
Full-payload encryption vs. just the
tokensfield. The first draft of ADR-0010 scoped at-rest encryption to atokenssub-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 tonode-redis.connect-redisv9 was rewritten fornode-redisv4 and no longer acceptsioredisdirectly. Two reasonable paths:ioredisclient; shim the six commandsconnect-redisactually calls (get,setwith{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.node-redis— clean alignment withconnect-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). NewassertSessionEncryptionKey()validator wired inmain.tsalongside the other pre-flight checks.SESSION_IDLE_TIMEOUT_SECONDS— optional, default1800.SESSION_ABSOLUTE_TIMEOUT_SECONDS— optional, default43200(consumed by the absolute-timeout interceptor in a follow-up)..env.exampleupdated; the three variables are promoted from the "future vars" block to the active section.Test plan
pnpm nx test portal-bff— 99/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./auth/callback→req.session.user; this PR has no user-visible behaviour to exercise on its own.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).