test(auth): cover signToken/verifyToken/validateSession crypto paths

The session methods had zero coverage because they call getPayload()
(audit finding B8). Stub the payload module (secret + findByID only) and
exercise the real crypto paths: round-trip, tampered signature, swapped
payload, expired exp, malformed segments, wrong secret, jti-less token
(fail closed), post-revocation rejection, and the per-instance denylist
limit. No running Payload needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 17:26:01 +02:00
parent cd1c0334af
commit 4d6734448a

View File

@@ -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();
});
}); });
}); });