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:
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user