diff --git a/src/lib/auth/__tests__/passkey.test.ts b/src/lib/auth/__tests__/passkey.test.ts new file mode 100644 index 000000000..26e08215f --- /dev/null +++ b/src/lib/auth/__tests__/passkey.test.ts @@ -0,0 +1,139 @@ +/** + * v1.4.43 W13 L-3 — Zod-narrowed `verifyAuthentication` boundary. + * + * Before the fix, the route passed a parsed JSON body through a raw + * `as AuthenticationResponseJSON` cast straight into + * `verifyAuthenticationResponse`. The SimpleWebAuthn verifier owned + * shape validation downstream, but a future refactor reading + * `typedResponse.id` BEFORE calling the verifier would crash on + * `undefined.id`. The Zod schema in front of the verifier closes + * that comprehensively — a malformed body now throws here with a + * structured error. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + authChallenge: { + findUnique: vi.fn(), + deleteMany: vi.fn(), + delete: vi.fn(), + create: vi.fn(), + }, + passkey: { + findUnique: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + }, + }, +})); + +vi.mock("@simplewebauthn/server", () => ({ + generateRegistrationOptions: vi.fn(), + verifyRegistrationResponse: vi.fn(), + generateAuthenticationOptions: vi.fn(), + // The verifier should NEVER be invoked on a malformed body — that's + // the whole point of the Zod boundary. + verifyAuthenticationResponse: vi.fn(async () => ({ + verified: true, + authenticationInfo: { newCounter: 1 }, + })), +})); + +import { verifyAuthentication } from "../passkey"; +import { prisma } from "@/lib/db"; +import { verifyAuthenticationResponse } from "@simplewebauthn/server"; + +const VALID_BODY = { + id: "credId123", + rawId: "credId123", + response: { + clientDataJSON: "AAAA", + authenticatorData: "BBBB", + signature: "CCCC", + }, + clientExtensionResults: {}, + type: "public-key", +}; + +beforeEach(() => { + vi.mocked(prisma.authChallenge.findUnique).mockResolvedValue({ + id: "ch-1", + challenge: "challenge", + expiresAt: new Date(Date.now() + 60_000), + } as never); + vi.mocked(prisma.authChallenge.delete).mockResolvedValue({} as never); + vi.mocked(prisma.passkey.findUnique).mockResolvedValue({ + id: "pk-1", + userId: "user-1", + credentialId: "credId123", + credentialPublicKey: new Uint8Array([1, 2, 3]), + counter: BigInt(0), + transports: ["internal"], + } as never); + vi.mocked(prisma.passkey.update).mockResolvedValue({} as never); + vi.mocked(verifyAuthenticationResponse).mockReset(); + vi.mocked(verifyAuthenticationResponse).mockResolvedValue({ + verified: true, + authenticationInfo: { newCounter: 1 }, + } as never); +}); + +describe("verifyAuthentication — Zod boundary (L-3)", () => { + it("accepts a well-formed AuthenticationResponseJSON body", async () => { + const out = await verifyAuthentication("ch-1", VALID_BODY); + expect(out.verification.verified).toBe(true); + expect(out.passkey.userId).toBe("user-1"); + expect(verifyAuthenticationResponse).toHaveBeenCalledTimes(1); + }); + + it("rejects a body missing the `id` field before reaching the verifier", async () => { + const malformed = { ...VALID_BODY, id: undefined }; + await expect(verifyAuthentication("ch-1", malformed)).rejects.toThrow( + /Malformed passkey authentication response/, + ); + expect(verifyAuthenticationResponse).not.toHaveBeenCalled(); + }); + + it("rejects a body with the wrong `type` literal", async () => { + const malformed = { ...VALID_BODY, type: "not-a-public-key" }; + await expect(verifyAuthentication("ch-1", malformed)).rejects.toThrow( + /Malformed passkey authentication response/, + ); + expect(verifyAuthenticationResponse).not.toHaveBeenCalled(); + }); + + it("rejects a body missing the nested `response.clientDataJSON`", async () => { + const malformed = { + ...VALID_BODY, + response: { ...VALID_BODY.response, clientDataJSON: undefined }, + }; + await expect(verifyAuthentication("ch-1", malformed)).rejects.toThrow( + /Malformed passkey authentication response/, + ); + expect(verifyAuthenticationResponse).not.toHaveBeenCalled(); + }); + + it("rejects an empty object as a request body", async () => { + await expect(verifyAuthentication("ch-1", {})).rejects.toThrow( + /Malformed passkey authentication response/, + ); + expect(verifyAuthenticationResponse).not.toHaveBeenCalled(); + }); + + it("rejects a null body", async () => { + await expect(verifyAuthentication("ch-1", null)).rejects.toThrow( + /Malformed passkey authentication response/, + ); + expect(verifyAuthenticationResponse).not.toHaveBeenCalled(); + }); + + it("accepts a body with extra unknown fields (forward-compatibility)", async () => { + // SimpleWebAuthn's interface is permissive about future + // authenticator-attachment / extension-result additions; the Zod + // schema must not reject unknown keys at the top level. + const extended = { ...VALID_BODY, futureField: "ignored" }; + const out = await verifyAuthentication("ch-1", extended); + expect(out.verification.verified).toBe(true); + }); +}); diff --git a/src/lib/auth/passkey.ts b/src/lib/auth/passkey.ts index f1ef7f107..fc8a94748 100644 --- a/src/lib/auth/passkey.ts +++ b/src/lib/auth/passkey.ts @@ -8,8 +8,40 @@ import { type VerifiedRegistrationResponse, type VerifiedAuthenticationResponse, } from "@simplewebauthn/server"; +import { z } from "zod/v4"; import { prisma } from "@/lib/db"; +// v1.4.43 W13 L-3 — explicit Zod narrowing in front of +// `verifyAuthentication`. Replaces the raw +// `as AuthenticationResponseJSON` cast; the SimpleWebAuthn verifier +// still owns the cryptographic validation downstream, but a +// malformed body now fails fast at the boundary with a structured +// error rather than crashing on a follow-up `.id` deref. +// +// Shape mirrors SimpleWebAuthn's `AuthenticationResponseJSON`: +// https://w3c.github.io/webauthn/#dictdef-authenticationresponsejson +// The schema is intentionally permissive (`passthrough` + optionals) +// so future authenticator-attachment / extension-result additions +// don't break the boundary; the strict checks remain inside +// `verifyAuthenticationResponse`. +const authenticationResponseSchema = z + .object({ + id: z.string().min(1), + rawId: z.string().min(1), + response: z + .object({ + clientDataJSON: z.string().min(1), + authenticatorData: z.string().min(1), + signature: z.string().min(1), + userHandle: z.string().optional(), + }) + .loose(), + authenticatorAttachment: z.string().optional(), + clientExtensionResults: z.unknown().optional(), + type: z.literal("public-key"), + }) + .loose(); + type Transport = | "ble" | "cable" @@ -184,9 +216,17 @@ export async function verifyAuthentication( throw new Error("Challenge expired or not found"); } - // The caller passes a parsed JSON body — narrow at the boundary; the - // SimpleWebAuthn verifier below performs full shape validation. - const typedResponse = response as AuthenticationResponseJSON; + // v1.4.43 W13 L-3 — explicit Zod narrowing instead of the previous + // raw `as AuthenticationResponseJSON` cast. A malformed body now + // throws here with a structured error rather than crashing on a + // follow-up `.id` deref. The SimpleWebAuthn verifier downstream + // still owns cryptographic validation; this just closes the + // type-narrowing gap a future refactor could trip on. + const parsed = authenticationResponseSchema.safeParse(response); + if (!parsed.success) { + throw new Error("Malformed passkey authentication response"); + } + const typedResponse = parsed.data as unknown as AuthenticationResponseJSON; // Find the passkey by credential ID const credentialId = typedResponse.id;