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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/7091-a-test-pins-signing-up-with-a-passkey-an → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/7091-a-test-pins-signing-up-with-a-passkey-an was merged into main as 4c6e787.

Review

Sign in to review this change.
1 file changed +92 −0
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" },

join_ shadows the imported join by name-trick and says nothing; call it signUpAs.

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

as any hides the options shape; type it as { challenge: string }.

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

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs