feat(auth): Zod-narrow passkey authentication body at boundary

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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 22:19:57 +02:00
1 parent 58d3f5eaca
commit ef7e1f0321
2 files changed
+182 -3

No files matched your search

+139
View File
@@ -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);
});
});
+43 -3
View File
@@ -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;