diff --git a/apps/portal-bff/src/auth/auth.controller.spec.ts b/apps/portal-bff/src/auth/auth.controller.spec.ts index 3ce2a02..6ee0628 100644 --- a/apps/portal-bff/src/auth/auth.controller.spec.ts +++ b/apps/portal-bff/src/auth/auth.controller.spec.ts @@ -1,5 +1,6 @@ import type { Request, Response } from 'express'; import type { Logger } from 'nestjs-pino'; +import type { UserSessionIndexService } from '../session/user-session-index.service'; import { AuthController } from './auth.controller'; import { PRE_AUTH_COOKIE_NAME, PRE_AUTH_COOKIE_TTL_MS } from './auth.cookie'; import { AuthCodeFlowException } from './auth.errors'; @@ -51,6 +52,8 @@ function makeResStub() { interface SessionStub { user?: AuthenticatedUser; + createdAt?: number; + absoluteExpiresAt?: number; save: jest.Mock; destroy: jest.Mock; } @@ -70,11 +73,13 @@ function makeSessionStub(opts?: { function makeReqStub(opts?: { signedCookies?: Record; session?: SessionStub; -}): Request & { session: SessionStub } { + sessionID?: string; +}): Request & { session: SessionStub; sessionID: string } { return { signedCookies: opts?.signedCookies ?? {}, session: opts?.session ?? makeSessionStub(), - } as unknown as Request & { session: SessionStub }; + sessionID: opts?.sessionID ?? 'sid-test', + } as unknown as Request & { session: SessionStub; sessionID: string }; } function makeLoggerStub() { @@ -96,6 +101,7 @@ interface ControllerFixture { beginAuthCodeFlow: jest.Mock; completeAuthCodeFlow: jest.Mock; logger: ReturnType; + userSessionIndex: { add: jest.Mock; remove: jest.Mock; list: jest.Mock }; } const LOGOUT_URL = `${ENTRA.authority}/oauth2/v2.0/logout?post_logout_redirect_uri=${encodeURIComponent(ENTRA.postLogoutRedirectUri)}`; @@ -112,12 +118,23 @@ function makeController(opts?: { completeAuthCodeFlow?: jest.Mock }): Controller completeAuthCodeFlow, buildLogoutUrl, } as unknown as AuthService; + const userSessionIndex = { + add: jest.fn().mockResolvedValue(undefined), + remove: jest.fn().mockResolvedValue(undefined), + list: jest.fn().mockResolvedValue([]), + }; const logger = makeLoggerStub(); return { - controller: new AuthController(service, logger as unknown as Logger, ENTRA), + controller: new AuthController( + service, + logger as unknown as Logger, + ENTRA, + userSessionIndex as unknown as UserSessionIndexService, + ), beginAuthCodeFlow, completeAuthCodeFlow, logger, + userSessionIndex, }; } @@ -196,6 +213,45 @@ describe('AuthController.callback', () => { expect(res.redirect).toHaveBeenCalledWith(302, ENTRA.postLogoutRedirectUri); }); + it('records createdAt + absoluteExpiresAt on the session (ADR-0010 §"TTL policy")', async () => { + const before = Date.now(); + const { controller } = makeController(); + const res = makeResStub(); + const session = makeSessionStub(); + const req = makeReqStub({ + signedCookies: { [PRE_AUTH_COOKIE_NAME]: JSON.stringify(PRE_AUTH) }, + session, + }); + + await controller.callback(req, res, 'auth-code', PRE_AUTH.state); + + const after = Date.now(); + expect(session.createdAt).toBeGreaterThanOrEqual(before); + expect(session.createdAt).toBeLessThanOrEqual(after); + // Default absoluteSeconds = 43200 (12 h) per ADR-0010. + expect(session.absoluteExpiresAt).toBe((session.createdAt as number) + 43_200 * 1000); + }); + + it('registers the new session in the user_sessions index after save', async () => { + const { controller, userSessionIndex } = makeController(); + const res = makeResStub(); + const session = makeSessionStub(); + const req = makeReqStub({ + signedCookies: { [PRE_AUTH_COOKIE_NAME]: JSON.stringify(PRE_AUTH) }, + session, + sessionID: 'fresh-sid', + }); + + await controller.callback(req, res, 'auth-code', PRE_AUTH.state); + + expect(userSessionIndex.add).toHaveBeenCalledWith(USER.oid, 'fresh-sid'); + // Order matters: the Redis session row must be written before + // the index points at it. We can't enforce a strict + // `toHaveBeenCalledBefore` ordering across jest mocks without + // a plugin — assert at least that the index entry was added. + expect(session.save).toHaveBeenCalled(); + }); + it('propagates a session.save() error as an exception (so the SPA never sees a successful redirect with no session)', async () => { const { controller } = makeController(); const res = makeResStub(); @@ -343,13 +399,14 @@ describe('AuthController.me', () => { describe('AuthController.logout', () => { it('destroys the session, clears the session cookie, logs success, redirects to Entra logout', async () => { - const { controller, logger } = makeController(); + const { controller, logger, userSessionIndex } = makeController(); const res = makeResStub(); const session = makeSessionStub({ user: USER }); - const req = makeReqStub({ session }); + const req = makeReqStub({ session, sessionID: 'logged-out-sid' }); await controller.logout(req, res); + expect(userSessionIndex.remove).toHaveBeenCalledWith(USER.oid, 'logged-out-sid'); expect(session.destroy).toHaveBeenCalledTimes(1); expect(res.clearCookie).toHaveBeenCalledWith('portal_session', { path: '/' }); expect(logger.log).toHaveBeenCalledWith( @@ -390,13 +447,17 @@ describe('AuthController.logout', () => { }); it('still clears the cookie and redirects when the user was already anonymous', async () => { - const { controller, logger } = makeController(); + const { controller, logger, userSessionIndex } = makeController(); const res = makeResStub(); const session = makeSessionStub(); const req = makeReqStub({ session }); await controller.logout(req, res); + // Nothing was ever added to the index for an anonymous request — + // skip the SREM (cheaper + avoids racing the cleanup of a + // future user's session id collision). + expect(userSessionIndex.remove).not.toHaveBeenCalled(); expect(session.destroy).toHaveBeenCalledTimes(1); expect(logger.log).toHaveBeenCalledWith( { event: 'auth.signed_out', wasAuthenticated: false }, diff --git a/apps/portal-bff/src/auth/auth.controller.ts b/apps/portal-bff/src/auth/auth.controller.ts index 0789c00..156d02e 100644 --- a/apps/portal-bff/src/auth/auth.controller.ts +++ b/apps/portal-bff/src/auth/auth.controller.ts @@ -1,7 +1,8 @@ import { Controller, Get, Inject, Query, Req, Res } from '@nestjs/common'; import type { Request, Response } from 'express'; import { Logger } from 'nestjs-pino'; -import { sessionCookieName } from '../session/session-cookie'; +import { readSessionTimeouts, sessionCookieName } from '../session/session-cookie'; +import { UserSessionIndexService } from '../session/user-session-index.service'; import { PRE_AUTH_COOKIE_NAME, clearPreAuthCookieOptions, @@ -27,6 +28,7 @@ export class AuthController { private readonly authService: AuthService, private readonly logger: Logger, @Inject(ENTRA_CONFIG) private readonly entra: EntraConfig, + private readonly userSessionIndex: UserSessionIndexService, ) {} /** @@ -106,13 +108,25 @@ export class AuthController { try { const user = await this.authService.completeAuthCodeFlow(code, state, preAuth); + const now = Date.now(); + const { absoluteSeconds } = readSessionTimeouts(); 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; // Force the save before the redirect: express-session writes // on response end, but the 302 we're about to emit closes the // response before the async store-write would otherwise // complete. Without this, the browser hits the SPA before // Redis carries the new payload. await saveSession(req); + // Register the freshly-minted session id in the per-user + // index so a future admin "logout everywhere" can enumerate + // and revoke. Best-effort: a Redis hiccup here doesn't fail + // sign-in (the service swallows + logs). + await this.userSessionIndex.add(user.oid, req.sessionID); this.logger.log( { event: 'auth.signed_in', @@ -168,9 +182,18 @@ export class AuthController { */ @Get('logout') async logout(@Req() req: Request, @Res() res: Response): Promise { - const wasAuthenticated = Boolean(req.session.user); + const user = req.session.user; + const wasAuthenticated = Boolean(user); + const sessionId = req.sessionID; const logoutUrl = this.authService.buildLogoutUrl(); + // Drop the user_sessions index entry before destroy() removes + // the session id from `req`. Skipped on already-anonymous + // requests (nothing was ever added). + if (user) { + await this.userSessionIndex.remove(user.oid, sessionId); + } + try { await destroySession(req); } catch (err) { diff --git a/apps/portal-bff/src/auth/auth.module.ts b/apps/portal-bff/src/auth/auth.module.ts index dee1cfe..6cc5da1 100644 --- a/apps/portal-bff/src/auth/auth.module.ts +++ b/apps/portal-bff/src/auth/auth.module.ts @@ -2,6 +2,7 @@ import { ConfidentialClientApplication, LogLevel } from '@azure/msal-node'; import { Module } from '@nestjs/common'; import { Logger } from 'nestjs-pino'; import { assertEntraConfig } from '../config/check-entra-config'; +import { SessionModule } from '../session/session.module'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { ENTRA_CONFIG, type EntraConfig } from './entra-config.token'; @@ -38,6 +39,7 @@ import { MSAL_CLIENT } from './msal-client.token'; * `imports: [AuthModule]` is enough to consume either. */ @Module({ + imports: [SessionModule], controllers: [AuthController], providers: [ AuthService, diff --git a/apps/portal-bff/src/main.ts b/apps/portal-bff/src/main.ts index 0e0a80f..a2049f6 100644 --- a/apps/portal-bff/src/main.ts +++ b/apps/portal-bff/src/main.ts @@ -13,7 +13,11 @@ 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'; +import { + SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE, + 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 @@ -86,6 +90,14 @@ async function bootstrap() { // cookie is parsed by the time `express-session` reads it. app.use(app.get(SESSION_MIDDLEWARE)); + // Absolute-timeout enforcement (ADR-0010 §"TTL policy"). Runs on + // every request that survives `express-session`; if the session + // is past its 12 h hard ceiling, destroy it + clear the cookie + + // drop the per-user index entry, then let the request continue + // anonymously (route-level guards turn it into a 401 where + // needed). + app.use(app.get(SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE)); + // Phase-2 security ADRs will harden the above: helmet, real CORS // allowlist, CSRF protection, rate limiting, auth guards, // structured error filter. diff --git a/apps/portal-bff/src/session/absolute-timeout.middleware.spec.ts b/apps/portal-bff/src/session/absolute-timeout.middleware.spec.ts new file mode 100644 index 0000000..1265078 --- /dev/null +++ b/apps/portal-bff/src/session/absolute-timeout.middleware.spec.ts @@ -0,0 +1,176 @@ +import type { NextFunction, Request, Response } from 'express'; +import type { Logger } from 'nestjs-pino'; +import { createAbsoluteTimeoutMiddleware } from './absolute-timeout.middleware'; +import type { UserSessionIndexService } from './user-session-index.service'; + +const NOW = 1_000_000_000_000; // arbitrary stable epoch ms + +interface SessionStub { + user?: { + oid: string; + tid: string; + username: string; + displayName: string; + amr: readonly string[]; + }; + createdAt?: number; + absoluteExpiresAt?: number; + destroy: jest.Mock; +} + +function makeSession(opts?: Partial): SessionStub { + return { + destroy: jest.fn((cb: (err?: Error) => void) => cb()), + ...opts, + }; +} + +function makeReq(session?: SessionStub, sessionID = 'session-abc'): Request { + return { session, sessionID } as unknown as Request; +} + +function makeRes() { + return { + clearCookie: jest.fn().mockReturnThis(), + } as unknown as Response & { clearCookie: jest.Mock }; +} + +function makeIndex(): UserSessionIndexService & { add: jest.Mock; remove: jest.Mock } { + return { + add: jest.fn().mockResolvedValue(undefined), + remove: jest.fn().mockResolvedValue(undefined), + } as unknown as UserSessionIndexService & { add: jest.Mock; remove: jest.Mock }; +} + +function makeLogger() { + return { + log: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + } as unknown as Logger & { + log: jest.Mock; + warn: jest.Mock; + error: jest.Mock; + }; +} + +const USER = { + oid: 'user-oid', + tid: 'tenant-id', + username: 'jane@apf.example', + displayName: 'Jane Doe', + amr: ['pwd', 'mfa'], +}; + +describe('absoluteTimeout middleware', () => { + it('passes through when there is no session at all', async () => { + const middleware = createAbsoluteTimeoutMiddleware(makeIndex(), makeLogger(), () => NOW); + const req = makeReq(undefined); + const res = makeRes(); + const next: NextFunction = jest.fn(); + + await middleware(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(res.clearCookie).not.toHaveBeenCalled(); + }); + + it('passes through when the session has no user (anonymous request hit a session-touching route)', async () => { + const session = makeSession(); + const middleware = createAbsoluteTimeoutMiddleware(makeIndex(), makeLogger(), () => NOW); + const res = makeRes(); + const next: NextFunction = jest.fn(); + + await middleware(makeReq(session), res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(session.destroy).not.toHaveBeenCalled(); + }); + + it('passes through when absoluteExpiresAt is unset (pre-hardening session)', async () => { + const session = makeSession({ user: USER }); + const middleware = createAbsoluteTimeoutMiddleware(makeIndex(), makeLogger(), () => NOW); + const res = makeRes(); + const next: NextFunction = jest.fn(); + + await middleware(makeReq(session), res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(session.destroy).not.toHaveBeenCalled(); + }); + + it('passes through when the session is still within the ceiling', async () => { + const session = makeSession({ + user: USER, + createdAt: NOW - 60_000, + absoluteExpiresAt: NOW + 60_000, + }); + const middleware = createAbsoluteTimeoutMiddleware(makeIndex(), makeLogger(), () => NOW); + const res = makeRes(); + const next: NextFunction = jest.fn(); + + await middleware(makeReq(session), res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(session.destroy).not.toHaveBeenCalled(); + expect(res.clearCookie).not.toHaveBeenCalled(); + }); + + it('destroys the session, removes from the index, clears the cookie, and still calls next() when past the ceiling', async () => { + const session = makeSession({ + user: USER, + createdAt: NOW - 13 * 60 * 60 * 1000, + absoluteExpiresAt: NOW - 60 * 60 * 1000, + }); + const index = makeIndex(); + const logger = makeLogger(); + const middleware = createAbsoluteTimeoutMiddleware(index, logger, () => NOW); + const res = makeRes(); + const next: NextFunction = jest.fn(); + + await middleware(makeReq(session, 'session-abc'), res, next); + + expect(session.destroy).toHaveBeenCalledTimes(1); + expect(index.remove).toHaveBeenCalledWith(USER.oid, 'session-abc'); + expect(res.clearCookie).toHaveBeenCalledWith('portal_session', { path: '/' }); + expect(logger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'session.absolute_timeout', + userId: USER.oid, + sessionId: 'session-abc', + }), + 'AbsoluteTimeout', + ); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('logs but does not throw when destroy() fails — still clears cookie + index + calls next()', async () => { + const session = makeSession({ + user: USER, + createdAt: NOW - 13 * 60 * 60 * 1000, + absoluteExpiresAt: NOW - 60 * 60 * 1000, + }); + session.destroy.mockImplementationOnce((cb: (err?: Error) => void) => + cb(new Error('redis down')), + ); + const index = makeIndex(); + const logger = makeLogger(); + const middleware = createAbsoluteTimeoutMiddleware(index, logger, () => NOW); + const res = makeRes(); + const next: NextFunction = jest.fn(); + + await middleware(makeReq(session, 'session-abc'), res, next); + + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'session.absolute_timeout.destroy_failed', + sessionId: 'session-abc', + }), + 'AbsoluteTimeout', + ); + expect(index.remove).toHaveBeenCalledWith(USER.oid, 'session-abc'); + expect(res.clearCookie).toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/portal-bff/src/session/absolute-timeout.middleware.ts b/apps/portal-bff/src/session/absolute-timeout.middleware.ts new file mode 100644 index 0000000..96bd2a5 --- /dev/null +++ b/apps/portal-bff/src/session/absolute-timeout.middleware.ts @@ -0,0 +1,100 @@ +import type { NextFunction, Request, RequestHandler, Response } from 'express'; +import { Logger } from 'nestjs-pino'; +import { sessionCookieName } from './session-cookie'; +import { UserSessionIndexService } from './user-session-index.service'; + +/** + * Build the absolute-timeout middleware per ADR-0010 §"TTL policy": + * + * absoluteExpiresAt is recorded at session creation and checked + * on every request. If exceeded, the BFF deletes the session key + * and clears the cookie. + * + * Behaviour on expired session (decided 2026-05-12 with the project + * lead): + * - Destroy the Redis-side session (`req.session.destroy()`). + * - Remove the session id from the `user_sessions:{userId}` index. + * - Clear the `portal_session` cookie on the response so the + * browser stops sending it. + * - Call `next()` — the request keeps flowing with no session. + * + * The non-intrusive choice over a hard 401: `/me` answers 401 on its + * own when the session is gone, public routes keep serving, and the + * user isn't surprised by a 401 on a route that never needed auth. + * + * Idle expiry (Redis EXPIRE on the session key) is *not* handled + * here — `connect-redis` already lets the key disappear on its own + * and the next request sees no session. + */ +export function createAbsoluteTimeoutMiddleware( + index: UserSessionIndexService, + logger: Logger, + now: () => number = Date.now, +): RequestHandler { + return async function absoluteTimeout( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + const session = req.session; + const absoluteExpiresAt = session?.absoluteExpiresAt; + const user = session?.user; + + // Anonymous, freshly-created, or pre-hardening session — nothing + // to enforce. `absoluteExpiresAt` is only set together with `user` + // in the callback (per ADR-0010), so a session without it either + // isn't post-auth or is a leftover from a deploy that predates + // this PR. Pass through. + if (!session || !user || !absoluteExpiresAt) { + next(); + return; + } + + if (now() <= absoluteExpiresAt) { + next(); + return; + } + + // Past the hard ceiling — tear it down. Capture identifiers + // before destroy() drops `req.session`. + const userId = user.oid; + const sessionId = req.sessionID; + const ageMs = now() - (session.createdAt ?? absoluteExpiresAt); + + try { + await destroySession(req); + } catch (err) { + logger.error( + { + event: 'session.absolute_timeout.destroy_failed', + sessionId, + message: err instanceof Error ? err.message : String(err), + }, + 'AbsoluteTimeout', + ); + // Fall through: still clear the cookie + index so the client + // is effectively signed out from the BFF's perspective. + } + + await index.remove(userId, sessionId); + res.clearCookie(sessionCookieName(), { path: '/' }); + + logger.log( + { + event: 'session.absolute_timeout', + userId, + sessionId, + ageMs, + }, + 'AbsoluteTimeout', + ); + + next(); + }; +} + +function destroySession(req: Request): Promise { + return new Promise((resolve, reject) => { + req.session.destroy((err) => (err ? reject(err) : resolve())); + }); +} diff --git a/apps/portal-bff/src/session/session.module.ts b/apps/portal-bff/src/session/session.module.ts index f24f34e..92cce80 100644 --- a/apps/portal-bff/src/session/session.module.ts +++ b/apps/portal-bff/src/session/session.module.ts @@ -7,13 +7,19 @@ import { assertSessionEncryptionKey } from '../config/check-session-encryption-k import { assertSessionSecret } from '../config/check-session-secret'; import { RedisModule } from '../redis/redis.module'; import { REDIS_CLIENT, type Redis } from '../redis/redis.token'; +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_MIDDLEWARE, type RequestHandler } from './session.token'; +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` @@ -28,24 +34,33 @@ import './session.types'; * 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 + * 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 (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). + * 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], + useFactory: (index: UserSessionIndexService, logger: Logger): RequestHandler => + createAbsoluteTimeoutMiddleware(index, logger), + }, { provide: SESSION_MIDDLEWARE, inject: [REDIS_CLIENT, Logger], @@ -109,6 +124,6 @@ import './session.types'; }, }, ], - exports: [SESSION_MIDDLEWARE], + exports: [SESSION_MIDDLEWARE, SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE, UserSessionIndexService], }) export class SessionModule {} diff --git a/apps/portal-bff/src/session/session.token.ts b/apps/portal-bff/src/session/session.token.ts index f59e45a..622f35a 100644 --- a/apps/portal-bff/src/session/session.token.ts +++ b/apps/portal-bff/src/session/session.token.ts @@ -12,4 +12,12 @@ import type { RequestHandler } from 'express'; */ export const SESSION_MIDDLEWARE = 'SESSION_MIDDLEWARE'; +/** + * DI token for the absolute-timeout middleware (per ADR-0010 §"TTL + * policy"). Resolved at bootstrap and mounted in `main.ts` + * immediately after {@link SESSION_MIDDLEWARE} so it runs on every + * request that carries a session cookie. + */ +export const SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE = 'SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE'; + export type { RequestHandler }; diff --git a/apps/portal-bff/src/session/session.types.ts b/apps/portal-bff/src/session/session.types.ts index eb4efb2..81d1c16 100644 --- a/apps/portal-bff/src/session/session.types.ts +++ b/apps/portal-bff/src/session/session.types.ts @@ -18,6 +18,21 @@ import type { AuthenticatedUser } from '../auth/auth.service'; declare module 'express-session' { interface SessionData { user?: AuthenticatedUser; + /** + * Epoch ms at which the BFF created this session. Posed by the + * callback at the same time as `user`. Lets the absolute-timeout + * middleware compute "is this session past its 12 h hard + * ceiling?" without re-reading the timeout env on every request. + */ + createdAt?: number; + /** + * Epoch ms at which the session is hard-expired regardless of + * activity (per ADR-0010 §"TTL policy"). Checked on every + * request by the absolute-timeout middleware. Independent from + * the idle TTL on the Redis key — whichever fires first ends + * the session. + */ + absoluteExpiresAt?: number; } } diff --git a/apps/portal-bff/src/session/user-session-index.service.spec.ts b/apps/portal-bff/src/session/user-session-index.service.spec.ts new file mode 100644 index 0000000..8c52f4f --- /dev/null +++ b/apps/portal-bff/src/session/user-session-index.service.spec.ts @@ -0,0 +1,91 @@ +import { Test } from '@nestjs/testing'; +import { LoggerModule, Logger } from 'nestjs-pino'; +import { REDIS_CLIENT } from '../redis/redis.token'; +import { UserSessionIndexService } from './user-session-index.service'; + +interface RedisStub { + sadd: jest.Mock; + srem: jest.Mock; + smembers: jest.Mock; +} + +function makeRedisStub(): RedisStub { + return { + sadd: jest.fn().mockResolvedValue(1), + srem: jest.fn().mockResolvedValue(1), + smembers: jest.fn().mockResolvedValue([]), + }; +} + +async function setup(redis: RedisStub) { + const ref = await Test.createTestingModule({ + imports: [LoggerModule.forRoot({ pinoHttp: { level: 'silent' } })], + providers: [UserSessionIndexService, { provide: REDIS_CLIENT, useValue: redis }], + }).compile(); + return { + service: ref.get(UserSessionIndexService), + logger: ref.get(Logger) as Logger & { warn: jest.Mock }, + }; +} + +describe('UserSessionIndexService', () => { + describe('add()', () => { + it('SADDs the session id to user_sessions:', async () => { + const redis = makeRedisStub(); + const { service } = await setup(redis); + await service.add('user-oid', 'session-abc'); + expect(redis.sadd).toHaveBeenCalledWith('user_sessions:user-oid', 'session-abc'); + }); + + it('swallows redis failures (best-effort) and logs a warning', async () => { + const redis = makeRedisStub(); + redis.sadd.mockRejectedValueOnce(new Error('redis unreachable')); + const { service, logger } = await setup(redis); + const warnSpy = jest.spyOn(logger, 'warn'); + await expect(service.add('user-oid', 'session-abc')).resolves.toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'user_session_index.add_failed', + userId: 'user-oid', + message: 'redis unreachable', + }), + 'UserSessionIndex', + ); + }); + }); + + describe('remove()', () => { + it('SREMs the session id from user_sessions:', async () => { + const redis = makeRedisStub(); + const { service } = await setup(redis); + await service.remove('user-oid', 'session-abc'); + expect(redis.srem).toHaveBeenCalledWith('user_sessions:user-oid', 'session-abc'); + }); + + it('swallows redis failures and logs a warning', async () => { + const redis = makeRedisStub(); + redis.srem.mockRejectedValueOnce(new Error('boom')); + const { service, logger } = await setup(redis); + const warnSpy = jest.spyOn(logger, 'warn'); + await service.remove('user-oid', 'session-abc'); + expect(warnSpy).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'user_session_index.remove_failed', + userId: 'user-oid', + }), + 'UserSessionIndex', + ); + }); + }); + + describe('list()', () => { + it('SMEMBERS the user_sessions set and returns the raw ids (orphans included)', async () => { + const redis = makeRedisStub(); + redis.smembers.mockResolvedValueOnce(['session-a', 'session-b', 'orphan']); + const { service } = await setup(redis); + const ids = await service.list('user-oid'); + expect(redis.smembers).toHaveBeenCalledWith('user_sessions:user-oid'); + expect(ids).toEqual(['session-a', 'session-b', 'orphan']); + }); + }); +}); diff --git a/apps/portal-bff/src/session/user-session-index.service.ts b/apps/portal-bff/src/session/user-session-index.service.ts new file mode 100644 index 0000000..827951d --- /dev/null +++ b/apps/portal-bff/src/session/user-session-index.service.ts @@ -0,0 +1,80 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Logger } from 'nestjs-pino'; +import { REDIS_CLIENT, type Redis } from '../redis/redis.token'; + +/** + * Secondary index `user_sessions:{userId}` → Redis set of session + * ids the user currently has open (per ADR-0010 §"Revocation"). + * + * Maintained on the lifecycle events: + * - session created in `/auth/callback` → `SADD` + * - session destroyed in `/auth/logout` → `SREM` + * - session destroyed by the absolute-timeout → `SREM` + * + * NOT actively cleaned on idle-TTL expiry: when the Redis session + * key disappears on its own, the corresponding entry in the user + * set becomes a "phantom" id pointing at nothing. ADR-0010 treats + * this as best-effort — orphans are filtered out by `list()` / + * future admin "logout everywhere" routes. + * + * Today the only consumer of `add` / `remove` is the auth surface. + * `list` and the matching admin endpoint land in a follow-up PR + * (admin module / `@RequireAdmin` guard). + */ +@Injectable() +export class UserSessionIndexService { + private static readonly KEY_PREFIX = 'user_sessions:'; + + constructor( + @Inject(REDIS_CLIENT) private readonly redis: Redis, + private readonly logger: Logger, + ) {} + + async add(userId: string, sessionId: string): Promise { + try { + await this.redis.sadd(this.keyFor(userId), sessionId); + } catch (err) { + // Best-effort by design: a failed SADD leaves the session + // unreachable from "logout everywhere" but does not break + // sign-in itself. Log so ops can spot a degraded Redis. + this.logger.warn( + { + event: 'user_session_index.add_failed', + userId, + message: err instanceof Error ? err.message : String(err), + }, + 'UserSessionIndex', + ); + } + } + + async remove(userId: string, sessionId: string): Promise { + try { + await this.redis.srem(this.keyFor(userId), sessionId); + } catch (err) { + this.logger.warn( + { + event: 'user_session_index.remove_failed', + userId, + message: err instanceof Error ? err.message : String(err), + }, + 'UserSessionIndex', + ); + } + } + + /** + * Returns the raw set of session ids for a user. Includes + * orphans (entries whose `session:` Redis key has already + * expired); the caller is expected to filter or rely on idle + * cleanup. v1 has no in-product consumer — exposed for tests + * today and the upcoming admin module. + */ + async list(userId: string): Promise { + return this.redis.smembers(this.keyFor(userId)); + } + + private keyFor(userId: string): string { + return `${UserSessionIndexService.KEY_PREFIX}${userId}`; + } +}