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