From ef7e1f032176293647fa7daff20e36b7b15fb6c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 21 May 2026 22:13:05 +0200 Subject: [PATCH] feat(auth): Zod-narrow passkey authentication body at boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.4.43 W13 L-3 — `verifyAuthentication` parsed the inbound JSON body via a raw `as AuthenticationResponseJSON` cast straight into `verifyAuthenticationResponse`. The SimpleWebAuthn verifier owned cryptographic validation downstream, but the cast bypassed TS's type-narrowing — a future refactor reading `typedResponse.id` before calling the verifier would have crashed on `undefined.id`. Drop the cast, parse via a Zod schema that mirrors SimpleWebAuthn's `AuthenticationResponseJSON` shape (id, rawId, nested response with clientDataJSON / authenticatorData / signature, type literal "public-key"). Permissive on unknown top-level keys for forward-compatibility with future authenticator-attachment / extension-result additions; the strict cryptographic checks remain inside the verifier. A malformed body now throws here with a structured "Malformed passkey authentication response" error before any deref, closing the boundary gap. --- src/lib/auth/__tests__/passkey.test.ts | 139 +++++++++++++++++++++++++ src/lib/auth/passkey.ts | 46 +++++++- 2 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 src/lib/auth/__tests__/passkey.test.ts 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;