feat(auth): revoke sessions server-side via a jti denylist

invalidateSession previously ignored its argument, so a stateless JWT
stayed valid until exp after logout (audit finding B5). createSession
now embeds the minted session.id in the token as jti; invalidateSession
records that jti in an in-memory denylist with expiry-based pruning, and
validateSession rejects denylisted (and jti-less, failing closed)
tokens. The denylist is per-process — the single-process limitation and
the shared-store upgrade path are documented in session-denylist.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 17:25:51 +02:00
parent c7d1dd8055
commit db2afde0dc
4 changed files with 157 additions and 15 deletions

View File

@@ -4,6 +4,7 @@ import type { IAuthenticationService } from "../../application/services/authenti
import type { Cookie } from "../../entities/models/cookie";
import type { Session } from "../../entities/models/session";
import type { User } from "../../entities/models/user";
import { InMemorySessionDenylist } from "./session-denylist";
const SALT_LENGTH = 16;
const KEY_LENGTH = 64;
@@ -15,7 +16,12 @@ const COOKIE_NAME = "payload-token";
const SESSION_DURATION_SECONDS = 7200; // 2 hours (matches Payload default)
export class AuthenticationService implements IAuthenticationService {
constructor(private config: SanitizedConfig) {}
constructor(
private config: SanitizedConfig,
// Server-side revocation (audit finding B5). In-memory: revocations are
// per-process — see session-denylist.ts for the limitation write-up.
private denylist: InMemorySessionDenylist = new InMemorySessionDenylist(),
) {}
generateUserId(): string {
return crypto.randomUUID();
@@ -69,10 +75,13 @@ export class AuthenticationService implements IAuthenticationService {
const payload = await getPayload({ config: this.config });
const expiresAt = new Date(Date.now() + SESSION_DURATION_SECONDS * 1000);
const token = this.signToken(user.id, payload.secret);
// The session id doubles as the JWT `jti` so the token can be revoked
// server-side via the denylist (audit finding B5).
const sessionId = crypto.randomUUID();
const token = this.signToken(user.id, sessionId, payload.secret);
const session: Session = {
id: crypto.randomUUID(),
id: sessionId,
userId: user.id,
expiresAt,
};
@@ -97,6 +106,11 @@ export class AuthenticationService implements IAuthenticationService {
const payload = await getPayload({ config: this.config });
const decoded = this.verifyToken(token, payload.secret);
if (!decoded) throw new Error("Invalid or expired session token");
// Server-side revocation check (audit finding B5): a signed, unexpired
// token is still rejected once its jti has been invalidated.
if (this.denylist.isRevoked(decoded.jti)) {
throw new Error("Session has been revoked");
}
const userDoc = await payload.findByID({
collection: "users" as const,
@@ -111,7 +125,7 @@ export class AuthenticationService implements IAuthenticationService {
};
const session: Session = {
id: token,
id: decoded.jti,
userId: user.id,
expiresAt: new Date(decoded.exp * 1000),
};
@@ -119,9 +133,11 @@ export class AuthenticationService implements IAuthenticationService {
return { user, session };
}
async invalidateSession(
_sessionId: string,
): Promise<{ blankCookie: Cookie }> {
async invalidateSession(sessionId: string): Promise<{ blankCookie: Cookie }> {
// `sessionId` is the JWT `jti` (the `session.id` returned by
// createSession/validateSession). Denylist it for the maximum token
// lifetime — beyond that, the token's own `exp` rejects it (B5).
this.denylist.revoke(sessionId, SESSION_DURATION_SECONDS);
return {
blankCookie: {
name: COOKIE_NAME,
@@ -138,13 +154,13 @@ export class AuthenticationService implements IAuthenticationService {
}
/** Sign a HS256 JWT using Payload's instance secret. No external dependency. */
private signToken(userId: string, secret: string): string {
private signToken(userId: string, jti: string, secret: string): string {
const header = Buffer.from(
JSON.stringify({ alg: "HS256", typ: "JWT" }),
).toString("base64url");
const exp = Math.floor(Date.now() / 1000) + SESSION_DURATION_SECONDS;
const body = Buffer.from(
JSON.stringify({ id: userId, collection: "users", exp }),
JSON.stringify({ id: userId, collection: "users", exp, jti }),
).toString("base64url");
const signature = crypto
.createHmac("sha256", secret)
@@ -157,22 +173,38 @@ export class AuthenticationService implements IAuthenticationService {
private verifyToken(
token: string,
secret: string,
): { id: string; exp: number } | null {
): { id: string; exp: number; jti: string } | null {
const parts = token.split(".");
if (parts.length !== 3) return null;
const [header, body, signature] = parts as [string, string, string];
const expected = crypto
.createHmac("sha256", secret)
.update(`${header}.${body}`)
.digest("base64url");
if (signature !== expected) return null;
.digest();
const provided = Buffer.from(signature, "base64url");
// Constant-time comparison, mirroring verifyPassword (audit finding B4).
// timingSafeEqual requires equal-length buffers; a length mismatch is
// already an invalid signature, and the guard leaks nothing an attacker
// does not know (the expected HMAC-SHA256 length is public).
if (provided.length !== expected.length) return null;
if (!crypto.timingSafeEqual(provided, expected)) return null;
try {
const decoded = JSON.parse(Buffer.from(body, "base64url").toString()) as {
id: string;
exp: number;
id?: unknown;
exp?: unknown;
jti?: unknown;
};
// Fail closed: tokens without a jti cannot be revoked, so they are
// not accepted (audit finding B5).
if (
typeof decoded.id !== "string" ||
typeof decoded.exp !== "number" ||
typeof decoded.jti !== "string"
) {
return null;
}
if (decoded.exp < Math.floor(Date.now() / 1000)) return null;
return decoded;
return { id: decoded.id, exp: decoded.exp, jti: decoded.jti };
} catch {
return null;
}