feat(portal-bff): absolute-timeout middleware + user_sessions index per ADR-0010
closes the ttl-policy + revocation pieces left from #110. 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, it destroys the redis-side session, clears the portal_session cookie, drops the entry from the per-user index, and lets the request keep flowing anonymously. route-level guards (/me today, future @requireauth) turn that into a 401 where auth is actually needed — public routes stay accessible. not the "every route returns 401" reading of adr-0010 §"ttl policy" (validated with the project lead): "returns 401" is interpreted as "the user eventually sees a 401 when they touch something that needs auth", not as a hard refusal on the current in-flight request. user_sessions:{userId} secondary index (adr-0010 §"revocation"): a new UserSessionIndexService maintains a redis set of active session ids per user. wired on /auth/callback (sadd at sign-in, after session.save() so the session row exists before the index points at it) and on /auth/logout + the absolute-timeout middleware (srem on destroy). best-effort: a failed sadd/srem logs a pino warning and the auth flow continues — the index is a convenience for admin operations, not a security invariant. orphans (entries whose session:<id> redis key has expired on idle ttl) are tolerated per the adr; future consumer code filters them out. no in-product consumer ships in this pr — the admin "logout everywhere" endpoint lands with the admin module (+ @requireadmin / @requiremfa guards). today the index is write-only on the bff side. 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 so every consumer sees a typed payload. wiring: - SessionModule provides UserSessionIndexService and a SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE token whose factory builds the middleware via createAbsoluteTimeoutMiddleware(index, logger). same pattern as the existing SESSION_MIDDLEWARE token. - AuthModule now imports SessionModule so AuthController can inject UserSessionIndexService. - main.ts mounts the absolute-timeout middleware immediately after the session middleware. per-user identifier is the entra `oid` claim (stable per-user inside the tenant, matches req.session.user.oid). future multi-tenant scenarios may want `${tid}:${oid}` — easy refactor when external id activation lands (adr-0008). not in scope here. out of scope, landing in follow-ups: - admin "logout everywhere" endpoint consuming UserSessionIndexService.list(userId) - audit-pipeline first-class events for session.absolute_timeout + user_session_index.* (adr-0013) - token blob persistence (id_token / access_token / refresh_token) in the encrypted session — adr-0014 dependency 123/123 tests pass (was 110); +13 specs across the two new files and the auth controller spec.
This commit is contained in:
@@ -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<string, unknown>;
|
||||
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<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)}`;
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user