feat(portal-bff): session middleware with AES-256-GCM at rest per ADR-0010 #110

Merged
julien merged 1 commits from feat/portal-bff/session-express-middleware into main 2026-05-12 18:06:14 +02:00
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. # variable supports the dev single-instance shape only.
REDIS_URL=redis://default:redis_dev_change_me@localhost:6379/0 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: # Future env vars introduced by upcoming phases / ADRs:
# #
# Auth flow (ADR-0009) — additional keys wired as the routes land: # 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_HOSTS (CSV `host:port,host:port,…`; prod HA)
# REDIS_SENTINEL_NAME (master name in Sentinel; prod HA) # REDIS_SENTINEL_NAME (master name in Sentinel; prod HA)
# REDIS_TLS ('true' in prod) # 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 (ADR-0011):
# MFA_FRESHNESS_SECONDS (default 600) # 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 { AuditModule } from '../audit/audit.module';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { RedisModule } from '../redis/redis.module'; import { RedisModule } from '../redis/redis.module';
import { SessionModule } from '../session/session.module';
@Module({ @Module({
imports: [ imports: [
@@ -15,6 +16,7 @@ import { RedisModule } from '../redis/redis.module';
AuditModule, AuditModule,
AuthModule, AuthModule,
RedisModule, RedisModule,
SessionModule,
HealthModule, HealthModule,
], ],
controllers: [AppController], 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 { assertDatabaseUrl } from './config/check-database-url';
import { assertEntraConfig } from './config/check-entra-config'; import { assertEntraConfig } from './config/check-entra-config';
import { assertRedisConfig } from './config/check-redis-config'; import { assertRedisConfig } from './config/check-redis-config';
import { assertSessionEncryptionKey } from './config/check-session-encryption-key';
import { assertSessionSecret } from './config/check-session-secret'; 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 // Fail fast on a malformed DATABASE_URL (most often a special char in
// the password that needs URL-encoding) rather than letting Prisma // the password that needs URL-encoding) rather than letting Prisma
@@ -32,6 +34,12 @@ const sessionSecret = assertSessionSecret();
// reconnect loop. // reconnect loop.
assertRedisConfig(); 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() { async function bootstrap() {
// `bufferLogs: true` holds early-bootstrap log lines until the // `bufferLogs: true` holds early-bootstrap log lines until the
// Pino-based Logger is wired in below, so we don't lose anything // 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 // Cookie parsing for the auth flow (per ADR-0009). `SESSION_SECRET`
// `SESSION_SECRET` will cover the post-login session cookie when // signs the pre-auth cookie and the post-login session id cookie;
// ADR-0010 storage lands; signed cookies are read from // signed cookies are read from `req.signedCookies`, unsigned from
// `req.signedCookies`, unsigned from `req.cookies`. // `req.cookies`.
app.use(cookieParser(sessionSecret)); 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 // Phase-2 security ADRs will harden the above: helmet, real CORS
// allowlist, CSRF protection, rate limiting, auth guards, // allowlist, CSRF protection, rate limiting, auth guards,
// structured error filter. // 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 };
@@ -1,6 +1,7 @@
--- ---
status: accepted status: accepted
date: 2026-04-29 date: 2026-04-29
last-updated: 2026-05-12
decision-makers: R&D Lead decision-makers: R&D Lead
tags: [security, backend, infrastructure] 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.** **TTL policy.**
@@ -156,8 +159,8 @@ The BFF refuses to start if any required variable is missing or malformed (e.g.
### Confirmation ### Confirmation
- `apps/portal-bff/src/session/session.module.ts` configures `express-session` + `connect-redis` against the env-provided Redis target. - `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 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. - 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')`. - 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. - 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. - `absoluteExpiresAt` is checked in a global NestJS interceptor before any controller logic; expiry triggers `DEL` and 401.
+3
View File
@@ -81,6 +81,7 @@
"@tailwindcss/postcss": "^4.2.4", "@tailwindcss/postcss": "^4.2.4",
"@types/cookie-parser": "^1.4.10", "@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/express-session": "^1.19.0",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "24.12.4", "@types/node": "24.12.4",
"@typescript-eslint/utils": "^8.40.0", "@typescript-eslint/utils": "^8.40.0",
@@ -146,7 +147,9 @@
"axios": "^1.6.0", "axios": "^1.6.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"connect-redis": "^9.0.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"express-session": "^1.19.0",
"ioredis": "^5.10.1", "ioredis": "^5.10.1",
"lucide-angular": "^1.0.0", "lucide-angular": "^1.0.0",
"nestjs-cls": "^6.2.0", "nestjs-cls": "^6.2.0",
+8661 -2123
View File
File diff suppressed because it is too large Load Diff