feat(portal-bff): session middleware with AES-256-GCM at rest per ADR-0010
CI / commits (pull_request) Successful in 2m46s
CI / scan (pull_request) Failing after 2m47s
CI / check (pull_request) Successful in 4m24s
CI / a11y (pull_request) Successful in 1m51s
CI / perf (pull_request) Successful in 3m24s

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:
Julien Gautier
2026-05-12 17:56:52 +02:00
parent d4b5ed1c5d
commit ed6cca499d
17 changed files with 10911 additions and 3477 deletions
+21 -3
View File
@@ -84,6 +84,27 @@ SESSION_SECRET=replace_with_32_random_bytes_base64url
# variable supports the dev single-instance shape only.
REDIS_URL=redis://default:redis_dev_change_me@localhost:6379/0
# Session payload encryption (per ADR-0010 §"At-rest encryption").
# AES-256-GCM key for encrypting the session JSON that connect-redis
# writes to Redis, so a Redis dump never carries raw user identities
# / future tokens / claims in plaintext. **Distinct** from
# SESSION_SECRET, which only signs the cookie's session-id — never
# reuse one for the other. Mandatory at boot.
#
# node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
SESSION_ENCRYPTION_KEY=replace_with_32_random_bytes_base64url
# Session timeouts (per ADR-0010). Both optional with sensible
# defaults; override only when staging / prod policy diverges.
# SESSION_IDLE_TIMEOUT_SECONDS — sliding window. Each request
# extends the cookie's `expires` by this many seconds.
# Default 1800 (30 min).
# SESSION_ABSOLUTE_TIMEOUT_SECONDS — hard ceiling. Session is
# destroyed regardless of activity at this age.
# Default 43200 (12 h).
# SESSION_IDLE_TIMEOUT_SECONDS=1800
# SESSION_ABSOLUTE_TIMEOUT_SECONDS=43200
# Future env vars introduced by upcoming phases / ADRs:
#
# Auth flow (ADR-0009) — additional keys wired as the routes land:
@@ -96,9 +117,6 @@ REDIS_URL=redis://default:redis_dev_change_me@localhost:6379/0
# REDIS_SENTINEL_HOSTS (CSV `host:port,host:port,…`; prod HA)
# REDIS_SENTINEL_NAME (master name in Sentinel; prod HA)
# REDIS_TLS ('true' in prod)
# SESSION_ENCRYPTION_KEY (32-byte base64)
# SESSION_IDLE_TIMEOUT_SECONDS (default 1800)
# SESSION_ABSOLUTE_TIMEOUT_SECONDS (default 43200)
#
# MFA (ADR-0011):
# MFA_FRESHNESS_SECONDS (default 600)
+2
View File
@@ -7,6 +7,7 @@ import { HealthModule } from '../health/health.module';
import { AuditModule } from '../audit/audit.module';
import { AuthModule } from '../auth/auth.module';
import { RedisModule } from '../redis/redis.module';
import { SessionModule } from '../session/session.module';
@Module({
imports: [
@@ -15,6 +16,7 @@ import { RedisModule } from '../redis/redis.module';
AuditModule,
AuthModule,
RedisModule,
SessionModule,
HealthModule,
],
controllers: [AppController],
@@ -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;
}
+19 -4
View File
@@ -11,7 +11,9 @@ import { AppModule } from './app/app.module';
import { assertDatabaseUrl } from './config/check-database-url';
import { assertEntraConfig } from './config/check-entra-config';
import { assertRedisConfig } from './config/check-redis-config';
import { assertSessionEncryptionKey } from './config/check-session-encryption-key';
import { assertSessionSecret } from './config/check-session-secret';
import { SESSION_MIDDLEWARE, type RequestHandler } from './session/session.token';
// Fail fast on a malformed DATABASE_URL (most often a special char in
// the password that needs URL-encoding) rather than letting Prisma
@@ -32,6 +34,12 @@ const sessionSecret = assertSessionSecret();
// reconnect loop.
assertRedisConfig();
// SESSION_ENCRYPTION_KEY is the AES-256-GCM key for session payload
// at-rest encryption (ADR-0010). Same fail-fast policy as the other
// pre-flight validators — a missing / weak key here would only
// surface on the first authenticated request otherwise.
assertSessionEncryptionKey();
async function bootstrap() {
// `bufferLogs: true` holds early-bootstrap log lines until the
// Pino-based Logger is wired in below, so we don't lose anything
@@ -65,12 +73,19 @@ async function bootstrap() {
}),
);
// Cookie parsing for the auth flow (per ADR-0009). The same
// `SESSION_SECRET` will cover the post-login session cookie when
// ADR-0010 storage lands; signed cookies are read from
// `req.signedCookies`, unsigned from `req.cookies`.
// Cookie parsing for the auth flow (per ADR-0009). `SESSION_SECRET`
// signs the pre-auth cookie and the post-login session id cookie;
// signed cookies are read from `req.signedCookies`, unsigned from
// `req.cookies`.
app.use(cookieParser(sessionSecret));
// Session middleware (per ADR-0010). Resolved from the Nest
// container so it sits on the same `ioredis` client the rest of
// the BFF uses, with AES-256-GCM applied to the payload before it
// lands in Redis. Mounted after `cookieParser` so the session id
// cookie is parsed by the time `express-session` reads it.
app.use(app.get<RequestHandler>(SESSION_MIDDLEWARE));
// Phase-2 security ADRs will harden the above: helmet, real CORS
// allowlist, CSRF protection, rate limiting, auth guards,
// structured error filter.
@@ -0,0 +1,116 @@
import IORedis from 'ioredis';
import { adaptIoredisForConnectRedis } from './ioredis-connect-redis-adapter';
/**
* Unit-level tests that pin the **shape** of each adapted call —
* `connect-redis` v9 invokes them in a specific way and any drift
* breaks the session store silently. The behavioural round-trip is
* exercised by `connect-redis`' own integration tests once its
* `RedisStore` is wired in `session.module.spec.ts`.
*/
describe('adaptIoredisForConnectRedis', () => {
function fakeClient(): {
spy: Record<string, jest.Mock>;
client: IORedis;
} {
const spy = {
get: jest.fn().mockResolvedValue('v'),
set: jest.fn().mockResolvedValue('OK'),
expire: jest.fn().mockResolvedValue(1),
del: jest.fn().mockResolvedValue(2),
mget: jest.fn().mockResolvedValue(['v1', null, 'v3']),
scanStream: jest.fn(),
};
// The adapter only touches the 6 methods above — we deliberately
// don't construct a real `ioredis` instance to keep the unit
// pure and avoid hitting the network.
return { spy, client: spy as unknown as IORedis };
}
it('forwards get(key) unchanged', async () => {
const { spy, client } = fakeClient();
const adapter = adaptIoredisForConnectRedis(client);
await adapter.get('sid');
expect(spy['get']).toHaveBeenCalledWith('sid');
});
it('translates set with EX expiration to ioredis varargs', async () => {
const { spy, client } = fakeClient();
const adapter = adaptIoredisForConnectRedis(client);
await adapter.set('sid', 'value', { expiration: { type: 'EX', value: 1800 } });
expect(spy['set']).toHaveBeenCalledWith('sid', 'value', 'EX', 1800);
});
it('translates set without options to a plain ioredis set', async () => {
const { spy, client } = fakeClient();
const adapter = adaptIoredisForConnectRedis(client);
await adapter.set('sid', 'value');
expect(spy['set']).toHaveBeenCalledWith('sid', 'value');
});
it('forwards expire(key, seconds) unchanged', async () => {
const { spy, client } = fakeClient();
const adapter = adaptIoredisForConnectRedis(client);
await adapter.expire('sid', 1800);
expect(spy['expire']).toHaveBeenCalledWith('sid', 1800);
});
it('spreads del([keys]) into ioredis varargs', async () => {
const { spy, client } = fakeClient();
const adapter = adaptIoredisForConnectRedis(client);
await adapter.del(['a', 'b', 'c']);
expect(spy['del']).toHaveBeenCalledWith('a', 'b', 'c');
});
it('short-circuits del([]) without touching the client', async () => {
const { spy, client } = fakeClient();
const adapter = adaptIoredisForConnectRedis(client);
const result = await adapter.del([]);
expect(result).toBe(0);
expect(spy['del']).not.toHaveBeenCalled();
});
it('spreads mGet([keys]) into ioredis varargs and returns the array', async () => {
const { spy, client } = fakeClient();
const adapter = adaptIoredisForConnectRedis(client);
const result = await adapter.mGet(['a', 'b', 'c']);
expect(spy['mget']).toHaveBeenCalledWith('a', 'b', 'c');
expect(result).toEqual(['v1', null, 'v3']);
});
it('short-circuits mGet([]) without touching the client', async () => {
const { spy, client } = fakeClient();
const adapter = adaptIoredisForConnectRedis(client);
expect(await adapter.mGet([])).toEqual([]);
expect(spy['mget']).not.toHaveBeenCalled();
});
it('wraps scanStream into an async iterable of key arrays', async () => {
const { spy, client } = fakeClient();
const fakeStream = makeReadable([['session:a', 'session:b'], ['session:c']]);
(spy['scanStream'] as jest.Mock).mockReturnValueOnce(fakeStream);
const adapter = adaptIoredisForConnectRedis(client);
const iterator = adapter.scanIterator({ MATCH: 'session:*', COUNT: 100 });
const collected: string[][] = [];
for await (const batch of iterator) {
collected.push(batch);
}
expect(spy['scanStream']).toHaveBeenCalledWith({ match: 'session:*', count: 100 });
expect(collected).toEqual([['session:a', 'session:b'], ['session:c']]);
});
});
function makeReadable<T>(chunks: T[]): NodeJS.ReadableStream {
// Minimal async iterable that mimics `ioredis.scanStream`'s
// for-await contract — the adapter only needs `Symbol.asyncIterator`.
return {
async *[Symbol.asyncIterator]() {
for (const chunk of chunks) {
yield chunk;
}
},
} as unknown as NodeJS.ReadableStream;
}
@@ -0,0 +1,74 @@
import type { Readable } from 'node:stream';
import type { Redis } from 'ioredis';
/**
* `connect-redis` v9 was rewritten for `node-redis` v4 and dropped
* direct support for `ioredis`: it calls `set(key, val, {expiration:
* {type: 'EX', value}})`, `mGet([keys])`, `del([keys])`, and
* `scanIterator({MATCH, COUNT})` — all of which are node-redis
* shapes. ADR-0010 selected `ioredis` for the project's Redis client
* (Sentinel support, single shared client across BFF features); we
* keep `ioredis` as primary and shim it for `connect-redis` here, so
* the impedance mismatch lives in one file instead of leaking into
* the rest of the codebase.
*
* Alternative considered: switch the whole BFF to `node-redis`. Both
* libraries are battle-tested and Sentinel-capable, but the swap
* would touch `RedisModule`, every future Redis consumer (OBO token
* cache, audit indices, etc.), and require an ADR amendment. The
* adapter is the lower-blast-radius choice — easy to delete later if
* we ever decide to align on `node-redis`.
*
* Surface covered: only the commands `connect-redis` actually calls
* (read source at `node_modules/connect-redis/dist/connect-redis.js`
* for the authoritative list). Anything else is intentionally
* absent — `connect-redis` doesn't reach for it.
*/
interface SetOptions {
expiration?: { type: 'EX'; value: number };
}
export interface NodeRedisLikeClient {
get(key: string): Promise<string | null>;
set(key: string, value: string, options?: SetOptions): Promise<unknown>;
expire(key: string, seconds: number): Promise<unknown>;
del(keys: string[]): Promise<unknown>;
mGet(keys: string[]): Promise<(string | null)[]>;
scanIterator(options: { MATCH: string; COUNT: number }): AsyncIterable<string[]>;
}
export function adaptIoredisForConnectRedis(client: Redis): NodeRedisLikeClient {
return {
get: (key) => client.get(key),
set: (key, value, options) => {
const ttl = options?.expiration?.value;
if (ttl !== undefined) {
return client.set(key, value, 'EX', ttl);
}
return client.set(key, value);
},
expire: (key, seconds) => client.expire(key, seconds),
// `ioredis`'s `del` takes varargs (`del(...keys)`); calling it
// with an empty list issues a no-op `DEL` and would error on
// some servers. Short-circuit instead.
del: (keys) => (keys.length === 0 ? Promise.resolve(0) : client.del(...keys)),
mGet: (keys) => (keys.length === 0 ? Promise.resolve([]) : client.mget(...keys)),
// `connect-redis` consumes `scanIterator` with `for await`. The
// node-redis iterator yields arrays of keys; `ioredis`'s
// `scanStream` is a Node `Readable` that emits the same shape.
scanIterator: ({ MATCH, COUNT }) =>
streamToAsyncIterable<string[]>(client.scanStream({ match: MATCH, count: COUNT })),
};
}
async function* streamToAsyncIterable<T>(stream: Readable): AsyncGenerator<T> {
for await (const chunk of stream) {
yield chunk as T;
}
}
@@ -0,0 +1,84 @@
import { readSessionTimeouts, sessionCookieName, sessionCookieOptions } from './session-cookie';
describe('session-cookie', () => {
const originalNodeEnv = process.env['NODE_ENV'];
const originalIdle = process.env['SESSION_IDLE_TIMEOUT_SECONDS'];
const originalAbsolute = process.env['SESSION_ABSOLUTE_TIMEOUT_SECONDS'];
afterEach(() => {
restore('NODE_ENV', originalNodeEnv);
restore('SESSION_IDLE_TIMEOUT_SECONDS', originalIdle);
restore('SESSION_ABSOLUTE_TIMEOUT_SECONDS', originalAbsolute);
});
describe('sessionCookieName', () => {
it('uses the __Host- prefix in production', () => {
process.env['NODE_ENV'] = 'production';
expect(sessionCookieName()).toBe('__Host-portal_session');
});
it('drops the __Host- prefix outside production (dev HTTP server)', () => {
process.env['NODE_ENV'] = 'development';
expect(sessionCookieName()).toBe('portal_session');
});
});
describe('sessionCookieOptions', () => {
it('sets Secure only in production', () => {
process.env['NODE_ENV'] = 'production';
expect(sessionCookieOptions(1800).secure).toBe(true);
process.env['NODE_ENV'] = 'development';
expect(sessionCookieOptions(1800).secure).toBe(false);
});
it('converts the idle timeout into milliseconds', () => {
expect(sessionCookieOptions(1800).maxAge).toBe(1_800_000);
});
it('pins httpOnly, sameSite=lax, and path=/', () => {
const opts = sessionCookieOptions(1800);
expect(opts.httpOnly).toBe(true);
expect(opts.sameSite).toBe('lax');
expect(opts.path).toBe('/');
});
});
describe('readSessionTimeouts', () => {
it('returns the ADR-0010 defaults when env vars are unset', () => {
delete process.env['SESSION_IDLE_TIMEOUT_SECONDS'];
delete process.env['SESSION_ABSOLUTE_TIMEOUT_SECONDS'];
expect(readSessionTimeouts()).toEqual({ idleSeconds: 1800, absoluteSeconds: 43200 });
});
it('parses positive integer overrides', () => {
process.env['SESSION_IDLE_TIMEOUT_SECONDS'] = '900';
process.env['SESSION_ABSOLUTE_TIMEOUT_SECONDS'] = '7200';
expect(readSessionTimeouts()).toEqual({ idleSeconds: 900, absoluteSeconds: 7200 });
});
it('rejects non-numeric values', () => {
process.env['SESSION_IDLE_TIMEOUT_SECONDS'] = 'half-an-hour';
expect(() => readSessionTimeouts()).toThrow(/must be a positive integer/);
});
it('rejects zero and negative values', () => {
process.env['SESSION_IDLE_TIMEOUT_SECONDS'] = '0';
expect(() => readSessionTimeouts()).toThrow(/must be a positive integer/);
process.env['SESSION_IDLE_TIMEOUT_SECONDS'] = '-1';
expect(() => readSessionTimeouts()).toThrow(/must be a positive integer/);
});
it('rejects fractional values', () => {
process.env['SESSION_IDLE_TIMEOUT_SECONDS'] = '1800.5';
expect(() => readSessionTimeouts()).toThrow(/must be a positive integer/);
});
});
});
function restore(name: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[name];
} else {
process.env[name] = value;
}
}
@@ -0,0 +1,76 @@
import type { CookieOptions } from 'express';
/**
* Name of the persistent post-auth session cookie (per ADR-0009 §
* "Cookies" and ADR-0010). Carries the opaque session id signed by
* `SESSION_SECRET`; the payload itself lives in Redis.
*
* Production uses the `__Host-` prefix, which browsers only accept
* when the cookie is set with `Secure`, `Path=/`, and no `Domain`
* attribute — a hard binding that defeats a class of cross-site
* cookie-injection attacks. Local dev runs on HTTP, where `Secure`
* isn't possible, so the prefix is dropped to keep the dev server
* usable. The two are toggled by `NODE_ENV`, same convention as
* {@link preAuthCookieName}.
*/
const PRODUCTION_NAME = '__Host-portal_session';
const DEVELOPMENT_NAME = 'portal_session';
export function sessionCookieName(): string {
return process.env['NODE_ENV'] === 'production' ? PRODUCTION_NAME : DEVELOPMENT_NAME;
}
/**
* Defaults from ADR-0010 §"TTL policy":
* idle (sliding): 30 min — cookie `maxAge`, refreshed on each
* request by `express-session` with `rolling: true`.
* absolute: 12 h — checked separately in a future interceptor;
* not enforced by the cookie itself.
*
* Both overridable via env so staging / prod can tighten without a
* code change.
*/
const DEFAULT_IDLE_TIMEOUT_SECONDS = 1800;
const DEFAULT_ABSOLUTE_TIMEOUT_SECONDS = 43200;
export interface SessionTimeouts {
readonly idleSeconds: number;
readonly absoluteSeconds: number;
}
export function readSessionTimeouts(): SessionTimeouts {
return {
idleSeconds: parsePositiveInt(
process.env['SESSION_IDLE_TIMEOUT_SECONDS'],
DEFAULT_IDLE_TIMEOUT_SECONDS,
'SESSION_IDLE_TIMEOUT_SECONDS',
),
absoluteSeconds: parsePositiveInt(
process.env['SESSION_ABSOLUTE_TIMEOUT_SECONDS'],
DEFAULT_ABSOLUTE_TIMEOUT_SECONDS,
'SESSION_ABSOLUTE_TIMEOUT_SECONDS',
),
};
}
export function sessionCookieOptions(idleSeconds: number): CookieOptions {
const isProduction = process.env['NODE_ENV'] === 'production';
return {
httpOnly: true,
sameSite: 'lax',
secure: isProduction,
path: '/',
maxAge: idleSeconds * 1000,
};
}
function parsePositiveInt(raw: string | undefined, fallback: number, name: string): number {
if (raw === undefined || raw === '') {
return fallback;
}
const value = Number(raw);
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer (got "${raw}").`);
}
return value;
}
@@ -0,0 +1,82 @@
import { randomBytes } from 'node:crypto';
import { SessionDecryptError, decrypt, encrypt } from './session-crypto';
const KEY = randomBytes(32);
const OTHER_KEY = randomBytes(32);
const PLAINTEXT = JSON.stringify({ user: { oid: 'abc', tid: 'def' }, createdAt: 1714000000000 });
describe('session-crypto', () => {
it('round-trips a payload', () => {
const ciphertext = encrypt(PLAINTEXT, KEY);
expect(decrypt(ciphertext, KEY)).toBe(PLAINTEXT);
});
it('produces the v1 envelope shape', () => {
const ciphertext = encrypt(PLAINTEXT, KEY);
const parts = ciphertext.split('.');
expect(parts).toHaveLength(4);
expect(parts[0]).toBe('v1');
});
it('uses a fresh IV per encryption', () => {
const a = encrypt(PLAINTEXT, KEY);
const b = encrypt(PLAINTEXT, KEY);
expect(a).not.toBe(b);
// Same plaintext + key + different IV ⇒ different ciphertexts but
// both decrypt back to the same value.
expect(decrypt(a, KEY)).toBe(PLAINTEXT);
expect(decrypt(b, KEY)).toBe(PLAINTEXT);
});
it('rejects ciphertext encrypted under a different key', () => {
const ciphertext = encrypt(PLAINTEXT, KEY);
expect(() => decrypt(ciphertext, OTHER_KEY)).toThrow(SessionDecryptError);
});
it('rejects tampered ciphertext (auth tag verification)', () => {
const ciphertext = encrypt(PLAINTEXT, KEY);
const parts = ciphertext.split('.');
// Flip a byte in the ciphertext segment.
const ct = Buffer.from(parts[3] as string, 'base64url');
ct[0] = (ct[0] ?? 0) ^ 0xff;
parts[3] = ct.toString('base64url');
const tampered = parts.join('.');
expect(() => decrypt(tampered, KEY)).toThrow(SessionDecryptError);
});
it('rejects a tampered auth tag', () => {
const ciphertext = encrypt(PLAINTEXT, KEY);
const parts = ciphertext.split('.');
const tag = Buffer.from(parts[2] as string, 'base64url');
tag[0] = (tag[0] ?? 0) ^ 0xff;
parts[2] = tag.toString('base64url');
const tampered = parts.join('.');
expect(() => decrypt(tampered, KEY)).toThrow(SessionDecryptError);
});
it('rejects an unknown version', () => {
const ciphertext = encrypt(PLAINTEXT, KEY);
const parts = ciphertext.split('.');
parts[0] = 'v999';
expect(() => decrypt(parts.join('.'), KEY)).toThrow(/unsupported version "v999"/);
});
it('rejects a malformed envelope (wrong segment count)', () => {
expect(() => decrypt('not-even-close', KEY)).toThrow(/malformed envelope/);
expect(() => decrypt('v1.a.b', KEY)).toThrow(/malformed envelope/);
});
it('rejects an IV of the wrong length', () => {
const ciphertext = encrypt(PLAINTEXT, KEY);
const parts = ciphertext.split('.');
parts[1] = Buffer.alloc(8).toString('base64url');
expect(() => decrypt(parts.join('.'), KEY)).toThrow(/iv length 8/);
});
it('rejects a tag of the wrong length', () => {
const ciphertext = encrypt(PLAINTEXT, KEY);
const parts = ciphertext.split('.');
parts[2] = Buffer.alloc(8).toString('base64url');
expect(() => decrypt(parts.join('.'), KEY)).toThrow(/tag length 8/);
});
});
@@ -0,0 +1,100 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
/**
* AES-256-GCM helpers for session payload encryption at rest (per
* ADR-0010 §"At-rest encryption").
*
* The session middleware wires these into the `connect-redis`
* serializer: the JSON-encoded session is encrypted with this
* module's {@link encrypt} before it lands in Redis, and decrypted
* on read. A Redis snapshot, RDB dump, or memory inspection
* therefore surfaces only ciphertext — useless without the key.
*
* **Granularity.** ADR-0010's first cut scoped at-rest encryption
* to a `tokens` sub-field. This implementation upgrades that to the
* entire payload — the session also carries claims (`oid`, `tid`,
* `preferred_username`, …) that qualify as PII under GDPR, and an
* APF-Handicap portal handles health-adjacent data. Encrypting the
* whole envelope removes the need to classify fields one by one and
* costs essentially the same. The ADR text will be amended to match.
*
* **Format.** A single dot-delimited string with a leading version
* tag so we can rotate the algorithm or key derivation later without
* a flag-day re-encryption:
*
* v1.<iv-b64url>.<tag-b64url>.<ciphertext-b64url>
*
* `v1` ⇒ AES-256-GCM with a random 96-bit IV and the 128-bit GCM
* auth tag. Any unknown version is rejected on read.
*
* **Failure mode.** On any tampering, key mismatch, or malformed
* payload, {@link decrypt} throws a {@link SessionDecryptError}. The
* middleware treats that as "no session" — the browser sees a 401
* and re-authenticates. (Per ADR-0010 §"Confirmation": "rejects (and
* logs as an audit event) any record whose authentication tag fails
* to verify".)
*/
const VERSION = 'v1';
const ALGORITHM = 'aes-256-gcm';
const IV_BYTES = 12; // 96 bits — the GCM-recommended IV length
const TAG_BYTES = 16; // 128 bits — full GCM auth tag
export class SessionDecryptError extends Error {
constructor(reason: string) {
super(`session payload could not be decrypted: ${reason}`);
this.name = 'SessionDecryptError';
}
}
export function encrypt(plaintext: string, key: Buffer): string {
// A fresh random IV per encryption is the GCM correctness
// requirement — reusing an (IV, key) pair across two messages
// catastrophically breaks confidentiality and authentication.
const iv = randomBytes(IV_BYTES);
const cipher = createCipheriv(ALGORITHM, key, iv);
const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return [
VERSION,
iv.toString('base64url'),
tag.toString('base64url'),
ciphertext.toString('base64url'),
].join('.');
}
export function decrypt(payload: string, key: Buffer): string {
const parts = payload.split('.');
if (parts.length !== 4) {
throw new SessionDecryptError('malformed envelope');
}
const [version, ivB64, tagB64, ciphertextB64] = parts as [string, string, string, string];
if (version !== VERSION) {
throw new SessionDecryptError(`unsupported version "${version}"`);
}
const iv = Buffer.from(ivB64, 'base64url');
const tag = Buffer.from(tagB64, 'base64url');
const ciphertext = Buffer.from(ciphertextB64, 'base64url');
if (iv.length !== IV_BYTES) {
throw new SessionDecryptError(`iv length ${iv.length} (expected ${IV_BYTES})`);
}
if (tag.length !== TAG_BYTES) {
throw new SessionDecryptError(`tag length ${tag.length} (expected ${TAG_BYTES})`);
}
const decipher = createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(tag);
try {
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return plaintext.toString('utf8');
} catch (err) {
// `decipher.final()` throws on auth-tag mismatch — i.e. tampered
// ciphertext, wrong key, or a record encrypted with a different
// key version that we haven't been told about.
throw new SessionDecryptError(err instanceof Error ? err.message : String(err));
}
}
@@ -0,0 +1,88 @@
import { randomBytes } from 'node:crypto';
import { Test } from '@nestjs/testing';
import { LoggerModule } from 'nestjs-pino';
import { SessionModule } from './session.module';
import { SESSION_MIDDLEWARE, type RequestHandler } from './session.token';
const STRONG_KEY = randomBytes(32).toString('base64url');
const STRONG_SECRET = randomBytes(32).toString('base64url');
interface OriginalEnv {
REDIS_URL: string | undefined;
SESSION_SECRET: string | undefined;
SESSION_ENCRYPTION_KEY: string | undefined;
NODE_ENV: string | undefined;
}
async function compile() {
return Test.createTestingModule({
imports: [LoggerModule.forRoot({ pinoHttp: { level: 'silent' } }), SessionModule],
}).compile();
}
describe('SessionModule', () => {
const original: OriginalEnv = {
REDIS_URL: process.env['REDIS_URL'],
SESSION_SECRET: process.env['SESSION_SECRET'],
SESSION_ENCRYPTION_KEY: process.env['SESSION_ENCRYPTION_KEY'],
NODE_ENV: process.env['NODE_ENV'],
};
beforeEach(() => {
// Well-formed but unreachable URL — `ioredis` opens its socket
// lazily so the module compiles without any network access.
process.env['REDIS_URL'] = 'redis://default:test-pass@127.0.0.1:65535/0';
process.env['SESSION_SECRET'] = STRONG_SECRET;
process.env['SESSION_ENCRYPTION_KEY'] = STRONG_KEY;
process.env['NODE_ENV'] = 'development';
});
afterEach(() => {
restore('REDIS_URL', original.REDIS_URL);
restore('SESSION_SECRET', original.SESSION_SECRET);
restore('SESSION_ENCRYPTION_KEY', original.SESSION_ENCRYPTION_KEY);
restore('NODE_ENV', original.NODE_ENV);
});
it('provides a request handler via SESSION_MIDDLEWARE', async () => {
const ref = await compile();
try {
const middleware = ref.get<RequestHandler>(SESSION_MIDDLEWARE);
expect(typeof middleware).toBe('function');
// Express request handlers always declare 3 named params
// (req, res, next) — useful smoke check that the factory
// returned the expected shape rather than a `Store` or
// a configured options object.
expect(middleware.length).toBe(3);
} finally {
await disposeRedis(ref);
await ref.close();
}
});
it('fails to compile when SESSION_ENCRYPTION_KEY is missing', async () => {
delete process.env['SESSION_ENCRYPTION_KEY'];
await expect(compile()).rejects.toThrow(/SESSION_ENCRYPTION_KEY is not set/);
});
it('fails to compile when SESSION_SECRET is missing', async () => {
delete process.env['SESSION_SECRET'];
await expect(compile()).rejects.toThrow(/SESSION_SECRET is not set/);
});
});
function restore(name: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[name];
} else {
process.env[name] = value;
}
}
async function disposeRedis(ref: Awaited<ReturnType<typeof compile>>): Promise<void> {
// `RedisModule` keeps an `ioredis` client open; explicitly
// disconnect so Jest doesn't hang on its reconnect timer.
const redisToken = 'REDIS_CLIENT';
const client = ref.get<{ disconnect: () => void }>(redisToken);
client.disconnect();
}
@@ -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 {}
@@ -0,0 +1,15 @@
import type { RequestHandler } from 'express';
/**
* DI token for the configured `express-session` middleware. Resolved
* once at bootstrap (`main.ts`) and mounted with `app.use(...)`
* after `cookie-parser` so cookies are already parsed when the
* session middleware reads the session id.
*
* Usage:
* const session = app.get<RequestHandler>(SESSION_MIDDLEWARE);
* app.use(session);
*/
export const SESSION_MIDDLEWARE = 'SESSION_MIDDLEWARE';
export type { RequestHandler };