import { randomBytes } from 'node:crypto'; import { Injectable } from '@nestjs/common'; import type { Request, Response } from 'express'; import { Logger } from 'nestjs-pino'; import { AuditWriter } from '../audit/audit.service'; import { csrfCookieName, csrfCookieOptions } from '../security/csrf-cookie'; import { readSessionTimeouts } from '../session/session-cookie'; import { UserSessionIndexService } from '../session/user-session-index.service'; import { UserDirectoryService } from '../users/user-directory.service'; import type { AuthenticatedUser } from './auth.service'; export type AuthSurface = 'user' | 'admin'; /** * Shared session-establishment recipe used by both `AuthController` * (user-portal) and `AdminAuthController` (admin-portal). Per * ADR-0020 §"Sessions — distinct from `portal-shell`", the two * surfaces use distinct cookies / Redis namespaces — but the * *recipe* for persisting an authenticated user into a session is * identical: * * 1. Mint a CSRF token (per ADR-0009 §"Double-submit CSRF"). * 2. Populate the session fields (`user`, `createdAt`, * `absoluteExpiresAt`, `csrfToken`, `mfaVerifiedAt`). * 3. Force `req.session.save()` before the 302 — `express-session` * writes on response end, but the redirect closes the response * before the async store write would otherwise complete. * 4. Mirror the CSRF token to the JS-readable cookie. * 5. Register the session id in the per-user index for future * "logout everywhere" — best-effort, a Redis hiccup does NOT * fail the sign-in. * 6. Emit the `auth.sign_in` audit row (blocking per ADR-0013). * 7. Log the success event. * * Surface-specific concerns — the redirect destination, the pre-auth * cookie clearing, the error paths — stay in the controllers. * * The middleware in `main.ts` has already resolved `req.session` to * the correct surface (user vs admin) by the time the controller's * callback handler runs; this service therefore writes to whichever * session was loaded without needing to know which one. */ @Injectable() export class SessionEstablisher { constructor( private readonly logger: Logger, private readonly userSessionIndex: UserSessionIndexService, private readonly audit: AuditWriter, private readonly userDirectory: UserDirectoryService, ) {} async establish(opts: { user: AuthenticatedUser; req: Request; res: Response; /** * Tag forwarded into the success log so dashboards can split * user-portal sign-ins from admin-portal sign-ins. Audit rows * keep the same `auth.sign_in` event type in both cases — * adding a surface field to the audit catalogue is a follow-up * decision (current ADR-0013 catalogue is single-tier). */ surface: AuthSurface; }): Promise { const { user, req, res, surface } = opts; const now = Date.now(); const { idleSeconds, absoluteSeconds } = readSessionTimeouts(); const csrfToken = randomBytes(32).toString('base64url'); req.session.user = user; req.session.createdAt = now; // Hard ceiling per ADR-0010 §"TTL policy" — checked on every // request by the absolute-timeout middleware, independent of // idle TTL. req.session.absoluteExpiresAt = now + absoluteSeconds * 1000; req.session.csrfToken = csrfToken; // MFA freshness anchor per ADR-0011 §"Confirmation". Entra's // CA policy decides whether MFA actually happened — the BFF // does not re-validate factors. Refreshed by future step-up // re-auth flows. req.session.mfaVerifiedAt = now; await saveSession(req); // Cookie maxAge matches the session's idle TTL so the CSRF // cookie expires alongside the session itself (rolling, // refreshed on each request). res.cookie(csrfCookieName(), csrfToken, csrfCookieOptions(idleSeconds * 1000)); // Best-effort: a Redis hiccup here doesn't fail sign-in. await this.userSessionIndex.add(user.oid, req.sessionID); // Blocking audit per ADR-0013. If this throws the user does // NOT see a successful sign-in: the exception propagates and // the controller emits a 5xx via the StructuredErrorFilter. await this.audit.signIn({ actor: user, sessionId: req.sessionID }); // Best-effort directory upsert (ADR-0020 §"User list"). MUST // NOT fail the sign-in — the directory is a convenience for // admin browsing, not a security boundary. The service catches // its own errors and logs them; awaiting it means an admin // that just signed in sees themselves on the user list // immediately, without racing the response. await this.userDirectory.recordSignIn({ oid: user.oid, tid: user.tid, username: user.username, displayName: user.displayName, // v1: single workforce-tenant audience per ADR-0008. The // dual-audience design ships a real value here when External // ID is activated. audience: 'workforce', }); this.logger.log( { event: 'auth.signed_in', surface, oid: user.oid, tid: user.tid, username: user.username, amr: user.amr, }, 'AuthCallback', ); } /** * Symmetric helper for the sign-out path. When `actor` is set, * removes the session id from the per-user index and emits the * `auth.sign_out` audit row (blocking per ADR-0013 — if the audit * row can't be written, the user does NOT get a "you're logged * out" experience). Then tears the session down unconditionally * — anonymous sign-outs still benefit from a `req.session.destroy()` * call to clear any orphan state in the store. A Redis hiccup on * `destroy()` is logged but non-fatal: clearing the cookie at the * HTTP layer (the controller's job) is sufficient to log the user * out from the BFF's point of view; the orphan Redis key will hit * its idle TTL on its own. * * The controller is responsible for HTTP-layer cleanup — the * session-cookie name is surface-specific (`portal_session` vs * `portal_admin_session`) and lives on the controller. */ async destroy(opts: { actor: AuthenticatedUser | undefined; req: Request }): Promise { const { actor, req } = opts; if (actor !== undefined) { const sessionId = req.sessionID; await this.userSessionIndex.remove(actor.oid, sessionId); await this.audit.signOut({ actor, sessionId }); } try { await destroySession(req); } catch (err) { this.logger.error( { event: 'session.destroy_failed', message: err instanceof Error ? err.message : String(err), }, 'AuthLogout', ); } } } function saveSession(req: Request): Promise { return new Promise((resolve, reject) => { req.session.save((err) => (err ? reject(err) : resolve())); }); } function destroySession(req: Request): Promise { return new Promise((resolve, reject) => { req.session.destroy((err) => (err ? reject(err) : resolve())); }); }