Files
HealthLog/src/lib/auth/__tests__/passkey.test.ts
T
Marc-André Bombeck ef7e1f0321 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.
2026-05-21 22:19:57 +02:00

140 lines
4.8 KiB
TypeScript

/**
* 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);
});
});