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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user