From 4d6734448a376f76e4cb5bc21a6ee536a050cdcc Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Fri, 10 Jul 2026 17:26:01 +0200 Subject: [PATCH] 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 --- .../services/authentication.service.test.ts | 161 +++++++++++++++++- 1 file changed, 155 insertions(+), 6 deletions(-) diff --git a/packages/auth/src/infrastructure/services/authentication.service.test.ts b/packages/auth/src/infrastructure/services/authentication.service.test.ts index 3007fc6..a207a69 100644 --- a/packages/auth/src/infrastructure/services/authentication.service.test.ts +++ b/packages/auth/src/infrastructure/services/authentication.service.test.ts @@ -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 { 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 => ({ + 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, 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", () => { const service = new AuthenticationService(stubPayloadConfig); @@ -42,11 +73,16 @@ describe("AuthenticationService", () => { }); }); - describe("session methods (require Payload)", () => { - // createSession and validateSession call getPayload() internally, - // so they require a running Payload instance. These are exercised - // by the mock service in use-case tests and by integration tests. - // Here we only test invalidateSession (no Payload dependency). + describe("session methods", () => { + // getPayload is mocked module-wide (secret + findByID only), so these + // exercise the real signToken/verifyToken/validateSession crypto paths + // without a running Payload instance (audit finding B8). + 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 () => { const { blankCookie } = await service.invalidateSession("any-token"); @@ -56,5 +92,118 @@ describe("AuthenticationService", () => { expect(blankCookie.attributes.httpOnly).toBe(true); 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(); + }); }); });