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 { AuditWriter } from '../audit/audit.service'; import { createAbsoluteTimeoutMiddleware } from './absolute-timeout.middleware'; import { adaptIoredisForConnectRedis } from './ioredis-connect-redis-adapter'; import { SessionDecryptError, decrypt, encrypt } from './session-crypto'; import { readSessionTimeouts, sessionCookieName, sessionCookieOptions } from './session-cookie'; import { SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE, SESSION_MIDDLEWARE, type RequestHandler, } from './session.token'; // Side-effect import: brings the `req.session.user` declaration // merging into the compile graph for every consumer of this module. import './session.types'; import { UserSessionIndexService } from './user-session-index.service'; /** * 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. * * What's wired today: * - Express-session middleware on every request (`SESSION_MIDDLEWARE`). * - Absolute-timeout middleware (`SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE`) * that enforces the 12 h hard ceiling per ADR-0010. * - `UserSessionIndexService`: maintains the * `user_sessions:{userId}` Redis set so a future admin endpoint * can "log out user X everywhere" — write-side hooks live in * the auth controller (create on `/auth/callback`, drop on * `/auth/logout` or absolute-timeout). * * Out of scope, landing in follow-ups: * - The admin "logout everywhere" route that consumes the * `UserSessionIndexService` (waits on the admin module + the * `@RequireAdmin` / `@RequireMfa` guards). * - Audit-pipeline wiring for `session.decrypt_failed` and * `session.absolute_timeout` (ADR-0013). */ @Module({ imports: [RedisModule], providers: [ UserSessionIndexService, { provide: SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE, inject: [UserSessionIndexService, Logger, AuditWriter], useFactory: ( index: UserSessionIndexService, logger: Logger, audit: AuditWriter, ): RequestHandler => createAbsoluteTimeoutMiddleware(index, logger, audit), }, { 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, SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE, UserSessionIndexService], }) export class SessionModule {}