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
This commit was merged in pull request #110.
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { assertSessionEncryptionKey } from './check-session-encryption-key';
|
||||
|
||||
const STRONG_KEY = randomBytes(32).toString('base64url');
|
||||
|
||||
describe('assertSessionEncryptionKey', () => {
|
||||
const original = process.env['SESSION_ENCRYPTION_KEY'];
|
||||
|
||||
afterEach(() => {
|
||||
if (original === undefined) {
|
||||
delete process.env['SESSION_ENCRYPTION_KEY'];
|
||||
} else {
|
||||
process.env['SESSION_ENCRYPTION_KEY'] = original;
|
||||
}
|
||||
});
|
||||
|
||||
it('returns the decoded 32-byte buffer for a well-formed key', () => {
|
||||
process.env['SESSION_ENCRYPTION_KEY'] = STRONG_KEY;
|
||||
const key = assertSessionEncryptionKey();
|
||||
expect(key).toBeInstanceOf(Buffer);
|
||||
expect(key.length).toBe(32);
|
||||
expect(key.equals(Buffer.from(STRONG_KEY, 'base64url'))).toBe(true);
|
||||
});
|
||||
|
||||
it('throws when SESSION_ENCRYPTION_KEY is unset', () => {
|
||||
delete process.env['SESSION_ENCRYPTION_KEY'];
|
||||
expect(() => assertSessionEncryptionKey()).toThrow(/SESSION_ENCRYPTION_KEY is not set/);
|
||||
});
|
||||
|
||||
it('throws when SESSION_ENCRYPTION_KEY is the .env.example placeholder', () => {
|
||||
process.env['SESSION_ENCRYPTION_KEY'] = 'replace_with_32_random_bytes_base64url';
|
||||
expect(() => assertSessionEncryptionKey()).toThrow(/placeholder/);
|
||||
});
|
||||
|
||||
it('throws when SESSION_ENCRYPTION_KEY decodes to fewer than 32 bytes', () => {
|
||||
process.env['SESSION_ENCRYPTION_KEY'] = randomBytes(16).toString('base64url');
|
||||
expect(() => assertSessionEncryptionKey()).toThrow(/decodes to 16 bytes/);
|
||||
});
|
||||
|
||||
it('throws when SESSION_ENCRYPTION_KEY decodes to more than 32 bytes', () => {
|
||||
process.env['SESSION_ENCRYPTION_KEY'] = randomBytes(64).toString('base64url');
|
||||
expect(() => assertSessionEncryptionKey()).toThrow(/decodes to 64 bytes/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Sanity-check the `SESSION_ENCRYPTION_KEY` env var early in
|
||||
* bootstrap so a missing or obviously weak value fails fast instead
|
||||
* of producing weak at-rest encryption at runtime.
|
||||
*
|
||||
* Wired in `main.ts` alongside the other `assertX()` validators —
|
||||
* same pre-flight family per ADR-0018 §"BFF env-var loading".
|
||||
*
|
||||
* `SESSION_ENCRYPTION_KEY` is the AES-256-GCM key used by the
|
||||
* session middleware to encrypt the payload before `connect-redis`
|
||||
* writes it to Redis (per ADR-0010 §"At-rest encryption"). It is
|
||||
* **distinct** from `SESSION_SECRET`, which only signs the cookie's
|
||||
* session id — never reuse one for the other.
|
||||
*
|
||||
* Returns the decoded 32-byte key as a `Buffer` so callers can pass
|
||||
* it straight to `crypto.createCipheriv('aes-256-gcm', key, iv)`
|
||||
* without re-decoding per request.
|
||||
*/
|
||||
|
||||
const PLACEHOLDER = 'replace_with_32_random_bytes_base64url';
|
||||
const REQUIRED_KEY_BYTES = 32;
|
||||
|
||||
export function assertSessionEncryptionKey(): Buffer {
|
||||
const raw = process.env['SESSION_ENCRYPTION_KEY'];
|
||||
if (!raw || raw === '') {
|
||||
throw new Error(
|
||||
`SESSION_ENCRYPTION_KEY is not set. Generate one with ` +
|
||||
`"node -e \\"console.log(require('crypto').randomBytes(32).toString('base64url'))\\"" ` +
|
||||
`and put it in apps/portal-bff/.env.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (raw === PLACEHOLDER) {
|
||||
throw new Error(
|
||||
`SESSION_ENCRYPTION_KEY is still set to the .env.example placeholder ` +
|
||||
`("${PLACEHOLDER}"). Replace with a real random value.`,
|
||||
);
|
||||
}
|
||||
|
||||
let decoded: Buffer;
|
||||
try {
|
||||
decoded = Buffer.from(raw, 'base64url');
|
||||
} catch {
|
||||
throw new Error(
|
||||
`SESSION_ENCRYPTION_KEY must be a base64url-encoded string. Got: ${truncate(raw)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// AES-256-GCM mandates a 32-byte key — anything else is rejected by
|
||||
// `crypto.createCipheriv` at first call. Catch the misconfiguration
|
||||
// at boot, not on the first authenticated request.
|
||||
if (decoded.length !== REQUIRED_KEY_BYTES) {
|
||||
throw new Error(
|
||||
`SESSION_ENCRYPTION_KEY decodes to ${decoded.length} bytes, ` +
|
||||
`but AES-256-GCM requires exactly ${REQUIRED_KEY_BYTES}. ` +
|
||||
`Generate a fresh value.`,
|
||||
);
|
||||
}
|
||||
|
||||
return decoded;
|
||||
}
|
||||
|
||||
function truncate(s: string): string {
|
||||
return s.length > 16 ? `${s.slice(0, 16)}…` : s;
|
||||
}
|
||||
Reference in New Issue
Block a user