Files
HealthLog/tests/integration/webauthn-challenge-claim.test.ts
T
Marc-André Bombeck bf63f6bdba fix(auth): claim a WebAuthn challenge in the statement that checks it
The passkey sign-in, security-key sign-in and security-key registration
verifiers read the challenge, verified the response, and deleted the
challenge afterwards. Two requests carrying the same assertion could both
read it before either deleted it, and both be accepted.

A challenge is now claimed with one guarded DELETE ... RETURNING that also
checks its ceremony, its account and its expiry, before anything is
verified; a concurrent second request finds nothing. A passkey sign-in also
refuses a challenge that was begun for a different account than the passkey
belongs to.
2026-09-26 20:22:12 +02:00

99 lines
3.0 KiB
TypeScript

/**
* A WebAuthn challenge is good for exactly one verification.
*
* The ceremony verifiers used to read the challenge, verify, and delete it
* afterwards, so two requests carrying the same assertion could both read it
* and both pass. `claimAuthChallenge` deletes and returns it in one guarded
* statement; this pins that against real Postgres.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
const { claimAuthChallenge } = await import("@/lib/auth/passkey");
const USER_ID = "user-challenge-claim";
async function challenge(
type: string,
opts: { userId?: string | null; expiresInMs?: number } = {},
): Promise<string> {
const row = await getPrismaClient().authChallenge.create({
data: {
userId: opts.userId === undefined ? USER_ID : opts.userId,
challenge: `material-${type}`,
type,
expiresAt: new Date(Date.now() + (opts.expiresInMs ?? 60_000)),
},
});
return row.id;
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
await getPrismaClient().user.create({
data: {
id: USER_ID,
username: "claim-user",
email: "claim@example.test",
},
});
});
describe("claimAuthChallenge", () => {
it("hands the challenge to exactly one of two concurrent claims", async () => {
const id = await challenge("authentication");
const results = await Promise.all(
Array.from({ length: 8 }, () =>
claimAuthChallenge({ challengeId: id, type: "authentication" }),
),
);
expect(results.filter((r) => r !== null)).toHaveLength(1);
expect(await getPrismaClient().authChallenge.count({ where: { id } })).toBe(
0,
);
});
it("returns the material and the account it was begun for", async () => {
const id = await challenge("mfa_authentication");
await expect(
claimAuthChallenge({
challengeId: id,
type: "mfa_authentication",
userId: USER_ID,
}),
).resolves.toEqual({
challenge: "material-mfa_authentication",
userId: USER_ID,
});
});
it("refuses another ceremony's challenge and leaves it in place", async () => {
const id = await challenge("mfa_authentication");
await expect(
claimAuthChallenge({ challengeId: id, type: "authentication" }),
).resolves.toBeNull();
expect(await getPrismaClient().authChallenge.count({ where: { id } })).toBe(
1,
);
});
it("refuses a challenge begun for another account", async () => {
const id = await challenge("mfa_registration");
await expect(
claimAuthChallenge({
challengeId: id,
type: "mfa_registration",
userId: "someone-else",
}),
).resolves.toBeNull();
});
it("refuses an expired challenge", async () => {
const id = await challenge("authentication", { expiresInMs: -1_000 });
await expect(
claimAuthChallenge({ challengeId: id, type: "authentication" }),
).resolves.toBeNull();
});
});