fix(auth): port server-side session revocation and sign-in hardening
Ports the upstream auth audit fixes onto the kept auth feature: - revoke sessions server-side via an in-memory jti denylist (B5): createSession embeds the session id as the JWT jti, invalidateSession denylists it for the max token lifetime, validateSession rejects denylisted and jti-less (fail-closed) tokens; constant-time signature comparison (B4). Adds session-denylist.ts + test. - cover signToken/verifyToken/validateSession crypto paths without a running Payload by stubbing the payload module (B8). - derive clientIp server-side from trusted proxy headers and drop it from the public sign-in input schema; thread it as a server-only request context argument so a client can no longer spoof its rate-limit bucket (B2). - declare the auth-injected email (and displayName) in the users collection-level DSR pii map so Art. 15 export and Art. 17 soft delete cover them (A5). Adapted to our collection set (no username field). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
This commit is contained in:
@@ -11,15 +11,27 @@ import type { IUsersRepository } from "../repositories/users.repository.interfac
|
|||||||
import type { IAuthenticationService } from "../services/authentication.service.interface";
|
import type { IAuthenticationService } from "../services/authentication.service.interface";
|
||||||
|
|
||||||
// ── Input ────────────────────────────────────────────────────────────────
|
// ── Input ────────────────────────────────────────────────────────────────
|
||||||
|
// `.strict()` + no clientIp field: a client submitting clientIp is rejected
|
||||||
|
// at the procedure boundary (audit finding B2).
|
||||||
export const signInInputSchema = z
|
export const signInInputSchema = z
|
||||||
.object({
|
.object({
|
||||||
username: z.string().min(3).max(31),
|
username: z.string().min(3).max(31),
|
||||||
password: z.string().min(6).max(255),
|
password: z.string().min(6).max(255),
|
||||||
clientIp: z.string().optional(),
|
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
export type SignInInput = z.infer<typeof signInInputSchema>;
|
export type SignInInput = z.infer<typeof signInInputSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-derived per-request context, typed OUTSIDE the public input schema
|
||||||
|
* so it can never be client-supplied (audit finding B2). The tRPC adapter
|
||||||
|
* derives `clientIp` from trusted proxy headers and the controller threads
|
||||||
|
* it through; `undefined` means "no proxy header present" and falls into a
|
||||||
|
* shared bucket.
|
||||||
|
*/
|
||||||
|
export type SignInRequestContext = {
|
||||||
|
clientIp?: string;
|
||||||
|
};
|
||||||
|
|
||||||
// ── Output ───────────────────────────────────────────────────────────────
|
// ── Output ───────────────────────────────────────────────────────────────
|
||||||
export const signInOutputSchema = z.object({
|
export const signInOutputSchema = z.object({
|
||||||
session: sessionSchema,
|
session: sessionSchema,
|
||||||
@@ -36,7 +48,7 @@ export const signInUseCase =
|
|||||||
authenticationService: IAuthenticationService,
|
authenticationService: IAuthenticationService,
|
||||||
rateLimit: IRateLimit,
|
rateLimit: IRateLimit,
|
||||||
) =>
|
) =>
|
||||||
async (input: SignInInput): Promise<SignInOutput> => {
|
async (input: SignInInput & SignInRequestContext): Promise<SignInOutput> => {
|
||||||
const { allowed: ipAllowed } = await rateLimit.consume(
|
const { allowed: ipAllowed } = await rateLimit.consume(
|
||||||
"ip",
|
"ip",
|
||||||
`signIn:ip:${input.clientIp ?? ""}`,
|
`signIn:ip:${input.clientIp ?? ""}`,
|
||||||
|
|||||||
@@ -1,7 +1,38 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import crypto from "node:crypto";
|
||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
import { AuthenticationService } from "@/infrastructure/services/authentication.service";
|
import { AuthenticationService } from "@/infrastructure/services/authentication.service";
|
||||||
import { stubPayloadConfig } from "@repo/core-testing/payload/stub-config";
|
import { stubPayloadConfig } from "@repo/core-testing/payload/stub-config";
|
||||||
|
|
||||||
|
// The session methods only need `payload.secret` + `payload.findByID`, so a
|
||||||
|
// module-level stub covers the pure crypto paths without booting Payload.
|
||||||
|
const payloadStub = vi.hoisted(() => ({
|
||||||
|
secret: "test-secret",
|
||||||
|
findByID: vi.fn(
|
||||||
|
async ({ id }: { collection: string; id: string }): Promise<unknown> => ({
|
||||||
|
id,
|
||||||
|
username: "alice",
|
||||||
|
passwordHash: "stored-hash",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("payload", () => ({
|
||||||
|
getPayload: vi.fn(async () => payloadStub),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** Craft a HS256 JWT directly so tests can control every claim (B8). */
|
||||||
|
function craftToken(payload: Record<string, unknown>, secret: string): string {
|
||||||
|
const header = Buffer.from(
|
||||||
|
JSON.stringify({ alg: "HS256", typ: "JWT" }),
|
||||||
|
).toString("base64url");
|
||||||
|
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||||
|
const signature = crypto
|
||||||
|
.createHmac("sha256", secret)
|
||||||
|
.update(`${header}.${body}`)
|
||||||
|
.digest("base64url");
|
||||||
|
return `${header}.${body}.${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
describe("AuthenticationService", () => {
|
describe("AuthenticationService", () => {
|
||||||
const service = new AuthenticationService(stubPayloadConfig);
|
const service = new AuthenticationService(stubPayloadConfig);
|
||||||
|
|
||||||
@@ -42,11 +73,16 @@ describe("AuthenticationService", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("session methods (require Payload)", () => {
|
describe("session methods", () => {
|
||||||
// createSession and validateSession call getPayload() internally,
|
// getPayload is mocked module-wide (secret + findByID only), so these
|
||||||
// so they require a running Payload instance. These are exercised
|
// exercise the real signToken/verifyToken/validateSession crypto paths
|
||||||
// by the mock service in use-case tests and by integration tests.
|
// without a running Payload instance (audit finding B8).
|
||||||
// Here we only test invalidateSession (no Payload dependency).
|
const user = { id: "u1", username: "alice", passwordHash: "stored-hash" };
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
payloadStub.secret = "test-secret";
|
||||||
|
});
|
||||||
|
|
||||||
it("invalidateSession returns a blank cookie with maxAge 0", async () => {
|
it("invalidateSession returns a blank cookie with maxAge 0", async () => {
|
||||||
const { blankCookie } = await service.invalidateSession("any-token");
|
const { blankCookie } = await service.invalidateSession("any-token");
|
||||||
@@ -56,5 +92,118 @@ describe("AuthenticationService", () => {
|
|||||||
expect(blankCookie.attributes.httpOnly).toBe(true);
|
expect(blankCookie.attributes.httpOnly).toBe(true);
|
||||||
expect(blankCookie.attributes.path).toBe("/");
|
expect(blankCookie.attributes.path).toBe("/");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("createSession then validateSession round-trips (jti = session.id)", async () => {
|
||||||
|
const svc = new AuthenticationService(stubPayloadConfig);
|
||||||
|
const { session, cookie } = await svc.createSession(user);
|
||||||
|
const validated = await svc.validateSession(cookie.value);
|
||||||
|
expect(validated.user.id).toBe("u1");
|
||||||
|
expect(validated.session.userId).toBe("u1");
|
||||||
|
// The session id is the JWT jti, minted once at createSession (B5).
|
||||||
|
expect(validated.session.id).toBe(session.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a token with a tampered signature", async () => {
|
||||||
|
const svc = new AuthenticationService(stubPayloadConfig);
|
||||||
|
const { cookie } = await svc.createSession(user);
|
||||||
|
const [header, body] = cookie.value.split(".");
|
||||||
|
const forged = `${header}.${body}.${Buffer.from("forged-signature").toString("base64url")}`;
|
||||||
|
await expect(svc.validateSession(forged)).rejects.toThrow(
|
||||||
|
/invalid or expired/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a token whose payload was swapped after signing", async () => {
|
||||||
|
const svc = new AuthenticationService(stubPayloadConfig);
|
||||||
|
const { cookie } = await svc.createSession(user);
|
||||||
|
const [header, , signature] = cookie.value.split(".");
|
||||||
|
const swappedBody = Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
id: "attacker",
|
||||||
|
collection: "users",
|
||||||
|
exp: Math.floor(Date.now() / 1000) + 9999,
|
||||||
|
jti: "attacker-jti",
|
||||||
|
}),
|
||||||
|
).toString("base64url");
|
||||||
|
await expect(
|
||||||
|
svc.validateSession(`${header}.${swappedBody}.${signature}`),
|
||||||
|
).rejects.toThrow(/invalid or expired/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an expired token", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const svc = new AuthenticationService(stubPayloadConfig);
|
||||||
|
const { cookie } = await svc.createSession(user);
|
||||||
|
vi.advanceTimersByTime(3 * 60 * 60 * 1000); // 3h > 2h session duration
|
||||||
|
await expect(svc.validateSession(cookie.value)).rejects.toThrow(
|
||||||
|
/invalid or expired/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["empty string", ""],
|
||||||
|
["one segment", "not-a-jwt"],
|
||||||
|
["two segments", "aaaa.bbbb"],
|
||||||
|
["four segments", "a.b.c.d"],
|
||||||
|
["garbage segments", "!!.??.%%"],
|
||||||
|
])("rejects a malformed token (%s)", async (_label, token) => {
|
||||||
|
const svc = new AuthenticationService(stubPayloadConfig);
|
||||||
|
await expect(svc.validateSession(token)).rejects.toThrow(
|
||||||
|
/invalid or expired/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a token signed with the wrong secret", async () => {
|
||||||
|
const svc = new AuthenticationService(stubPayloadConfig);
|
||||||
|
const token = craftToken(
|
||||||
|
{
|
||||||
|
id: "u1",
|
||||||
|
collection: "users",
|
||||||
|
exp: Math.floor(Date.now() / 1000) + 600,
|
||||||
|
jti: "jti-1",
|
||||||
|
},
|
||||||
|
"some-other-secret",
|
||||||
|
);
|
||||||
|
await expect(svc.validateSession(token)).rejects.toThrow(
|
||||||
|
/invalid or expired/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a correctly signed token without a jti (fail closed)", async () => {
|
||||||
|
const svc = new AuthenticationService(stubPayloadConfig);
|
||||||
|
const token = craftToken(
|
||||||
|
{
|
||||||
|
id: "u1",
|
||||||
|
collection: "users",
|
||||||
|
exp: Math.floor(Date.now() / 1000) + 600,
|
||||||
|
},
|
||||||
|
"test-secret",
|
||||||
|
);
|
||||||
|
await expect(svc.validateSession(token)).rejects.toThrow(
|
||||||
|
/invalid or expired/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a valid token after its session is invalidated (B5)", async () => {
|
||||||
|
const svc = new AuthenticationService(stubPayloadConfig);
|
||||||
|
const { session, cookie } = await svc.createSession(user);
|
||||||
|
// Sanity: valid before revocation.
|
||||||
|
await expect(svc.validateSession(cookie.value)).resolves.toBeDefined();
|
||||||
|
|
||||||
|
await svc.invalidateSession(session.id);
|
||||||
|
await expect(svc.validateSession(cookie.value)).rejects.toThrow(
|
||||||
|
/revoked/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("revocation is per-service-instance (documented single-process limit)", async () => {
|
||||||
|
const svcA = new AuthenticationService(stubPayloadConfig);
|
||||||
|
const svcB = new AuthenticationService(stubPayloadConfig);
|
||||||
|
const { session, cookie } = await svcA.createSession(user);
|
||||||
|
await svcA.invalidateSession(session.id);
|
||||||
|
// A separate instance (≈ another process) still accepts the token —
|
||||||
|
// this pins the documented in-memory denylist limitation.
|
||||||
|
await expect(svcB.validateSession(cookie.value)).resolves.toBeDefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
import { authRouter } from "@/integrations/api/router";
|
import { authRouter } from "@/integrations/api/router";
|
||||||
@@ -27,6 +27,45 @@ describe("authRouter", () => {
|
|||||||
});
|
});
|
||||||
expect(result.name).toBe("session");
|
expect(result.name).toBe("session");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects a client-supplied clientIp at the procedure boundary (B2)", async () => {
|
||||||
|
const caller = authRouter.createCaller({});
|
||||||
|
try {
|
||||||
|
await caller.signIn({
|
||||||
|
username: "alice",
|
||||||
|
password: "password_alice",
|
||||||
|
clientIp: "6.6.6.6",
|
||||||
|
} as never);
|
||||||
|
throw new Error("expected throw");
|
||||||
|
} catch (e) {
|
||||||
|
expect(e).toBeInstanceOf(TRPCError);
|
||||||
|
expect((e as TRPCError).code).toBe("BAD_REQUEST");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("threads ctx.clientIp (server-derived) into the controller (B2)", async () => {
|
||||||
|
const original = authContainer.get(AUTH_SYMBOLS.ISignInController);
|
||||||
|
authContainer.unbind(AUTH_SYMBOLS.ISignInController);
|
||||||
|
const spy = vi.fn(async () => ({
|
||||||
|
name: "session",
|
||||||
|
value: "tok",
|
||||||
|
attributes: {},
|
||||||
|
}));
|
||||||
|
authContainer.bind(AUTH_SYMBOLS.ISignInController).toConstantValue(spy);
|
||||||
|
try {
|
||||||
|
const caller = authRouter.createCaller({ clientIp: "203.0.113.7" });
|
||||||
|
await caller.signIn({ username: "alice", password: "password_alice" });
|
||||||
|
expect(spy).toHaveBeenCalledWith(
|
||||||
|
{ username: "alice", password: "password_alice" },
|
||||||
|
{ clientIp: "203.0.113.7" },
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
authContainer.unbind(AUTH_SYMBOLS.ISignInController);
|
||||||
|
authContainer
|
||||||
|
.bind(AUTH_SYMBOLS.ISignInController)
|
||||||
|
.toConstantValue(original);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("authRouter error mapping", () => {
|
describe("authRouter error mapping", () => {
|
||||||
|
|||||||
@@ -14,18 +14,29 @@ import type { ISignOutController } from "../../interface-adapters/controllers/si
|
|||||||
import { authProcedure } from "./procedures";
|
import { authProcedure } from "./procedures";
|
||||||
|
|
||||||
export const authRouter = router({
|
export const authRouter = router({
|
||||||
signIn: authProcedure.input(signInInputSchema).mutation(({ input }) => {
|
signIn: authProcedure.input(signInInputSchema).mutation(({ input, ctx }) => {
|
||||||
const ctrl = authContainer.get<ISignInController>(AUTH_SYMBOLS.ISignInController);
|
const ctrl = authContainer.get<ISignInController>(
|
||||||
return ctrl(input);
|
AUTH_SYMBOLS.ISignInController,
|
||||||
|
);
|
||||||
|
// clientIp is derived server-side by the adapter's createContext (from
|
||||||
|
// trusted proxy headers) — never from the client payload; the strict
|
||||||
|
// input schema rejects a client-supplied clientIp outright (B2). Same
|
||||||
|
// ctx-cast pattern as the dsr router until the shared t is context-typed.
|
||||||
|
const { clientIp } = ctx as { clientIp?: string };
|
||||||
|
return ctrl(input, { clientIp });
|
||||||
}),
|
}),
|
||||||
|
|
||||||
signUp: authProcedure.input(signUpInputSchema).mutation(({ input }) => {
|
signUp: authProcedure.input(signUpInputSchema).mutation(({ input }) => {
|
||||||
const ctrl = authContainer.get<ISignUpController>(AUTH_SYMBOLS.ISignUpController);
|
const ctrl = authContainer.get<ISignUpController>(
|
||||||
|
AUTH_SYMBOLS.ISignUpController,
|
||||||
|
);
|
||||||
return ctrl(input);
|
return ctrl(input);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
signOut: authProcedure.input(signOutInputSchema).mutation(({ input }) => {
|
signOut: authProcedure.input(signOutInputSchema).mutation(({ input }) => {
|
||||||
const ctrl = authContainer.get<ISignOutController>(AUTH_SYMBOLS.ISignOutController);
|
const ctrl = authContainer.get<ISignOutController>(
|
||||||
|
AUTH_SYMBOLS.ISignOutController,
|
||||||
|
);
|
||||||
return ctrl(input);
|
return ctrl(input);
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,27 @@ export const users: CollectionConfig = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
subject: { kind: "self", field: "id" },
|
subject: { kind: "self", field: "id" },
|
||||||
|
// Collection-level PII map consumed by the DSR walkers (audit finding
|
||||||
|
// A5): export includes fields marked exportable; the soft-delete path
|
||||||
|
// redacts them. `email` is auto-added by Payload's `auth: true` and has
|
||||||
|
// no explicit field entry below, so it MUST be declared here or Art. 15
|
||||||
|
// export misses it and Art. 17 soft delete leaves it behind. `displayName`
|
||||||
|
// is declared here (in addition to its field-level tag) because the DSR
|
||||||
|
// walkers read only this collection-level map.
|
||||||
|
pii: {
|
||||||
|
email: {
|
||||||
|
category: "contact-email",
|
||||||
|
purpose: ["account-authentication", "transactional-notifications"],
|
||||||
|
exportable: true,
|
||||||
|
restrictable: true,
|
||||||
|
},
|
||||||
|
displayName: {
|
||||||
|
category: "identification-username",
|
||||||
|
purpose: ["service-delivery"],
|
||||||
|
exportable: true,
|
||||||
|
restrictable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { MockAuthenticationService } from "@/infrastructure/services/authenticat
|
|||||||
import { InputParseError } from "@/entities/errors/common";
|
import { InputParseError } from "@/entities/errors/common";
|
||||||
import { userFactory } from "@/__factories__/user.factory";
|
import { userFactory } from "@/__factories__/user.factory";
|
||||||
import { NoopRateLimit } from "@repo/core-shared/rate-limit";
|
import { NoopRateLimit } from "@repo/core-shared/rate-limit";
|
||||||
|
import { RecordingRateLimit } from "@repo/core-testing/rate-limit";
|
||||||
|
|
||||||
describe("signInController", () => {
|
describe("signInController", () => {
|
||||||
it("returns a cookie on successful sign-in", async () => {
|
it("returns a cookie on successful sign-in", async () => {
|
||||||
@@ -28,6 +29,45 @@ describe("signInController", () => {
|
|||||||
expect(result.value).toBeTruthy();
|
expect(result.value).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("threads the server-derived clientIp into the use case (B2)", async () => {
|
||||||
|
const users = new MockUsersRepository([]);
|
||||||
|
const auth = new MockAuthenticationService(users);
|
||||||
|
const rl = new RecordingRateLimit();
|
||||||
|
await users.createUser(
|
||||||
|
userFactory.build({
|
||||||
|
username: "alice",
|
||||||
|
passwordHash: "hashed_testpassword",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const controller = signInController(signInUseCase(users, auth, rl));
|
||||||
|
await controller(
|
||||||
|
{ username: "alice", password: "testpassword" },
|
||||||
|
{ clientIp: "203.0.113.7" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rl.consumeCalls[0]).toMatchObject({
|
||||||
|
budgetName: "ip",
|
||||||
|
key: "signIn:ip:203.0.113.7",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects clientIp inside the client payload (strict schema, B2)", async () => {
|
||||||
|
const users = new MockUsersRepository([]);
|
||||||
|
const auth = new MockAuthenticationService(users);
|
||||||
|
const controller = signInController(
|
||||||
|
signInUseCase(users, auth, new NoopRateLimit()),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller({
|
||||||
|
username: "alice",
|
||||||
|
password: "testpassword",
|
||||||
|
clientIp: "6.6.6.6",
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(InputParseError);
|
||||||
|
});
|
||||||
|
|
||||||
it("throws InputParseError on invalid input", async () => {
|
it("throws InputParseError on invalid input", async () => {
|
||||||
const users = new MockUsersRepository([]);
|
const users = new MockUsersRepository([]);
|
||||||
const auth = new MockAuthenticationService(users);
|
const auth = new MockAuthenticationService(users);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
signInInputSchema,
|
signInInputSchema,
|
||||||
type ISignInUseCase,
|
type ISignInUseCase,
|
||||||
type SignInOutput,
|
type SignInOutput,
|
||||||
|
type SignInRequestContext,
|
||||||
} from "../../application/use-cases/sign-in.use-case";
|
} from "../../application/use-cases/sign-in.use-case";
|
||||||
|
|
||||||
function presenter(value: SignInOutput) {
|
function presenter(value: SignInOutput) {
|
||||||
@@ -13,11 +14,21 @@ export type ISignInController = ReturnType<typeof signInController>;
|
|||||||
|
|
||||||
export const signInController =
|
export const signInController =
|
||||||
(signInUseCase: ISignInUseCase) =>
|
(signInUseCase: ISignInUseCase) =>
|
||||||
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
|
async (
|
||||||
|
input: unknown,
|
||||||
|
// Server-derived, never part of the client-facing input schema (B2):
|
||||||
|
// the tRPC adapter builds it from trusted proxy headers.
|
||||||
|
requestContext?: SignInRequestContext,
|
||||||
|
): Promise<ReturnType<typeof presenter>> => {
|
||||||
const parsed = signInInputSchema.safeParse(input);
|
const parsed = signInInputSchema.safeParse(input);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
throw new InputParseError("Invalid sign-in input", { cause: parsed.error });
|
throw new InputParseError("Invalid sign-in input", {
|
||||||
|
cause: parsed.error,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const result = await signInUseCase(parsed.data);
|
const result = await signInUseCase({
|
||||||
|
...parsed.data,
|
||||||
|
clientIp: requestContext?.clientIp,
|
||||||
|
});
|
||||||
return presenter(result);
|
return presenter(result);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user