mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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.
99 lines
3.0 KiB
TypeScript
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();
|
|
});
|
|
});
|