Public: anyone can read this; the crew can change it.
A test pins signing up with a passkey another account already holds: refused, and no account made #7097
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7091-a-test-pins-signing-up-with-a-passkey-an → main 2 commits fork 3 threads · 3 unresolved
test/passkey-taken.test.ts added +92
| @@ -0,0 +1,92 @@ | ||
| 1 | /** | |
| 2 | * A passkey belongs to one account. A second sign-up that presents the same | |
| 3 | * credential id is refused, makes no account, and leaves the first alone. | |
| 4 | */ | |
| 5 | ||
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { signUpWithPasskey } from "../src/actions/users.ts"; | |
| 12 | import type { Credential } from "../src/auth/webauthn.ts"; | |
| 13 | import { findPasskey, listPasskeys } from "../src/core/passkeys.ts"; | |
| 14 | import { countUsers, getUserByName } from "../src/core/users.ts"; | |
| 15 | import { PASSKEY_TAKEN } from "../src/web/passkey-words.ts"; | |
| 16 | import { serve } from "../src/http/server.ts"; | |
| 17 | import { setLimit } from "../src/settings/limits.ts"; | |
| 18 | import { device } from "./helpers/passkey.ts"; | |
| 19 | import { fixtureSlot } from "./helpers/slot.ts"; | |
| 20 | ||
| 21 | describe("signing up with a passkey another account holds", () => { | |
| 22 | let dataDir: string; | |
| 23 | let app: App; | |
| 24 | ||
| 25 | beforeAll(async () => { | |
| 26 | dataDir = await mkdtemp(join(tmpdir(), "forge-passkey-taken-")); | |
| 27 | app = createApp({ dataDir, port: 0, passkeyOnlySignup: true }); | |
| 28 | setLimit(app.db, "signup.per_source", 100, "setup"); | |
| 29 | }); | |
| 30 | afterAll(async () => { | |
| 31 | app.close(); | |
| 32 | await rm(dataDir, { recursive: true, force: true }); | |
| 33 | }); | |
| 34 | ||
| 35 | const credential: Credential = { | |
| 36 | id: "cred-shared-0123456789", | |
| 37 | alg: "ES256", | |
| 38 | key: { kty: "EC", crv: "P-256", x: "x-coordinate", y: "y-coordinate" }, | |
| 39 | signCount: 0, | |
| 40 | }; | |
| 41 | const join_ = (username: string) => | |
| 42 | signUpWithPasskey( | |
| 43 | app, | |
| 44 | { username, email: "", passkey: credential, passkeyName: "Laptop" }, | |
| 45 | { slot: fixtureSlot(app) }, | |
| 46 | ); | |
| 47 | ||
| 48 | test("refused with passkey_taken, no second account, first key unchanged", async () => { | |
| 49 | const first = await join_("first-holder"); | |
| 50 | expect(first.ok).toBe(true); | |
| 51 | if (!first.ok) return; | |
| 52 | const users = countUsers(app.db); | |
| 53 | const before = findPasskey(app.db, credential.id); | |
| 54 | ||
| 55 | const second = await join_("second-claimant"); | |
| 56 | expect(second.ok).toBe(false); | |
| 57 | if (second.ok) return; | |
| 58 | expect(second.error).toBe("passkey_taken"); | |
| 59 | ||
| 60 | expect(getUserByName(app.db, "second-claimant")).toBeUndefined(); | |
| 61 | expect(countUsers(app.db)).toBe(users); | |
| 62 | expect(listPasskeys(app.db, first.value.id)).toHaveLength(1); | |
| 63 | expect(findPasskey(app.db, credential.id)).toEqual(before!); | |
outdated
This test cannot fail if the action leaks the holder. It compares a constant to a literal and then checks that the constant lacks a username the constant never saw. test/passkey-words.test.ts and test/claim-link.test.ts:226 already pin the same wording. Fix: delete it. Or, for the real guarantee, post the second sign-up through the page handler in src/web/pages/signup-passkey.ts and assert the response body has the refusal text and not "first-holder". |
||
| 64 | expect(before!.userId).toBe(first.value.id); | |
| 65 | }); | |
| 66 | ||
| 67 | test("the page refuses in plain words, with no cookie, and never names the holder", async () => { | |
| 68 | const servers = await serve(app); | |
| 69 | try { | |
| 70 | const origin = new URL(servers.url).origin; | |
| 71 | const d = await device(origin, new URL(servers.url).hostname); | |
| 72 | const post = (path: string, body: unknown) => | |
| 73 | fetch(`${servers.url}${path}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); | |
|
||
| 74 | const signUp = async (username: string) => { | |
| 75 | const o = (await (await post("/signup/passkey/options", { username, email: "" })).json()) as any; | |
| 76 | return post("/signup/passkey", { username, email: "", challenge: o.challenge, ...(await d.create(o.challenge)) }); | |
| 77 | }; | |
| 78 | expect((await signUp("page-holder")).status).toBe(201); | |
| 79 | const users = countUsers(app.db); | |
| 80 | const again = await signUp("page-claimant"); | |
| 81 | const body = await again.text(); | |
| 82 | expect(again.status).toBe(409); | |
| 83 | expect(again.headers.get("set-cookie")).toBeNull(); | |
| 84 | expect(JSON.parse(body).message).toBe(PASSKEY_TAKEN); | |
| 85 | expect(body).not.toContain("page-holder"); | |
| 86 | expect(countUsers(app.db)).toBe(users); | |
| 87 | expect(getUserByName(app.db, "page-claimant")).toBeUndefined(); | |
| 88 | } finally { | |
| 89 | await servers.stop(); | |
| 90 | } | |
| 91 | }); | |
| 92 | }); |
join_shadows the importedjoinby name-trick and says nothing; call itsignUpAs.