feat(portal-bff): session middleware with AES-256-GCM at rest per ADR-0010
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).
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
---
|
||||
status: accepted
|
||||
date: 2026-04-29
|
||||
last-updated: 2026-05-12
|
||||
decision-makers: R&D Lead
|
||||
tags: [security, backend, infrastructure]
|
||||
---
|
||||
@@ -94,7 +95,9 @@ type SessionPayload = {
|
||||
};
|
||||
```
|
||||
|
||||
**Encryption at rest.** The `tokens` field is encrypted with **AES-256-GCM** before serialisation, using a key read from `SESSION_ENCRYPTION_KEY` (32 bytes, base64-encoded). A fresh 96-bit IV is generated per session and prepended to the ciphertext; the GCM auth tag is appended. A `RedisSessionStore` wrapper around `connect-redis` performs encryption on `set`/`touch` and decryption on `get`. Key rotation procedure (overlap window, re-encryption) is deferred to a future operations ADR.
|
||||
**Encryption at rest.** The **entire serialised session payload** is encrypted with **AES-256-GCM** before it lands in Redis, using a key read from `SESSION_ENCRYPTION_KEY` (32 bytes, base64url-encoded). A fresh 96-bit IV is generated per write; the GCM auth tag is appended; the envelope is a versioned dot-delimited string (`v1.<iv>.<tag>.<ciphertext>`, all base64url) so the algorithm / key derivation can rotate without a flag-day re-encryption. The encryption hook is the `connect-redis` `serializer` option (no `RedisSessionStore` wrapper class needed) — encryption runs on `set`, decryption on `get`. An earlier draft of this ADR scoped encryption to just the `tokens` sub-field; v1 ships with whole-payload encryption because the session also carries claims (`oid`, `tid`, `preferred_username`, …) that qualify as PII under GDPR — encrypting the envelope is strictly stronger and removes the need to classify fields one by one. Key rotation procedure (overlap window, re-encryption) is deferred to a future operations ADR.
|
||||
|
||||
**Redis client.** `ioredis` for the BFF-wide shared connection. `connect-redis` v9 was rewritten against `node-redis` v4 and no longer accepts `ioredis` directly; the BFF ships a small adapter (`session/ioredis-connect-redis-adapter.ts`) that shapes the six commands `connect-redis` actually calls (`get`, `set` with `{expiration:{type:'EX',value}}`, `expire`, `del`, `mGet`, `scanIterator`) to the `node-redis` surface. Keeping `ioredis` as primary means the rest of the BFF (OBO cache, future Redis consumers, Sentinel topology) stays on a single client and a single connection pool; the adapter is the only place that knows about the impedance mismatch.
|
||||
|
||||
**TTL policy.**
|
||||
|
||||
@@ -156,8 +159,8 @@ The BFF refuses to start if any required variable is missing or malformed (e.g.
|
||||
|
||||
### Confirmation
|
||||
|
||||
- `apps/portal-bff/src/session/session.module.ts` configures `express-session` + `connect-redis` against the env-provided Redis target.
|
||||
- The session store is wrapped in a `RedisSessionStore` that applies AES-256-GCM encryption to the `tokens` field on `set`/`touch` and decryption on `get`. The wrapper rejects (and logs as an audit event) any record whose authentication tag fails to verify — this catches both tampering and a wrong key.
|
||||
- `apps/portal-bff/src/session/session.module.ts` configures `express-session` + `connect-redis` against the shared `ioredis` client (via `ioredis-connect-redis-adapter.ts`).
|
||||
- The `connect-redis` `serializer` option applies AES-256-GCM encryption to the JSON-encoded session on `set` and decryption on `get` (see `session/session-crypto.ts`). The serializer rejects (and logs an audit event) any record whose authentication tag fails to verify — this catches both tampering and a wrong key.
|
||||
- The session id is generated via `crypto.randomBytes(32).toString('base64url')`.
|
||||
- Cookie name `__Host-portal_session` (per ADR-0009); cookie attributes asserted by integration tests.
|
||||
- `absoluteExpiresAt` is checked in a global NestJS interceptor before any controller logic; expiry triggers `DEL` and 401.
|
||||
|
||||
Reference in New Issue
Block a user