feat(portal-bff): absolute-timeout middleware + user_sessions index per ADR-0010 (#115)
## Summary
Hardens the BFF session per ADR-0010 §"TTL policy" and §"Revocation":
- **Absolute-timeout middleware** — every request that survives `express-session` runs through a new middleware that checks `req.session.absoluteExpiresAt`. Past the 12 h hard ceiling, the middleware destroys the Redis-side session, clears the `portal_session` cookie, drops the entry from the per-user index, and lets the request continue anonymously. Route-level guards (`/me`, future `@RequireAuth`) turn that into a 401 where the user actually needs auth — public routes keep serving.
- **`user_sessions:{userId}` secondary index** — a new `UserSessionIndexService` maintains a Redis set of active session ids per user. Hooked into `/auth/callback` (SADD on sign-in) and `/auth/logout` + the absolute-timeout middleware (SREM on destroy). Best-effort: a failed `SADD`/`SREM` logs a warning and the auth flow continues. No in-product consumer in this PR — the admin "logout everywhere" endpoint lands with the admin module.
- **Session payload extension** — `createdAt` and `absoluteExpiresAt` are now set on the session at the same moment as `req.session.user` (in `/auth/callback`). The `session.types.ts` declaration merging exposes them as optional `SessionData` fields.
## Notable choices
**Non-intrusive enforcement on expiry.** ADR-0010 says "returns 401"; we interpret that as "the user eventually sees a 401 when they touch something that needs auth", not "every route returns 401 the moment we notice the ceiling". The middleware destroys the session and calls `next()` — `/me` returns 401 on its own (no user on the session), public routes stay accessible. Validated with the project lead 2026-05-12.
**Express middleware exposed via DI, not a NestJS `MiddlewareConsumer`.** Same pattern as `SESSION_MIDDLEWARE`: factory inside `SessionModule`, resolved from the application context in `main.ts` with `app.get<RequestHandler>(SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE)`. Keeps the wiring co-located with the session middleware and avoids the `AppModule.configure(consumer)` boilerplate for a one-off enforcement layer.
**Best-effort index maintenance.** `UserSessionIndexService.add` / `remove` catch Redis errors and log a Pino warning instead of throwing. Rationale (per ADR-0010): the index is a convenience for admin operations, not a security invariant — a Redis hiccup must not break sign-in / sign-out. Orphans (entries pointing to keys that have expired idle-TTL on their own) are tolerated and will be filtered by future consumer code.
**Per-user index identifier = Entra `oid`.** Stable per-user inside the tenant, matches `req.session.user.oid`. Admin "logout user X" will work against this same key. Future multi-tenant scenarios may want `${tid}:${oid}` — easy refactor when External ID activation lands (ADR-0008).
## Out of scope (next PRs)
- Admin "logout everywhere" endpoint consuming `UserSessionIndexService.list(userId)`. Waits on the admin module + `@RequireAdmin` / `@RequireMfa` guards.
- Audit-pipeline first-class events for `session.absolute_timeout` and `user_session_index.*` (ADR-0013). For now they're structured Pino logs.
- Token blob persistence (id_token / access_token / refresh_token) in the encrypted session — ADR-0014 dependency.
## Test plan
- [x] `pnpm nx test portal-bff` → **123/123 pass** (was 110 before; +13 specs across new `user-session-index.service.spec.ts`, `absolute-timeout.middleware.spec.ts`, and added cases in `auth.controller.spec.ts`).
- [x] `pnpm nx lint portal-bff` → clean.
- [x] `pnpm nx build portal-bff` → clean webpack build.
- [x] Prettier-clean for all touched files.
- [ ] Manual smoke against running BFF:
- [ ] Sign in normally → Redis has `session:<id>` + `user_sessions:<oid>` SISMEMBER returns `<id>`.
- [ ] Logout → both keys gone.
- [ ] Forge a past `absoluteExpiresAt` in Redis (or shorten `SESSION_ABSOLUTE_TIMEOUT_SECONDS=5` in `.env`) → next request after expiry returns 401 on `/me`, cookie cleared, index entry SREM-ed.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #115
This commit was merged in pull request #115.
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import type { Logger } from 'nestjs-pino';
|
import type { Logger } from 'nestjs-pino';
|
||||||
|
import type { UserSessionIndexService } from '../session/user-session-index.service';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { PRE_AUTH_COOKIE_NAME, PRE_AUTH_COOKIE_TTL_MS } from './auth.cookie';
|
import { PRE_AUTH_COOKIE_NAME, PRE_AUTH_COOKIE_TTL_MS } from './auth.cookie';
|
||||||
import { AuthCodeFlowException } from './auth.errors';
|
import { AuthCodeFlowException } from './auth.errors';
|
||||||
@@ -51,6 +52,8 @@ function makeResStub() {
|
|||||||
|
|
||||||
interface SessionStub {
|
interface SessionStub {
|
||||||
user?: AuthenticatedUser;
|
user?: AuthenticatedUser;
|
||||||
|
createdAt?: number;
|
||||||
|
absoluteExpiresAt?: number;
|
||||||
save: jest.Mock;
|
save: jest.Mock;
|
||||||
destroy: jest.Mock;
|
destroy: jest.Mock;
|
||||||
}
|
}
|
||||||
@@ -70,11 +73,13 @@ function makeSessionStub(opts?: {
|
|||||||
function makeReqStub(opts?: {
|
function makeReqStub(opts?: {
|
||||||
signedCookies?: Record<string, unknown>;
|
signedCookies?: Record<string, unknown>;
|
||||||
session?: SessionStub;
|
session?: SessionStub;
|
||||||
}): Request & { session: SessionStub } {
|
sessionID?: string;
|
||||||
|
}): Request & { session: SessionStub; sessionID: string } {
|
||||||
return {
|
return {
|
||||||
signedCookies: opts?.signedCookies ?? {},
|
signedCookies: opts?.signedCookies ?? {},
|
||||||
session: opts?.session ?? makeSessionStub(),
|
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() {
|
function makeLoggerStub() {
|
||||||
@@ -96,6 +101,7 @@ interface ControllerFixture {
|
|||||||
beginAuthCodeFlow: jest.Mock;
|
beginAuthCodeFlow: jest.Mock;
|
||||||
completeAuthCodeFlow: jest.Mock;
|
completeAuthCodeFlow: jest.Mock;
|
||||||
logger: ReturnType<typeof makeLoggerStub>;
|
logger: ReturnType<typeof makeLoggerStub>;
|
||||||
|
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)}`;
|
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,
|
completeAuthCodeFlow,
|
||||||
buildLogoutUrl,
|
buildLogoutUrl,
|
||||||
} as unknown as AuthService;
|
} as unknown as AuthService;
|
||||||
|
const userSessionIndex = {
|
||||||
|
add: jest.fn().mockResolvedValue(undefined),
|
||||||
|
remove: jest.fn().mockResolvedValue(undefined),
|
||||||
|
list: jest.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
const logger = makeLoggerStub();
|
const logger = makeLoggerStub();
|
||||||
return {
|
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,
|
beginAuthCodeFlow,
|
||||||
completeAuthCodeFlow,
|
completeAuthCodeFlow,
|
||||||
logger,
|
logger,
|
||||||
|
userSessionIndex,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +213,45 @@ describe('AuthController.callback', () => {
|
|||||||
expect(res.redirect).toHaveBeenCalledWith(302, ENTRA.postLogoutRedirectUri);
|
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 () => {
|
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 { controller } = makeController();
|
||||||
const res = makeResStub();
|
const res = makeResStub();
|
||||||
@@ -343,13 +399,14 @@ describe('AuthController.me', () => {
|
|||||||
|
|
||||||
describe('AuthController.logout', () => {
|
describe('AuthController.logout', () => {
|
||||||
it('destroys the session, clears the session cookie, logs success, redirects to Entra logout', async () => {
|
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 res = makeResStub();
|
||||||
const session = makeSessionStub({ user: USER });
|
const session = makeSessionStub({ user: USER });
|
||||||
const req = makeReqStub({ session });
|
const req = makeReqStub({ session, sessionID: 'logged-out-sid' });
|
||||||
|
|
||||||
await controller.logout(req, res);
|
await controller.logout(req, res);
|
||||||
|
|
||||||
|
expect(userSessionIndex.remove).toHaveBeenCalledWith(USER.oid, 'logged-out-sid');
|
||||||
expect(session.destroy).toHaveBeenCalledTimes(1);
|
expect(session.destroy).toHaveBeenCalledTimes(1);
|
||||||
expect(res.clearCookie).toHaveBeenCalledWith('portal_session', { path: '/' });
|
expect(res.clearCookie).toHaveBeenCalledWith('portal_session', { path: '/' });
|
||||||
expect(logger.log).toHaveBeenCalledWith(
|
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 () => {
|
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 res = makeResStub();
|
||||||
const session = makeSessionStub();
|
const session = makeSessionStub();
|
||||||
const req = makeReqStub({ session });
|
const req = makeReqStub({ session });
|
||||||
|
|
||||||
await controller.logout(req, res);
|
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(session.destroy).toHaveBeenCalledTimes(1);
|
||||||
expect(logger.log).toHaveBeenCalledWith(
|
expect(logger.log).toHaveBeenCalledWith(
|
||||||
{ event: 'auth.signed_out', wasAuthenticated: false },
|
{ event: 'auth.signed_out', wasAuthenticated: false },
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Controller, Get, Inject, Query, Req, Res } from '@nestjs/common';
|
import { Controller, Get, Inject, Query, Req, Res } from '@nestjs/common';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { Logger } from 'nestjs-pino';
|
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 {
|
import {
|
||||||
PRE_AUTH_COOKIE_NAME,
|
PRE_AUTH_COOKIE_NAME,
|
||||||
clearPreAuthCookieOptions,
|
clearPreAuthCookieOptions,
|
||||||
@@ -27,6 +28,7 @@ export class AuthController {
|
|||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
@Inject(ENTRA_CONFIG) private readonly entra: EntraConfig,
|
@Inject(ENTRA_CONFIG) private readonly entra: EntraConfig,
|
||||||
|
private readonly userSessionIndex: UserSessionIndexService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -106,13 +108,25 @@ export class AuthController {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await this.authService.completeAuthCodeFlow(code, state, preAuth);
|
const user = await this.authService.completeAuthCodeFlow(code, state, preAuth);
|
||||||
|
const now = Date.now();
|
||||||
|
const { absoluteSeconds } = readSessionTimeouts();
|
||||||
req.session.user = user;
|
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
|
// Force the save before the redirect: express-session writes
|
||||||
// on response end, but the 302 we're about to emit closes the
|
// on response end, but the 302 we're about to emit closes the
|
||||||
// response before the async store-write would otherwise
|
// response before the async store-write would otherwise
|
||||||
// complete. Without this, the browser hits the SPA before
|
// complete. Without this, the browser hits the SPA before
|
||||||
// Redis carries the new payload.
|
// Redis carries the new payload.
|
||||||
await saveSession(req);
|
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(
|
this.logger.log(
|
||||||
{
|
{
|
||||||
event: 'auth.signed_in',
|
event: 'auth.signed_in',
|
||||||
@@ -168,9 +182,18 @@ export class AuthController {
|
|||||||
*/
|
*/
|
||||||
@Get('logout')
|
@Get('logout')
|
||||||
async logout(@Req() req: Request, @Res() res: Response): Promise<void> {
|
async logout(@Req() req: Request, @Res() res: Response): Promise<void> {
|
||||||
const wasAuthenticated = Boolean(req.session.user);
|
const user = req.session.user;
|
||||||
|
const wasAuthenticated = Boolean(user);
|
||||||
|
const sessionId = req.sessionID;
|
||||||
const logoutUrl = this.authService.buildLogoutUrl();
|
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 {
|
try {
|
||||||
await destroySession(req);
|
await destroySession(req);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ConfidentialClientApplication, LogLevel } from '@azure/msal-node';
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { Logger } from 'nestjs-pino';
|
import { Logger } from 'nestjs-pino';
|
||||||
import { assertEntraConfig } from '../config/check-entra-config';
|
import { assertEntraConfig } from '../config/check-entra-config';
|
||||||
|
import { SessionModule } from '../session/session.module';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { ENTRA_CONFIG, type EntraConfig } from './entra-config.token';
|
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.
|
* `imports: [AuthModule]` is enough to consume either.
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [SessionModule],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [
|
providers: [
|
||||||
AuthService,
|
AuthService,
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ import { assertEntraConfig } from './config/check-entra-config';
|
|||||||
import { assertRedisConfig } from './config/check-redis-config';
|
import { assertRedisConfig } from './config/check-redis-config';
|
||||||
import { assertSessionEncryptionKey } from './config/check-session-encryption-key';
|
import { assertSessionEncryptionKey } from './config/check-session-encryption-key';
|
||||||
import { assertSessionSecret } from './config/check-session-secret';
|
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
|
// Fail fast on a malformed DATABASE_URL (most often a special char in
|
||||||
// the password that needs URL-encoding) rather than letting Prisma
|
// 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.
|
// cookie is parsed by the time `express-session` reads it.
|
||||||
app.use(app.get<RequestHandler>(SESSION_MIDDLEWARE));
|
app.use(app.get<RequestHandler>(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<RequestHandler>(SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE));
|
||||||
|
|
||||||
// Phase-2 security ADRs will harden the above: helmet, real CORS
|
// Phase-2 security ADRs will harden the above: helmet, real CORS
|
||||||
// allowlist, CSRF protection, rate limiting, auth guards,
|
// allowlist, CSRF protection, rate limiting, auth guards,
|
||||||
// structured error filter.
|
// structured error filter.
|
||||||
|
|||||||
@@ -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>): 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
req.session.destroy((err) => (err ? reject(err) : resolve()));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,13 +7,19 @@ import { assertSessionEncryptionKey } from '../config/check-session-encryption-k
|
|||||||
import { assertSessionSecret } from '../config/check-session-secret';
|
import { assertSessionSecret } from '../config/check-session-secret';
|
||||||
import { RedisModule } from '../redis/redis.module';
|
import { RedisModule } from '../redis/redis.module';
|
||||||
import { REDIS_CLIENT, type Redis } from '../redis/redis.token';
|
import { REDIS_CLIENT, type Redis } from '../redis/redis.token';
|
||||||
|
import { createAbsoluteTimeoutMiddleware } from './absolute-timeout.middleware';
|
||||||
import { adaptIoredisForConnectRedis } from './ioredis-connect-redis-adapter';
|
import { adaptIoredisForConnectRedis } from './ioredis-connect-redis-adapter';
|
||||||
import { SessionDecryptError, decrypt, encrypt } from './session-crypto';
|
import { SessionDecryptError, decrypt, encrypt } from './session-crypto';
|
||||||
import { readSessionTimeouts, sessionCookieName, sessionCookieOptions } from './session-cookie';
|
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
|
// Side-effect import: brings the `req.session.user` declaration
|
||||||
// merging into the compile graph for every consumer of this module.
|
// merging into the compile graph for every consumer of this module.
|
||||||
import './session.types';
|
import './session.types';
|
||||||
|
import { UserSessionIndexService } from './user-session-index.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Session module — wires `express-session` with a `connect-redis`
|
* 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
|
* the same Redis client the rest of the BFF uses, instead of
|
||||||
* spinning up a second connection at the bootstrap layer.
|
* spinning up a second connection at the bootstrap layer.
|
||||||
*
|
*
|
||||||
* Scope of this PR — infrastructure only:
|
* What's wired today:
|
||||||
* - middleware mounted on every request
|
* - Express-session middleware on every request (`SESSION_MIDDLEWARE`).
|
||||||
* - per-request `req.session` available downstream
|
* - Absolute-timeout middleware (`SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE`)
|
||||||
* - cookie set on first write (`saveUninitialized: false`)
|
* that enforces the 12 h hard ceiling per ADR-0010.
|
||||||
* - encryption-at-rest active
|
* - `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):
|
* Out of scope, landing in follow-ups:
|
||||||
* - `/auth/callback` populating `req.session.user`
|
* - The admin "logout everywhere" route that consumes the
|
||||||
* - `/me`, `/auth/logout`
|
* `UserSessionIndexService` (waits on the admin module + the
|
||||||
* - absolute-timeout interceptor
|
* `@RequireAdmin` / `@RequireMfa` guards).
|
||||||
* - `user_sessions:{userId}` secondary index
|
* - Audit-pipeline wiring for `session.decrypt_failed` and
|
||||||
* - JSON-decode error → audit event with `event:
|
* `session.absolute_timeout` (ADR-0013).
|
||||||
* session.decrypt_failed` (the throw is in place; audit
|
|
||||||
* pipeline lands with ADR-0013).
|
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [RedisModule],
|
imports: [RedisModule],
|
||||||
providers: [
|
providers: [
|
||||||
|
UserSessionIndexService,
|
||||||
|
{
|
||||||
|
provide: SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE,
|
||||||
|
inject: [UserSessionIndexService, Logger],
|
||||||
|
useFactory: (index: UserSessionIndexService, logger: Logger): RequestHandler =>
|
||||||
|
createAbsoluteTimeoutMiddleware(index, logger),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: SESSION_MIDDLEWARE,
|
provide: SESSION_MIDDLEWARE,
|
||||||
inject: [REDIS_CLIENT, Logger],
|
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 {}
|
export class SessionModule {}
|
||||||
|
|||||||
@@ -12,4 +12,12 @@ import type { RequestHandler } from 'express';
|
|||||||
*/
|
*/
|
||||||
export const SESSION_MIDDLEWARE = 'SESSION_MIDDLEWARE';
|
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 };
|
export type { RequestHandler };
|
||||||
|
|||||||
@@ -18,6 +18,21 @@ import type { AuthenticatedUser } from '../auth/auth.service';
|
|||||||
declare module 'express-session' {
|
declare module 'express-session' {
|
||||||
interface SessionData {
|
interface SessionData {
|
||||||
user?: AuthenticatedUser;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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:<userId>', 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:<userId>', 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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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:<id>` 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<string[]> {
|
||||||
|
return this.redis.smembers(this.keyFor(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private keyFor(userId: string): string {
|
||||||
|
return `${UserSessionIndexService.KEY_PREFIX}${userId}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user