import { randomBytes } from 'node:crypto'; import { Module } from '@nestjs/common'; import { RedisStore } from 'connect-redis'; import expressSession from 'express-session'; import type { CookieOptions } from 'express'; 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 { adminSessionCookieName } from './admin-session-cookie'; import { adaptIoredisForConnectRedis } from './ioredis-connect-redis-adapter'; import { SessionDecryptError, decrypt, encrypt } from './session-crypto'; import { readSessionTimeouts, sessionCookieName, sessionCookieOptions } from './session-cookie'; import { ADMIN_SESSION_MIDDLEWARE, 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'; /** * Build a configured `express-session` middleware. Shared between * the user-portal middleware ({@link SESSION_MIDDLEWARE}) and the * admin-portal middleware ({@link ADMIN_SESSION_MIDDLEWARE}) per * ADR-0020 §"Sessions — distinct from `portal-shell`". * * The two surfaces differ only on the cookie name and Redis key * prefix; the TTL policy, encryption key, signing secret, session-id * entropy, and error-handling all come from the same source so a * future hardening (e.g. tighter idle TTL on admin) is a parameter * change rather than a divergent code path. */ function buildSessionMiddleware( redis: Redis, logger: Logger, opts: { cookieName: string; redisKeyPrefix: string; surfaceTag: 'user' | 'admin' }, ): 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: opts.redisKeyPrefix, 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. The // `surface` tag lets dashboards split user-portal // decrypt failures from admin-portal ones. logger.warn( { event: 'session.decrypt_failed', surface: opts.surfaceTag, reason: err instanceof SessionDecryptError ? err.message : String(err), }, 'session', ); throw err; } }, }, }); const cookie: CookieOptions = sessionCookieOptions(timeouts.idleSeconds); return expressSession({ name: opts.cookieName, 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 the OIDC callback // populates it. saveUninitialized: false, // `rolling: true` — the cookie's `expires` slides forward // on every response, matching the sliding-idle policy. rolling: true, cookie, }); } /** * 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. * * Two middlewares are provided, path-routed in `main.ts`: * * - {@link SESSION_MIDDLEWARE} — user-portal. Cookie * `portal_session` / `__Host-portal_session`. Redis prefix * `session:`. Bound to every path except `/api/admin/*`. * * - {@link ADMIN_SESSION_MIDDLEWARE} — admin-portal per ADR-0020. * Cookie `portal_admin_session` / `__Host-portal_admin_session`. * Redis prefix `session:admin:`. Bound to `/api/admin/*` only. * * The {@link SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE} (ADR-0010 §"TTL * policy") and {@link UserSessionIndexService} are shared across * both surfaces — the absolute-timeout check reads `req.session` * which the dispatch has already resolved to one surface or the * other; the user-session index is keyed by Entra `oid`, so an * admin sign-in and a user-portal sign-in by the same person * naturally land in different Redis sets only because the session * id namespaces differ. */ @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 => buildSessionMiddleware(redis, logger, { cookieName: sessionCookieName(), redisKeyPrefix: 'session:', surfaceTag: 'user', }), }, { provide: ADMIN_SESSION_MIDDLEWARE, inject: [REDIS_CLIENT, Logger], useFactory: (redis: Redis, logger: Logger): RequestHandler => buildSessionMiddleware(redis, logger, { cookieName: adminSessionCookieName(), redisKeyPrefix: 'session:admin:', surfaceTag: 'admin', }), }, ], exports: [ SESSION_MIDDLEWARE, ADMIN_SESSION_MIDDLEWARE, SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE, UserSessionIndexService, ], }) export class SessionModule {}