feat(portal-bff): session middleware with AES-256-GCM at rest per ADR-0010 (#110)
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

## 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
This commit was merged in pull request #110.
This commit is contained in:
2026-05-12 18:06:13 +02:00
parent d4b5ed1c5d
commit 758d723744
17 changed files with 10911 additions and 3477 deletions
@@ -0,0 +1,111 @@
import { randomBytes } from 'node:crypto';
import { Module } from '@nestjs/common';
import { RedisStore } from 'connect-redis';
import expressSession from 'express-session';
import { Logger } from 'nestjs-pino';
import { assertSessionEncryptionKey } from '../config/check-session-encryption-key';
import { assertSessionSecret } from '../config/check-session-secret';
import { RedisModule } from '../redis/redis.module';
import { REDIS_CLIENT, type Redis } from '../redis/redis.token';
import { adaptIoredisForConnectRedis } from './ioredis-connect-redis-adapter';
import { SessionDecryptError, decrypt, encrypt } from './session-crypto';
import { readSessionTimeouts, sessionCookieName, sessionCookieOptions } from './session-cookie';
import { SESSION_MIDDLEWARE, type RequestHandler } from './session.token';
/**
* Session module — wires `express-session` with a `connect-redis`
* store on top of the shared `ioredis` client, with AES-256-GCM
* encryption applied to the JSON payload before it lands in Redis.
*
* The configured middleware is exposed as a NestJS provider under
* the {@link SESSION_MIDDLEWARE} token. `main.ts` resolves it from
* the application context and mounts it once via `app.use(...)`
* after `cookie-parser`. Mounting the express-session middleware
* through DI (rather than constructing it in `main.ts`) keeps it on
* the same Redis client the rest of the BFF uses, instead of
* spinning up a second connection at the bootstrap layer.
*
* Scope of this PR — infrastructure only:
* - middleware mounted on every request
* - per-request `req.session` available downstream
* - cookie set on first write (`saveUninitialized: false`)
* - encryption-at-rest active
*
* Out of scope, landing in follow-ups (per ADR-0010):
* - `/auth/callback` populating `req.session.user`
* - `/me`, `/auth/logout`
* - absolute-timeout interceptor
* - `user_sessions:{userId}` secondary index
* - JSON-decode error → audit event with `event:
* session.decrypt_failed` (the throw is in place; audit
* pipeline lands with ADR-0013).
*/
@Module({
imports: [RedisModule],
providers: [
{
provide: SESSION_MIDDLEWARE,
inject: [REDIS_CLIENT, Logger],
useFactory: (redis: Redis, logger: Logger): RequestHandler => {
const secret = assertSessionSecret();
const key = assertSessionEncryptionKey();
const timeouts = readSessionTimeouts();
const store = new RedisStore({
// `connect-redis` v9 was rewritten against the
// `node-redis` v4 command surface; the adapter shims
// `ioredis` to look the same for the handful of commands
// the store actually calls.
client: adaptIoredisForConnectRedis(redis) as unknown as never,
prefix: 'session:',
ttl: timeouts.idleSeconds,
serializer: {
stringify: (sess) => encrypt(JSON.stringify(sess), key),
parse: (payload) => {
try {
return JSON.parse(decrypt(payload, key));
} catch (err) {
// Tamper, wrong key, or unknown version. The phase-2
// audit pipeline (ADR-0013) will turn this into a
// first-class audit event; for now we surface a
// structured Pino log so ops can spot it.
logger.warn(
{
event: 'session.decrypt_failed',
reason: err instanceof SessionDecryptError ? err.message : String(err),
},
'session',
);
throw err;
}
},
},
});
return expressSession({
name: sessionCookieName(),
secret,
// `crypto.randomBytes(32).toString('base64url')` ⇒ 256
// bits of entropy in the session id, per ADR-0010
// §"Confirmation".
genid: () => randomBytes(32).toString('base64url'),
store,
// `resave: false` — RedisStore.touch refreshes the TTL on
// every request; no need to rewrite the payload.
resave: false,
// `saveUninitialized: false` — don't create empty session
// keys for unauthenticated visitors browsing public
// routes. The session is born when `/auth/callback`
// populates it.
saveUninitialized: false,
// `rolling: true` — the cookie's `expires` slides forward
// on every response, matching the sliding-idle policy.
rolling: true,
cookie: sessionCookieOptions(timeouts.idleSeconds),
});
},
},
],
exports: [SESSION_MIDDLEWARE],
})
export class SessionModule {}