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:
@@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest";
|
|||||||
import { signOutUseCase } from "@/application/use-cases/sign-out.use-case";
|
import { signOutUseCase } from "@/application/use-cases/sign-out.use-case";
|
||||||
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
|
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
|
||||||
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
|
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
|
||||||
|
import { UnauthenticatedError } from "@/entities/errors/auth";
|
||||||
|
import { userFactory } from "@/__factories__/user.factory";
|
||||||
|
|
||||||
describe("signOutUseCase", () => {
|
describe("signOutUseCase", () => {
|
||||||
it("returns void on successful sign-out", async () => {
|
it("returns void on successful sign-out", async () => {
|
||||||
@@ -12,4 +14,22 @@ describe("signOutUseCase", () => {
|
|||||||
const result = await useCase({ sessionId: "session_1" });
|
const result = await useCase({ sessionId: "session_1" });
|
||||||
expect(result).toBeUndefined();
|
expect(result).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("revokes the session server-side: validateSession rejects it afterwards", async () => {
|
||||||
|
const users = new MockUsersRepository([]);
|
||||||
|
const auth = new MockAuthenticationService(users);
|
||||||
|
const user = userFactory.build({ username: "alice" });
|
||||||
|
await users.createUser(user);
|
||||||
|
const { session } = await auth.createSession(user);
|
||||||
|
|
||||||
|
// Sanity: session is valid before sign-out.
|
||||||
|
await expect(auth.validateSession(session.id)).resolves.toBeDefined();
|
||||||
|
|
||||||
|
await signOutUseCase(auth)({ sessionId: session.id });
|
||||||
|
|
||||||
|
// B5: sign-out must invalidate server-side, not just clear the cookie.
|
||||||
|
await expect(auth.validateSession(session.id)).rejects.toBeInstanceOf(
|
||||||
|
UnauthenticatedError,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { IAuthenticationService } from "../../application/services/authenti
|
|||||||
import type { Cookie } from "../../entities/models/cookie";
|
import type { Cookie } from "../../entities/models/cookie";
|
||||||
import type { Session } from "../../entities/models/session";
|
import type { Session } from "../../entities/models/session";
|
||||||
import type { User } from "../../entities/models/user";
|
import type { User } from "../../entities/models/user";
|
||||||
|
import { InMemorySessionDenylist } from "./session-denylist";
|
||||||
|
|
||||||
const SALT_LENGTH = 16;
|
const SALT_LENGTH = 16;
|
||||||
const KEY_LENGTH = 64;
|
const KEY_LENGTH = 64;
|
||||||
@@ -15,7 +16,12 @@ const COOKIE_NAME = "payload-token";
|
|||||||
const SESSION_DURATION_SECONDS = 7200; // 2 hours (matches Payload default)
|
const SESSION_DURATION_SECONDS = 7200; // 2 hours (matches Payload default)
|
||||||
|
|
||||||
export class AuthenticationService implements IAuthenticationService {
|
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 {
|
generateUserId(): string {
|
||||||
return crypto.randomUUID();
|
return crypto.randomUUID();
|
||||||
@@ -69,10 +75,13 @@ export class AuthenticationService implements IAuthenticationService {
|
|||||||
const payload = await getPayload({ config: this.config });
|
const payload = await getPayload({ config: this.config });
|
||||||
const expiresAt = new Date(Date.now() + SESSION_DURATION_SECONDS * 1000);
|
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 = {
|
const session: Session = {
|
||||||
id: crypto.randomUUID(),
|
id: sessionId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
};
|
};
|
||||||
@@ -97,6 +106,11 @@ export class AuthenticationService implements IAuthenticationService {
|
|||||||
const payload = await getPayload({ config: this.config });
|
const payload = await getPayload({ config: this.config });
|
||||||
const decoded = this.verifyToken(token, payload.secret);
|
const decoded = this.verifyToken(token, payload.secret);
|
||||||
if (!decoded) throw new Error("Invalid or expired session token");
|
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({
|
const userDoc = await payload.findByID({
|
||||||
collection: "users" as const,
|
collection: "users" as const,
|
||||||
@@ -111,7 +125,7 @@ export class AuthenticationService implements IAuthenticationService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const session: Session = {
|
const session: Session = {
|
||||||
id: token,
|
id: decoded.jti,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
expiresAt: new Date(decoded.exp * 1000),
|
expiresAt: new Date(decoded.exp * 1000),
|
||||||
};
|
};
|
||||||
@@ -119,9 +133,11 @@ export class AuthenticationService implements IAuthenticationService {
|
|||||||
return { user, session };
|
return { user, session };
|
||||||
}
|
}
|
||||||
|
|
||||||
async invalidateSession(
|
async invalidateSession(sessionId: string): Promise<{ blankCookie: Cookie }> {
|
||||||
_sessionId: string,
|
// `sessionId` is the JWT `jti` (the `session.id` returned by
|
||||||
): Promise<{ blankCookie: Cookie }> {
|
// 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 {
|
return {
|
||||||
blankCookie: {
|
blankCookie: {
|
||||||
name: COOKIE_NAME,
|
name: COOKIE_NAME,
|
||||||
@@ -138,13 +154,13 @@ export class AuthenticationService implements IAuthenticationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Sign a HS256 JWT using Payload's instance secret. No external dependency. */
|
/** 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(
|
const header = Buffer.from(
|
||||||
JSON.stringify({ alg: "HS256", typ: "JWT" }),
|
JSON.stringify({ alg: "HS256", typ: "JWT" }),
|
||||||
).toString("base64url");
|
).toString("base64url");
|
||||||
const exp = Math.floor(Date.now() / 1000) + SESSION_DURATION_SECONDS;
|
const exp = Math.floor(Date.now() / 1000) + SESSION_DURATION_SECONDS;
|
||||||
const body = Buffer.from(
|
const body = Buffer.from(
|
||||||
JSON.stringify({ id: userId, collection: "users", exp }),
|
JSON.stringify({ id: userId, collection: "users", exp, jti }),
|
||||||
).toString("base64url");
|
).toString("base64url");
|
||||||
const signature = crypto
|
const signature = crypto
|
||||||
.createHmac("sha256", secret)
|
.createHmac("sha256", secret)
|
||||||
@@ -157,22 +173,38 @@ export class AuthenticationService implements IAuthenticationService {
|
|||||||
private verifyToken(
|
private verifyToken(
|
||||||
token: string,
|
token: string,
|
||||||
secret: string,
|
secret: string,
|
||||||
): { id: string; exp: number } | null {
|
): { id: string; exp: number; jti: string } | null {
|
||||||
const parts = token.split(".");
|
const parts = token.split(".");
|
||||||
if (parts.length !== 3) return null;
|
if (parts.length !== 3) return null;
|
||||||
const [header, body, signature] = parts as [string, string, string];
|
const [header, body, signature] = parts as [string, string, string];
|
||||||
const expected = crypto
|
const expected = crypto
|
||||||
.createHmac("sha256", secret)
|
.createHmac("sha256", secret)
|
||||||
.update(`${header}.${body}`)
|
.update(`${header}.${body}`)
|
||||||
.digest("base64url");
|
.digest();
|
||||||
if (signature !== expected) return null;
|
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 {
|
try {
|
||||||
const decoded = JSON.parse(Buffer.from(body, "base64url").toString()) as {
|
const decoded = JSON.parse(Buffer.from(body, "base64url").toString()) as {
|
||||||
id: string;
|
id?: unknown;
|
||||||
exp: number;
|
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;
|
if (decoded.exp < Math.floor(Date.now() / 1000)) return null;
|
||||||
return decoded;
|
return { id: decoded.id, exp: decoded.exp, jti: decoded.jti };
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { InMemorySessionDenylist } from "@/infrastructure/services/session-denylist";
|
||||||
|
|
||||||
|
describe("InMemorySessionDenylist", () => {
|
||||||
|
it("reports a revoked jti as revoked", () => {
|
||||||
|
const denylist = new InMemorySessionDenylist();
|
||||||
|
denylist.revoke("jti-1", 60);
|
||||||
|
expect(denylist.isRevoked("jti-1")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not report unknown jtis as revoked", () => {
|
||||||
|
const denylist = new InMemorySessionDenylist();
|
||||||
|
expect(denylist.isRevoked("never-seen")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prunes entries after their ttl elapses", () => {
|
||||||
|
let now = 1_000_000;
|
||||||
|
const denylist = new InMemorySessionDenylist(() => now);
|
||||||
|
denylist.revoke("jti-1", 60);
|
||||||
|
expect(denylist.isRevoked("jti-1")).toBe(true);
|
||||||
|
|
||||||
|
now += 60_000; // exactly at expiry — entry is prunable
|
||||||
|
expect(denylist.isRevoked("jti-1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps entries alive until the ttl elapses", () => {
|
||||||
|
let now = 1_000_000;
|
||||||
|
const denylist = new InMemorySessionDenylist(() => now);
|
||||||
|
denylist.revoke("jti-1", 60);
|
||||||
|
now += 59_999;
|
||||||
|
expect(denylist.isRevoked("jti-1")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prunes expired entries on revoke, not just on reads", () => {
|
||||||
|
let now = 1_000_000;
|
||||||
|
const denylist = new InMemorySessionDenylist(() => now);
|
||||||
|
denylist.revoke("old", 1);
|
||||||
|
now += 5_000;
|
||||||
|
denylist.revoke("new", 60);
|
||||||
|
// Reach into nothing — observable via isRevoked semantics only.
|
||||||
|
expect(denylist.isRevoked("old")).toBe(false);
|
||||||
|
expect(denylist.isRevoked("new")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* In-memory JWT `jti` denylist backing server-side session revocation
|
||||||
|
* (audit finding B5).
|
||||||
|
*
|
||||||
|
* `AuthenticationService.createSession` mints a session id and embeds it in
|
||||||
|
* the JWT as `jti`; `invalidateSession(jti)` records it here and
|
||||||
|
* `validateSession` rejects any token whose `jti` is denylisted. Entries
|
||||||
|
* expire with the token they revoke (max session lifetime), so the map is
|
||||||
|
* self-pruning and cannot grow past the number of sign-outs per lifetime
|
||||||
|
* window.
|
||||||
|
*
|
||||||
|
* SINGLE-PROCESS LIMITATION: this denylist lives in process memory. It is
|
||||||
|
* correct for a single server process (the template's deployment shape) but
|
||||||
|
* revocations are NOT shared across processes/instances and do not survive
|
||||||
|
* restarts — a restarted process accepts a signed, unexpired token again.
|
||||||
|
* Multi-instance deployments must swap this for a shared store (Redis, DB)
|
||||||
|
* behind the same two methods.
|
||||||
|
*/
|
||||||
|
export class InMemorySessionDenylist {
|
||||||
|
/** jti -> epoch-ms after which the entry may be pruned. */
|
||||||
|
private readonly revoked = new Map<string, number>();
|
||||||
|
|
||||||
|
constructor(private readonly clock: () => number = () => Date.now()) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a revoked `jti`. `ttlSeconds` should be the maximum remaining
|
||||||
|
* token lifetime — after that, the token's own `exp` rejects it anyway.
|
||||||
|
*/
|
||||||
|
revoke(jti: string, ttlSeconds: number): void {
|
||||||
|
this.prune();
|
||||||
|
this.revoked.set(jti, this.clock() + ttlSeconds * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
isRevoked(jti: string): boolean {
|
||||||
|
this.prune();
|
||||||
|
return this.revoked.has(jti);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Expiry-based pruning — runs on every access; the map stays small. */
|
||||||
|
private prune(): void {
|
||||||
|
const now = this.clock();
|
||||||
|
for (const [jti, expiresAt] of this.revoked) {
|
||||||
|
if (expiresAt <= now) this.revoked.delete(jti);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user