Files
apf_portal/docs/decisions/0010-session-management-redis.md
julien 758d723744
CI / commits (push) Has been skipped
CI / scan (push) Failing after 2m25s
CI / check (push) Successful in 3m48s
CI / a11y (push) Successful in 1m27s
CI / perf (push) Successful in 4m28s
feat(portal-bff): session middleware with AES-256-GCM at rest per ADR-0010 (#110)
## 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.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #110
2026-05-12 18:06:13 +02:00

17 KiB

status, date, last-updated, decision-makers, tags
status date last-updated decision-makers tags
accepted 2026-04-29 2026-05-12 R&D Lead
security
backend
infrastructure

Session management — opaque session IDs in cookies, payload in self-hosted Redis with AES-GCM at rest

Context and Problem Statement

ADR-0009 fixed the authentication flow: the BFF holds id/access/refresh tokens server-side and the browser only carries an opaque __Host-portal_session cookie. This ADR pins where the session lives, in what shape, with which TTL policy, and with which security posture.

Two non-negotiable constraints frame the choices:

  • the BFF must be able to revoke a session immediately (logout, security incident, admin action);
  • the session payload contains tokens that, if exfiltrated from Redis, would let an attacker impersonate the user — so storage must protect against snapshot or memory-dump leaks, not only against network-level theft.

Decision Drivers

  • Immediate, server-side revocation (logout, force-logout-everywhere).
  • Defense in depth at the storage layer — Redis security is necessary but not sufficient.
  • Production HA with a self-hosted Redis (per the on-prem constraint of the project).
  • Battle-tested integration in NestJS, no bricolage on a security-critical path.
  • TTL policy aligned with enterprise norms (idle + absolute), without harming UX.
  • Listing and revoking active sessions for a given user must be possible without a second persistence layer.
  • Configuration is env-driven; no host or secret in source.

Considered Options

Storage backend

  • Redis (self-hosted). (Chosen.)
  • PostgreSQL session table.
  • In-memory (single-node only).
  • Cookie-session (signed payload in the cookie itself).

NestJS integration

  • express-session + connect-redis middleware mounted under the Express adapter. (Chosen.)
  • A custom Redis-backed session service.
  • A NestJS-specific wrapper (nestjs-session, etc.).

Session ID format

  • Opaque crypto-random ≥ 256 bits. (Chosen.)
  • JWT-as-session-id.

Encryption at rest

  • AES-256-GCM applied by the BFF before tokens are stored. (Chosen.)
  • No application-level encryption (rely on Redis ACL + TLS only).

TTL policy

  • Idle (sliding) 30 min + absolute 12 h. (Chosen.)
  • Shorter idle (e.g. 15 min) — friction.
  • Longer absolute (e.g. 24 h) — risk window.
  • No idle expiry.

Production topology

  • Redis Sentinel HA (3+ nodes). (Chosen.) Operational details deferred to a phase-3 infrastructure ADR.
  • Redis Cluster.
  • Single node (dev only).

Decision Outcome

Backend. Redis, self-hosted, accessed via the standard express-session + connect-redis middleware mounted at NestJS bootstrap (the BFF runs on the Express adapter per ADR-0005). The Redis client is ioredis (Sentinel support, mature in Node).

Cookie ↔ session binding. The browser carries __Host-portal_session (defined in ADR-0009) containing a 256-bit crypto-random session id signed with SESSION_SECRET. The BFF dereferences the id against Redis to retrieve the session payload. The browser sees nothing else.

Session payload (Redis value, JSON-encoded):

type SessionPayload = {
  userId: string;
  audience: 'workforce' | 'customer';
  claims: {
    // curated subset of the id_token, never the full token
    sub: string;
    oid: string;
    tid: string; // home tenant of the user
    name?: string;
    preferred_username?: string;
    roles?: string[];
  };
  tokens: EncryptedBlob; // AES-256-GCM ciphertext of { id_token, access_token, refresh_token, expiresAt }
  createdAt: number; // epoch ms
  lastSeenAt: number; // epoch ms — updated on each request
  absoluteExpiresAt: number; // epoch ms — createdAt + SESSION_ABSOLUTE_TIMEOUT
  ip?: string; // remote IP at session creation, for audit
  userAgent?: string; // UA at session creation, for audit
};

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.

Field Default Source of truth
Idle (sliding) timeout 30 min SESSION_IDLE_TIMEOUT_SECONDS
Absolute timeout 12 h SESSION_ABSOLUTE_TIMEOUT_SECONDS

Mechanics:

  • The Redis key carries an EXPIRE matching the idle timeout; every authenticated request refreshes it via connect-redis's touch.
  • absoluteExpiresAt is recorded at session creation and checked on every request. If exceeded, the BFF deletes the session key and returns 401.
  • The two checks are independent: a session ends at whichever timeout fires first.

Defaults are policy decisions, not technical limits — they can be tuned per environment via env without code changes.

Token refresh. Access tokens (Entra-issued, ~1 h lifetime) are refreshed via acquireTokenSilent (MSAL Node) when an authenticated handler observes the access token is within ~5 min of expiry. The refresh token (Entra workforce, ~90-day sliding) is stored in the encrypted blob alongside the access token. Refresh-token rotation is enabled (cf. ADR-0009).

Revocation.

  • POST /auth/logout deletes the session key immediately (DEL session:{id}).
  • An admin "log out user X everywhere" operation lists keys via a secondary index user_sessions:{userId} (a Redis set of session ids maintained on session create/destroy) and DEL-s them.
  • An admin "log out everyone" operation is intentionally not provided as a one-shot endpoint — it would be implemented as a runbook, not a feature, to avoid creating an obvious abuse vector.

Active-sessions listing. The optional user_sessions:{userId} index supports listing of active sessions per user (for an admin dashboard or a user-side "my active sessions" view). No PostgreSQL mirror — historical trace lives in the audit log (future ADR).

Topology.

  • Production: Redis Sentinel with at least 3 nodes (1 master, 2 replicas, 3 sentinel processes for quorum), TLS in transit, ACL-restricted credentials, AOF persistence at least every second. Hosting and operational specifics (k8s operator, backup strategy, monitoring) are deferred to a phase-3 infrastructure ADR.
  • Development: a single Redis instance (Docker container or bare process), no TLS, no persistence required. Same connection-string interface (REDIS_URL or Sentinel-style REDIS_SENTINEL_HOSTS / REDIS_SENTINEL_NAME) so code paths are identical.

Configuration (env-driven).

Variable Purpose
REDIS_URL or REDIS_SENTINEL_HOSTS + REDIS_SENTINEL_NAME connection target
REDIS_PASSWORD client-side ACL credential
REDIS_TLS 'true'/'false' — required true in prod
SESSION_SECRET cookie-signing HMAC secret (also used by ADR-0009)
SESSION_ENCRYPTION_KEY 32-byte base64 AES-GCM key for the encrypted token blob
SESSION_IDLE_TIMEOUT_SECONDS default 1800
SESSION_ABSOLUTE_TIMEOUT_SECONDS default 43200

The BFF refuses to start if any required variable is missing or malformed (e.g. encryption key not exactly 32 bytes after base64 decode).

Consequences

  • Good, because server-side state means a session can be invalidated by a single Redis command, instantly, with no race window.
  • Good, because AES-GCM at rest defends against Redis snapshot, RDB dump, or memory-inspection scenarios — exfiltrated ciphertext is useless without the key.
  • Good, because the TTL policy (30 min idle / 12 h absolute) is consistent with enterprise standards and well within Entra's token lifetimes.
  • Good, because Sentinel gives a clean failover story for self-hosted prod — no SaaS dependency.
  • Good, because connect-redis is mature and used by thousands of Node services; no bricolage on a security-critical path.
  • Good, because the user_sessions:{userId} secondary index covers "list / revoke per user" without a Postgres mirror.
  • Bad, because we now manage two long-lived secrets (SESSION_SECRET, SESSION_ENCRYPTION_KEY) — both must be backed up, rotated, and distributed safely. The rotation procedure is a real operational item (future ADR).
  • Bad, because Sentinel deployment, monitoring, and persistence are non-trivial — flagged for the phase-3 infrastructure ADR; this is real ops work, not a footnote.
  • Bad, because 30-min idle is short for tasks like writing a long form. Mitigation: the SPA can fire a lightweight heartbeat to /auth/me (already an existing route) on user activity to keep the session warm; this remains a UX detail, not an architectural one.
  • Bad, because the secondary user_sessions:{userId} index adds two extra Redis writes per session lifecycle event and must be kept consistent — mitigated by treating it as best-effort (orphan entries are tolerated; expired session ids are cleaned during the next list/revoke operation).
  • Neutral, because no Postgres mirror means "active sessions" exists only in Redis; if Redis is wiped, all users are logged out. That is the intended behaviour — sessions are ephemeral by design.

Confirmation

  • 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.
  • user_sessions:{userId} membership is maintained on session create / destroy; "log out everywhere" is exposed as a controller method on an admin module (future).
  • Redis client is ioredis; in prod, configured via Sentinel with TLS; in dev, against REDIS_URL.
  • Integration tests cover: login → session created; subsequent request → idle TTL refreshed; 30 min idle → 401; 12 h elapsed → 401 even under activity; logout → key deleted; tampered token blob → reject + audit; admin force-logout → all sessions for the target user deleted.
  • helmet and the BFF startup checks reject missing or malformed SESSION_SECRET, SESSION_ENCRYPTION_KEY.

Pros and Cons of the Options

Storage backend

Redis self-hosted (chosen)

  • Good, because in-memory fast, native TTL, sub-ms latency on session reads on the hot path.
  • Good, because immediate revocation, atomic operations, mature ecosystem.
  • Good, because aligned with the on-prem constraint without a SaaS dependency.
  • Bad, because operating a Sentinel cluster is non-trivial — flagged for the phase-3 infra ADR.

PostgreSQL session table

  • Good, because we already need PostgreSQL (ADR-0006) — no new component.
  • Bad, because every authenticated request would hit a relational DB for a hot, ephemeral key-value lookup. Slower, more contention, less natural TTL.
  • Bad, because mixing transient session state with durable business data conflates concerns.

In-memory

  • Bad, because no HA, sessions lost at every restart, scale-out impossible.
  • Good, because stateless server.
  • Bad, because revocation is impossible without an extra blacklist; the session keeps "working" until the cookie naturally expires. Unacceptable for a security-sensitive portal.
  • Bad, because the cookie carrying tokens (even encrypted) is sent on every request — header bloat and a more attractive target.

NestJS integration

express-session + connect-redis (chosen)

  • Good, because the de facto standard pair in Node, mature, well-understood.
  • Good, because runs natively under the Express adapter NestJS already uses.
  • Bad, because two libraries to keep up to date — manageable.

Custom Redis service

  • Bad, because reinvents connect-redis poorly. Bricolage on a security path.

NestJS-specific wrapper

  • Neutral, because adds nothing meaningful over the express-session + connect-redis baseline.

Session ID format

Opaque random (chosen)

  • Good, because zero information leaked client-side; revocation is just a DEL.
  • Good, because compatible with __Host-portal_session and the BFF pattern.

JWT-as-session-id

  • Bad, because brittle (signature, claims surface), revocation requires a blacklist, and exposes structure that opaque IDs hide.

Encryption at rest

AES-256-GCM (chosen)

  • Good, because authenticated encryption — tamper detection comes for free.
  • Good, because defends against Redis exfiltration scenarios that go beyond the network/ACL boundary.
  • Bad, because adds a second secret to manage and rotate.

No application-level encryption

  • Bad, because relies entirely on Redis being uncompromisable. Anyone who reads a memory dump or RDB snapshot gets working tokens.

TTL policy

30 min idle / 12 h absolute (chosen)

  • Good, because aligned with enterprise practice and well within Entra refresh-token lifetimes.
  • Good, because limits the window of damage from a leaked session id.
  • Bad, because mildly UX-disruptive on long inactive periods (mitigated by heartbeat).

15 min idle / 8 h absolute

  • Good, because tighter security.
  • Bad, because more user friction; would require explicit UX consideration.

60 min idle / 24 h absolute

  • Good, because more user-friendly.
  • Bad, because doubles the risk window after credential loss / session theft.

Production topology

Redis Sentinel HA (chosen)

  • Good, because automatic failover, replica reads possible, simple to reason about.
  • Good, because matches the operational profile of an on-prem deployment.

Redis Cluster

  • Good, because scales beyond a single master.
  • Bad, because more complex; we don't need cross-shard scaling for sessions at expected volume.

More Information