Public: anyone can read this; the crew can change it.
Passkeys: Settings → Passkeys lists, adds and removes them, and signing in with one issues the same session as a password #1713
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1694-passkeys-settings-passkeys-lists-adds-an → main 4 commits fork 2 threads · 2 unresolved
12 files
changed +730
−4
README.md +6
| @@ -84,6 +84,12 @@ sees `Reduced protection: …` in one line, under the filter on the terminal's | ||
| 84 | 84 | first screen and in the web footer; nobody else does, and a fully protected |
| 85 | 85 | forge shows nothing. |
| 86 | 86 | |
| 87 | Passkeys: Settings → Passkeys lists, adds and removes them (several per | |
| 88 | account; `src/auth/webauthn.ts` verifies, no dependency, `none` attestation). | |
| 89 | The sign-in page's "Sign in with a passkey" gives the same HttpOnly session a | |
| 90 | password does. The relying party is the address the browser used (or | |
| 91 | `FORGE_URL`), so a passkey does not follow a forge to another hostname. | |
| 92 | ||
| 87 | 93 | Your SSH key is your identity. A key the forge knows signs you straight in; any |
| 88 | 94 | other key browses public repos as a guest and can press `s` to sign in with a |
| 89 | 95 | username and password — which links that key to your account, so the next |
scripts/gate.ts +1
| @@ -36,6 +36,7 @@ export const GATE: readonly string[] = [ | ||
| 36 | 36 | "test/boards-query.test.ts", |
| 37 | 37 | "test/turnstile.test.ts", |
| 38 | 38 | "test/thoughts.test.ts", |
| 39 | "test/passkeys.test.ts", | |
| 39 | 40 | "test/webauthn.test.ts", |
| 40 | 41 | "test/chaos.test.ts", |
| 41 | 42 | "test/flow-stage.test.ts", |
src/core/passkeys.ts added +132
| @@ -0,0 +1,132 @@ | ||
| 1 | /** | |
| 2 | * Passkeys an account has registered: the public half only, several per | |
| 3 | * account. Verification lives in `auth/webauthn.ts`; this is what is kept. | |
| 4 | */ | |
| 5 | ||
| 6 | import type { Database } from "bun:sqlite"; | |
| 7 | import type { Alg, Credential, Jwk } from "../auth/webauthn.ts"; | |
| 8 | import { id as newId } from "../kernel/id.ts"; | |
| 9 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 10 | ||
| 11 | /** More than this is a script, not a person's devices. */ | |
| 12 | export const PASSKEY_MAX = 20; | |
| 13 | export const PASSKEY_NAME_MAX = 60; | |
| 14 | ||
| 15 | export interface PasskeyInfo { | |
| 16 | readonly id: string; | |
| 17 | readonly name: string; | |
| 18 | readonly createdAt: string; | |
| 19 | readonly lastUsedAt: string | null; | |
| 20 | } | |
| 21 | ||
| 22 | interface Row { | |
| 23 | id: string; | |
| 24 | user_id: string; | |
| 25 | credential_id: string; | |
| 26 | name: string; | |
| 27 | alg: Alg; | |
| 28 | key: string; | |
| 29 | sign_count: number; | |
| 30 | created_at: string; | |
| 31 | last_used_at: string | null; | |
| 32 | } | |
| 33 | ||
| 34 | export function listPasskeys(db: Database, userId: string): PasskeyInfo[] { | |
| 35 | return db | |
| 36 | .query<Row, [string]>( | |
| 37 | "SELECT * FROM passkey WHERE user_id = ?1 ORDER BY created_at, id", | |
| 38 | ) | |
| 39 | .all(userId) | |
| 40 | .map((r) => ({ | |
| 41 | id: r.id, | |
| 42 | name: r.name, | |
| 43 | createdAt: r.created_at, | |
| 44 | lastUsedAt: r.last_used_at, | |
| 45 | })); | |
| 46 | } | |
| 47 | ||
| 48 | /** The credential ids of an account, so a device is not enrolled twice. */ | |
| 49 | export function passkeyIds(db: Database, userId: string): string[] { | |
| 50 | return db | |
| 51 | .query<{ credential_id: string }, [string]>( | |
| 52 | "SELECT credential_id FROM passkey WHERE user_id = ?1", | |
| 53 | ) | |
| 54 | .all(userId) | |
| 55 | .map((r) => r.credential_id); | |
| 56 | } | |
| 57 | ||
| 58 | export function addPasskey( | |
| 59 | db: Database, | |
| 60 | userId: string, | |
| 61 | name: string, | |
| 62 | credential: Credential, | |
| 63 | ): Result<string, "too_many" | "duplicate"> { | |
| 64 | const count = db | |
| 65 | .query<{ n: number }, [string]>( | |
| 66 | "SELECT count(*) AS n FROM passkey WHERE user_id = ?1", | |
| 67 | ) | |
| 68 | .get(userId)!.n; | |
| 69 | if (count >= PASSKEY_MAX) return err("too_many"); | |
| 70 | const id = newId(); | |
| 71 | try { | |
| 72 | db.query( | |
| 73 | `INSERT INTO passkey (id, user_id, credential_id, name, alg, key, sign_count) | |
| 74 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`, | |
| 75 | ).run( | |
| 76 | id, | |
| 77 | userId, | |
| 78 | credential.id, | |
| 79 | name.slice(0, PASSKEY_NAME_MAX), | |
| 80 | credential.alg, | |
| 81 | JSON.stringify(credential.key), | |
| 82 | credential.signCount, | |
| 83 | ); | |
| 84 | } catch { | |
| 85 | return err("duplicate"); | |
| 86 | } | |
| 87 | return ok(id); | |
| 88 | } | |
| 89 | ||
| 90 | /** Remove one of the account's own passkeys. False when it is not theirs. */ | |
| 91 | export function removePasskey( | |
| 92 | db: Database, | |
| 93 | userId: string, | |
| 94 | passkeyId: string, | |
| 95 | ): boolean { | |
| 96 | return ( | |
| 97 | db | |
| 98 | .query("DELETE FROM passkey WHERE id = ?1 AND user_id = ?2") | |
| 99 | .run(passkeyId, userId).changes > 0 | |
| 100 | ); | |
| 101 | } | |
| 102 | ||
| 103 | /** The account and verification material behind a credential id. */ | |
| 104 | export function findPasskey( | |
| 105 | db: Database, | |
| 106 | credentialId: string, | |
| 107 | ): { id: string; userId: string; credential: Credential } | undefined { | |
| 108 | const r = db | |
| 109 | .query<Row, [string]>("SELECT * FROM passkey WHERE credential_id = ?1") | |
| 110 | .get(credentialId); | |
| 111 | if (!r) return undefined; | |
| 112 | return { | |
| 113 | id: r.id, | |
| 114 | userId: r.user_id, | |
| 115 | credential: { | |
| 116 | id: r.credential_id, | |
| 117 | alg: r.alg, | |
| 118 | key: JSON.parse(r.key) as Jwk, | |
| 119 | signCount: r.sign_count, | |
| 120 | }, | |
| 121 | }; | |
| 122 | } | |
| 123 | ||
| 124 | export function notePasskeyUsed( | |
| 125 | db: Database, | |
| 126 | passkeyId: string, | |
| 127 | signCount: number, | |
| 128 | ): void { | |
| 129 | db.query( | |
| 130 | "UPDATE passkey SET sign_count = ?2, last_used_at = ?3 WHERE id = ?1", | |
| 131 | ).run(passkeyId, signCount, new Date().toISOString()); | |
| 132 | } |
src/core/schema.ts +17
| @@ -1688,6 +1688,23 @@ export const migrations: readonly string[] = [ | ||
| 1688 | 1688 | CREATE TRIGGER thought_no_delete BEFORE DELETE ON thought |
| 1689 | 1689 | BEGIN SELECT RAISE(ABORT, 'a thought is never deleted'); END; |
| 1690 | 1690 | `, |
| 1691 | // 82 — passkeys (#1694): several per account. `credential_id` is the | |
| 1692 | // base64url id the authenticator names itself by; `key` is the public JWK, | |
| 1693 | // `sign_count` the last counter seen (a clone shows as one that stalls). | |
| 1694 | ` | |
| 1695 | CREATE TABLE passkey ( | |
| 1696 | id TEXT PRIMARY KEY, | |
| 1697 | user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, | |
| 1698 | credential_id TEXT NOT NULL UNIQUE, | |
| 1699 | name TEXT NOT NULL, | |
| 1700 | alg TEXT NOT NULL, | |
| 1701 | key TEXT NOT NULL, | |
| 1702 | sign_count INTEGER NOT NULL DEFAULT 0, | |
| 1703 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 1704 | last_used_at TEXT | |
| 1705 | ); | |
| 1706 | CREATE INDEX passkey_user ON passkey(user_id); | |
| 1707 | `, | |
| 1691 | 1708 | ]; |
| 1692 | 1709 | |
| 1693 | 1710 | /** |
src/http/server.ts +14
| @@ -89,6 +89,14 @@ import { | ||
| 89 | 89 | verifyPage, |
| 90 | 90 | themeSubmit, |
| 91 | 91 | } from "../web/pages/site.ts"; |
| 92 | import { | |
| 93 | passkeyAdd, | |
| 94 | passkeyDelete, | |
| 95 | passkeyOptions, | |
| 96 | passkeysPage, | |
| 97 | signinOptions, | |
| 98 | signinPasskey, | |
| 99 | } from "../web/pages/passkeys.ts"; | |
| 92 | 100 | import { confirmPage, searchPage } from "../web/pages/search.ts"; |
| 93 | 101 | import { followingSubmit } from "../web/pages/following.ts"; |
| 94 | 102 | import { reconnectSubmit } from "../web/pages/frozen.ts"; |
| @@ -655,6 +663,12 @@ export function buildRouter(): Router<Handler> { | ||
| 655 | 663 | router.add("POST", "/settings/tokens", tokenCreate); |
| 656 | 664 | router.add("POST", "/settings/tokens/:id/delete", tokenDelete); |
| 657 | 665 | router.add("POST", "/settings/sessions/:id/delete", sessionDelete); |
| 666 | router.add("GET", "/settings/passkeys", passkeysPage); | |
| 667 | router.add("POST", "/settings/passkeys/options", passkeyOptions); | |
| 668 | router.add("POST", "/settings/passkeys", passkeyAdd); | |
| 669 | router.add("POST", "/settings/passkeys/:id/delete", passkeyDelete); | |
| 670 | router.add("POST", "/signin/passkey/options", signinOptions); | |
| 671 | router.add("POST", "/signin/passkey", signinPasskey); | |
| 658 | 672 | // The crew: a forge admin's section, hidden from everyone else. |
| 659 | 673 | router.add("GET", "/settings/subscriptions", subscriptionsPage); |
| 660 | 674 | router.add("POST", "/settings/subscriptions", subscriptionAdd); |
src/web/pages/account.ts +3 −1
| @@ -54,7 +54,7 @@ import { | ||
| 54 | 54 | } from "../ui.ts"; |
| 55 | 55 | import { everydayLine } from "./everyday.ts"; |
| 56 | 56 | |
| 57 | type Section = "profile" | "password" | "tokens" | "keys"; | |
| 57 | type Section = "profile" | "password" | "tokens" | "keys" | "passkeys"; | |
| 58 | 58 | |
| 59 | 59 | // The section list lives beside the newest page that joins it, so adding one |
| 60 | 60 | // cannot leave the others pointing at a nav that has forgotten it. |
| @@ -68,11 +68,13 @@ export function accountShell( | ||
| 68 | 68 | title: string, |
| 69 | 69 | body: Html, |
| 70 | 70 | status = 200, |
| 71 | scriptJs?: string, | |
| 71 | 72 | ): Response { |
| 72 | 73 | return page( |
| 73 | 74 | { |
| 74 | 75 | ctx, |
| 75 | 76 | title, |
| 77 | ...(scriptJs === undefined ? {} : { scriptJs }), | |
| 76 | 78 | body: html`<div class="page-head"><h1>${title}</h1></div> |
| 77 | 79 | ${settingsLayout(sections(ctx, current), body, "Account settings")}`, |
| 78 | 80 | }, |
src/web/pages/origins.ts +7
| @@ -73,6 +73,7 @@ export type AccountSection = | ||
| 73 | 73 | | "password" |
| 74 | 74 | | "tokens" |
| 75 | 75 | | "keys" |
| 76 | | "passkeys" | |
| 76 | 77 | | "origins" |
| 77 | 78 | | "crew" |
| 78 | 79 | | "accounts" |
| @@ -170,6 +171,12 @@ export function accountSections( | ||
| 170 | 171 | icon: "lock", |
| 171 | 172 | current: current === "keys", |
| 172 | 173 | }, |
| 174 | { | |
| 175 | label: "Passkeys", | |
| 176 | href: "/settings/passkeys", | |
| 177 | icon: "key", | |
| 178 | current: current === "passkeys", | |
| 179 | }, | |
| 173 | 180 | { |
| 174 | 181 | label: "Origins", |
| 175 | 182 | href: "/settings/origins", |
src/web/pages/passkeys.ts added +198
| @@ -0,0 +1,198 @@ | ||
| 1 | /** | |
| 2 | * Passkeys: Settings → Passkeys lists, adds and removes them, and the | |
| 3 | * sign-in page's passkey button gives the same session a password does. | |
| 4 | * The ceremonies are JSON (a browser's `navigator.credentials` cannot post a | |
| 5 | * form); the verification is `auth/webauthn.ts`. Every mutation is | |
| 6 | * same-origin checked, and a challenge answers once. | |
| 7 | */ | |
| 8 | ||
| 9 | import { challenges, verifyAssertion, verifyRegistration } from "../../auth/webauthn.ts"; | |
| 10 | import type { App } from "../../app.ts"; | |
| 11 | import { | |
| 12 | addPasskey, | |
| 13 | findPasskey, | |
| 14 | listPasskeys, | |
| 15 | notePasskeyUsed, | |
| 16 | passkeyIds, | |
| 17 | removePasskey, | |
| 18 | PASSKEY_NAME_MAX, | |
| 19 | } from "../../core/passkeys.ts"; | |
| 20 | import { createSession } from "../../core/sessions.ts"; | |
| 21 | import { getUserById, isActive } from "../../core/users.ts"; | |
| 22 | import { crossOrigin, type Ctx } from "../../http/context.ts"; | |
| 23 | import { clientAddress } from "../../http/peer.ts"; | |
| 24 | import { STRANGER_MAX } from "../../http/limits.ts"; | |
| 25 | import { boundedBody, json, str } from "../../http/respond.ts"; | |
| 26 | import { signInFirst } from "../chrome.ts"; | |
| 27 | import { readForm } from "../form.ts"; | |
| 28 | import { html } from "../html.ts"; | |
| 29 | import { flashTo } from "../layout.ts"; | |
| 30 | import { passkeyJs } from "../passkey-client.ts"; | |
| 31 | import { card, empty, confirmAction, time } from "../ui.ts"; | |
| 32 | import { accountShell } from "./account.ts"; | |
| 33 | import { safeNext, sessionCookie, signinAllowed } from "./site.ts"; | |
| 34 | ||
| 35 | /** One-time challenges, one store per ceremony per forge. */ | |
| 36 | const stores = new WeakMap<App, { add: ReturnType<typeof challenges>; signin: ReturnType<typeof challenges> }>(); | |
| 37 | function store(app: App) { | |
| 38 | let s = stores.get(app); | |
| 39 | if (!s) stores.set(app, (s = { add: challenges(), signin: challenges() })); | |
| 40 | return s; | |
| 41 | } | |
| 42 | ||
| 43 | /** What the browser must see as the relying party: the forge's public address. */ | |
| 44 | function relying(ctx: Ctx) { | |
| 45 | return { origin: ctx.url.origin, rpId: ctx.url.hostname }; | |
| 46 | } | |
| 47 | ||
| 48 | const refuse = (status: number, message: string) => | |
| 49 | json({ error: "refused", message }, status); | |
| 50 | ||
| 51 | /** A signed-in browser, on a same-origin request; else the refusal to send. */ | |
| 52 | function sessionOnly(ctx: Ctx): Response | undefined { | |
| 53 | if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return refuse(403, "forbidden"); | |
| 54 | if (!ctx.auth.user) return refuse(401, "Sign in first."); | |
| 55 | if (ctx.auth.via !== "session") return refuse(403, "Passkeys are changed from a signed-in browser."); | |
| 56 | return undefined; | |
| 57 | } | |
| 58 | ||
| 59 | // GET /settings/passkeys | |
| 60 | export function passkeysPage(ctx: Ctx): Response { | |
| 61 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 62 | const keys = listPasskeys(ctx.app.db, ctx.auth.user.id); | |
| 63 | const body = html`<p class="muted small mb"> | |
| 64 | A passkey signs you in with a fingerprint, face or device PIN instead of | |
| 65 | a password. Add one per device; each can be removed on its own. | |
| 66 | </p> | |
| 67 | ${keys.length === 0 | |
| 68 | ? empty({ icon: "key", title: "No passkeys yet", note: "Add one to sign in without typing a password." }) | |
| 69 | : card( | |
| 70 | html`<div class="table-scroll"> | |
| 71 | <table class="table" id="passkeys"> | |
| 72 | <thead><tr><th>Name</th><th>Added</th><th>Last used</th><th></th></tr></thead> | |
| 73 | <tbody> | |
| 74 | ${keys.map( | |
| 75 | (k) => html`<tr> | |
| 76 | <td><strong>${k.name}</strong></td> | |
| 77 | <td class="muted">${time(k.createdAt)}</td> | |
| 78 | <td class="muted">${k.lastUsedAt ? time(k.lastUsedAt) : "never"}</td> | |
| 79 | <td>${confirmAction({ action: `/settings/passkeys/${k.id}/delete`, verb: "Remove", size: "sm" })}</td> | |
| 80 | </tr>`, | |
| 81 | )} | |
| 82 | </tbody> | |
| 83 | </table> | |
| 84 | </div>`, | |
| 85 | { pad: false }, | |
| 86 | )} | |
| 87 | ${card( | |
| 88 | html`<div class="field"> | |
| 89 | <label for="passkey-name">Name</label> | |
| 90 | <input id="passkey-name" name="name" maxlength="${PASSKEY_NAME_MAX}" autocomplete="off" placeholder="Laptop, phone, security key" /> | |
| 91 | </div> | |
| 92 | <p class="error-note" id="passkey-add-note" role="status"></p> | |
| 93 | <noscript><p class="muted small">Adding a passkey needs JavaScript.</p></noscript> | |
| 94 | <div class="form-actions"> | |
| 95 | <button type="button" id="passkey-add" class="btn" data-variant="primary">Add a passkey</button> | |
| 96 | </div>`, | |
| 97 | { head: html`<h3>New passkey</h3>` }, | |
| 98 | )}`; | |
| 99 | return accountShell(ctx, "passkeys", "Passkeys", body, 200, passkeyJs); | |
| 100 | } | |
| 101 | ||
| 102 | // POST /settings/passkeys/options — a challenge for a new passkey. | |
| 103 | export function passkeyOptions(ctx: Ctx): Response { | |
| 104 | const denied = sessionOnly(ctx); | |
| 105 | if (denied) return denied; | |
| 106 | const user = ctx.auth.user!; | |
| 107 | return json({ | |
| 108 | challenge: store(ctx.app).add.issue(), | |
| 109 | rpId: relying(ctx).rpId, | |
| 110 | rpName: "forge", | |
| 111 | user: { | |
| 112 | id: Buffer.from(user.id).toString("base64url"), | |
| 113 | name: user.username, | |
| 114 | displayName: user.displayName || user.username, | |
| 115 | }, | |
| 116 | exclude: passkeyIds(ctx.app.db, user.id), | |
| 117 | }); | |
| 118 | } | |
| 119 | ||
| 120 | // POST /settings/passkeys — verify the browser's answer and keep the key. | |
| 121 | export async function passkeyAdd(ctx: Ctx): Promise<Response> { | |
| 122 | const denied = sessionOnly(ctx); | |
| 123 | if (denied) return denied; | |
| 124 | const data = await boundedBody(ctx.request, STRANGER_MAX); | |
| 125 | if (data === "too_large") return refuse(413, "payload too large"); | |
| 126 | const challenge = str(data.challenge); | |
| 127 | if (!store(ctx.app).add.consume(challenge)) | |
| 128 | return refuse(400, "That request expired. Try again."); | |
| 129 | const made = await verifyRegistration( | |
| 130 | { clientDataJSON: str(data.clientDataJSON), attestationObject: str(data.attestationObject) }, | |
| 131 | { challenge, ...relying(ctx) }, | |
| 132 | ); | |
| 133 | if (!made.ok) return refuse(400, "That passkey was not accepted."); | |
| 134 | const name = str(data.name).replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, PASSKEY_NAME_MAX) || "Passkey"; | |
| 135 | const added = addPasskey(ctx.app.db, ctx.auth.user!.id, name, made); | |
| 136 | if (!added.ok) | |
| 137 | return refuse( | |
| 138 | 409, | |
| 139 | added.error === "too_many" ? "That is as many passkeys as an account keeps." : "That passkey is already registered.", | |
| 140 | ); | |
| 141 | return json({ ok: true }); | |
| 142 | } | |
| 143 | ||
| 144 | // POST /settings/passkeys/:id/delete | |
| 145 | export async function passkeyDelete(ctx: Ctx): Promise<Response> { | |
| 146 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 147 | const form = await readForm(ctx, "admin"); | |
| 148 | if (form instanceof Response) return form; | |
| 149 | const gone = removePasskey(ctx.app.db, ctx.auth.user.id, ctx.params.id ?? ""); | |
| 150 | return flashTo( | |
| 151 | ctx, | |
| 152 | "/settings/passkeys", | |
| 153 | gone ? { message: "Passkey removed." } : { tone: "danger", message: "No such passkey." }, | |
| 154 | ); | |
| 155 | } | |
| 156 | ||
| 157 | // POST /signin/passkey/options — a challenge for someone with no session yet. | |
| 158 | export function signinOptions(ctx: Ctx): Response { | |
| 159 | if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return refuse(403, "forbidden"); | |
| 160 | const wait = signinAllowed(ctx, `passkey:${clientAddress(ctx.request)}`); | |
No test covers this limit. Lower |
||
| 161 | if (wait !== undefined) | |
| 162 | return json({ error: "rate_limited", message: `Too many attempts. Try again in ${wait}s.` }, 429, { "retry-after": String(wait) }); | |
| 163 | return json({ challenge: store(ctx.app).signin.issue(), rpId: relying(ctx).rpId }); | |
| 164 | } | |
| 165 | ||
| 166 | // POST /signin/passkey — a good assertion gets the session a password gets. | |
| 167 | export async function signinPasskey(ctx: Ctx): Promise<Response> { | |
| 168 | if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return refuse(403, "forbidden"); | |
| 169 | const wait = signinAllowed(ctx, `passkey:${clientAddress(ctx.request)}`); | |
| 170 | if (wait !== undefined) | |
| 171 | return json({ error: "rate_limited", message: `Too many attempts. Try again in ${wait}s.` }, 429, { "retry-after": String(wait) }); | |
| 172 | const data = await boundedBody(ctx.request, STRANGER_MAX); | |
| 173 | if (data === "too_large") return refuse(413, "payload too large"); | |
| 174 | const challenge = str(data.challenge); | |
| 175 | const no = () => refuse(401, "That passkey was not accepted."); | |
| 176 | if (!store(ctx.app).signin.consume(challenge)) return no(); | |
| 177 | const found = findPasskey(ctx.app.db, str(data.id)); | |
| 178 | if (!found) return no(); | |
| 179 | const user = getUserById(ctx.app.db, found.userId); | |
| 180 | if (!user || !isActive(user)) return no(); | |
| 181 | const verified = await verifyAssertion( | |
| 182 | { | |
| 183 | clientDataJSON: str(data.clientDataJSON), | |
| 184 | authenticatorData: str(data.authenticatorData), | |
| 185 | signature: str(data.signature), | |
| 186 | }, | |
| 187 | { challenge, ...relying(ctx) }, | |
| 188 | found.credential, | |
| 189 | ); | |
| 190 | if (!verified.ok) return no(); | |
| 191 | notePasskeyUsed(ctx.app.db, found.id, verified.signCount); | |
| 192 | const session = createSession(ctx.app.db, user.id); | |
| 193 | return json( | |
| 194 | { ok: true, next: safeNext(ctx.url.searchParams.get("next")) ?? "/" }, | |
| 195 | 200, | |
| 196 | { "set-cookie": sessionCookie(session.token, session.expiresAt, ctx.url, ctx.app.config.url) }, | |
| 197 | ); | |
| 198 | } |
src/web/pages/search.ts +9
| @@ -536,6 +536,15 @@ const DESTRUCTIVE: { | ||
| 536 | 536 | phrase: "", |
| 537 | 537 | }), |
| 538 | 538 | }, |
| 539 | { | |
| 540 | pattern: /^\/settings\/passkeys\/([^/]+)\/delete$/, | |
| 541 | describe: () => ({ | |
| 542 | verb: "Remove passkey", | |
| 543 | title: "Remove this passkey?", | |
| 544 | detail: "The device holding it can no longer sign in as you.", | |
| 545 | phrase: "", | |
| 546 | }), | |
| 547 | }, | |
| 539 | 548 | { |
| 540 | 549 | pattern: /^\/([^/]+)\/-\/teams\/([^/]+)\/delete$/, |
| 541 | 550 | describe: (m) => ({ |
src/web/pages/site.ts +16 −3
| @@ -40,6 +40,7 @@ import { sshCommand } from "../../ssh/command.ts"; | ||
| 40 | 40 | import { hostOf, pageHead, signInFirst } from "../chrome.ts"; |
| 41 | 41 | import { RateLimiter } from "../../core/ratelimit.ts"; |
| 42 | 42 | import { field, readForm } from "../form.ts"; |
| 43 | import { passkeyJs } from "../passkey-client.ts"; | |
| 43 | 44 | import { html, when, type Html } from "../html.ts"; |
| 44 | 45 | import { icon } from "../icons.ts"; |
| 45 | 46 | import { flashTo, page, redirect } from "../layout.ts"; |
| @@ -77,7 +78,7 @@ export function sessionCookie( | ||
| 77 | 78 | * protocol-relative `//evil.example` is a redirect off-site wearing a path's |
| 78 | 79 | * clothes, so it is refused with everything else that isn't a plain path. |
| 79 | 80 | */ |
| 80 | function safeNext(raw: string | null): string | undefined { | |
| 81 | export function safeNext(raw: string | null): string | undefined { | |
| 81 | 82 | if (raw === null || raw === "") return undefined; |
| 82 | 83 | if (!raw.startsWith("/") || raw.startsWith("//")) return undefined; |
| 83 | 84 | if (raw.includes("\\") || /[\n\r]/.test(raw)) return undefined; |
| @@ -460,6 +461,13 @@ function authForm( | ||
| 460 | 461 | return html`<div class="narrow"> |
| 461 | 462 | ${pageHead(kind === "signin" ? "Sign in" : "Create your account")} |
| 462 | 463 | ${card(body)} |
| 464 | ${when( | |
| 465 | kind === "signin", | |
| 466 | () => html`<p class="mt"> | |
| 467 | <button type="button" id="passkey-signin" class="btn" data-variant="outline">Sign in with a passkey</button> | |
| 468 | <span class="error-note small" id="passkey-signin-note" role="status"></span> | |
| 469 | </p>`, | |
| 470 | )} | |
| 463 | 471 | ${claimable |
| 464 | 472 | ? html`<p class="muted small mt" id="claim-door"> |
| 465 | 473 | Was this forge made for you? <a href="/claim">Claim it with your code</a>. |
| @@ -469,7 +477,12 @@ function authForm( | ||
| 469 | 477 | } |
| 470 | 478 | |
| 471 | 479 | export function signinPage(ctx: Ctx): Response { |
| 472 | return page({ ctx, title: "Sign in", body: authForm(ctx, "signin") }); | |
| 480 | return page({ | |
| 481 | ctx, | |
| 482 | title: "Sign in", | |
| 483 | body: authForm(ctx, "signin"), | |
| 484 | scriptJs: passkeyJs, | |
| 485 | }); | |
| 473 | 486 | } |
| 474 | 487 | |
| 475 | 488 | /** The widget's public site key, when signup is gated on Turnstile. */ |
| @@ -499,7 +512,7 @@ export function signupPage(ctx: Ctx): Response { | ||
| 499 | 512 | */ |
| 500 | 513 | const signinLimits = new WeakMap<Ctx["app"], RateLimiter>(); |
| 501 | 514 | |
| 502 | function signinAllowed(ctx: Ctx, username: string): number | undefined { | |
| 515 | export function signinAllowed(ctx: Ctx, username: string): number | undefined { | |
| 503 | 516 | let limiter = signinLimits.get(ctx.app); |
| 504 | 517 | if (limiter === undefined) { |
| 505 | 518 | limiter = new RateLimiter({ perMinute: 0, adminPerMinute: 0, enabled: true }); |
src/web/passkey-client.ts added +94
| @@ -0,0 +1,94 @@ | ||
| 1 | /** | |
| 2 | * The browser half of passkeys: two small ceremonies, inlined (nonce'd) on | |
| 3 | * the two pages that need them. A page without script still works; it just | |
| 4 | * has no passkey button. Binary travels as base64url, as the server expects. | |
| 5 | */ | |
| 6 | export const passkeyJs = /* js */ ` | |
| 7 | (function () { | |
| 8 | var doc = document; | |
| 9 | function bytes(s) { | |
| 10 | s = s.replace(/-/g, '+').replace(/_/g, '/'); | |
| 11 | while (s.length % 4) s += '='; | |
| 12 | var raw = atob(s), out = new Uint8Array(raw.length); | |
| 13 | for (var i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); | |
| 14 | return out; | |
| 15 | } | |
| 16 | function text(buf) { | |
| 17 | var a = new Uint8Array(buf), s = ''; | |
| 18 | for (var i = 0; i < a.length; i++) s += String.fromCharCode(a[i]); | |
| 19 | return btoa(s).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, ''); | |
| 20 | } | |
| 21 | function post(url, data) { | |
| 22 | return fetch(url, { | |
| 23 | method: 'POST', | |
| 24 | credentials: 'same-origin', | |
| 25 | headers: { 'content-type': 'application/json' }, | |
| 26 | body: JSON.stringify(data || {}) | |
| 27 | }).then(function (r) { | |
| 28 | return r.json().catch(function () { return {}; }).then(function (j) { | |
| 29 | if (!r.ok) throw new Error(j.message || 'That did not work.'); | |
| 30 | return j; | |
| 31 | }); | |
| 32 | }); | |
| 33 | } | |
| 34 | function wire(id, run) { | |
| 35 | var el = doc.getElementById(id); | |
| 36 | if (!el) return; | |
| 37 | var note = doc.getElementById(id + '-note'); | |
| 38 | if (!window.PublicKeyCredential || !navigator.credentials) { | |
| 39 | el.disabled = true; | |
| 40 | if (note) note.textContent = 'This browser has no passkey support.'; | |
| 41 | return; | |
| 42 | } | |
| 43 | el.addEventListener('click', function (e) { | |
| 44 | e.preventDefault(); | |
| 45 | el.disabled = true; | |
| 46 | if (note) note.textContent = ''; | |
| 47 | run().catch(function (err) { | |
| 48 | if (note) note.textContent = err && err.name === 'NotAllowedError' ? 'Cancelled.' : (err && err.message) || 'That did not work.'; | |
| 49 | el.disabled = false; | |
| 50 | }); | |
| 51 | }); | |
| 52 | } | |
| 53 | wire('passkey-add', function () { | |
| 54 | var name = doc.getElementById('passkey-name'); | |
| 55 | return post('/settings/passkeys/options').then(function (o) { | |
| 56 | return navigator.credentials.create({ publicKey: { | |
| 57 | challenge: bytes(o.challenge), | |
| 58 | rp: { id: o.rpId, name: o.rpName }, | |
| 59 | user: { id: bytes(o.user.id), name: o.user.name, displayName: o.user.displayName }, | |
| 60 | pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }], | |
| 61 | excludeCredentials: o.exclude.map(function (c) { return { type: 'public-key', id: bytes(c) }; }), | |
| 62 | authenticatorSelection: { residentKey: 'preferred', userVerification: 'required' }, | |
| 63 | attestation: 'none', | |
| 64 | timeout: 120000 | |
| 65 | } }).then(function (cred) { | |
| 66 | return post('/settings/passkeys', { | |
| 67 | name: name ? name.value : '', | |
| 68 | challenge: o.challenge, | |
| 69 | clientDataJSON: text(cred.response.clientDataJSON), | |
| 70 | attestationObject: text(cred.response.attestationObject) | |
| 71 | }); | |
| 72 | }); | |
| 73 | }).then(function () { location.reload(); }); | |
| 74 | }); | |
| 75 | wire('passkey-signin', function () { | |
| 76 | return post('/signin/passkey/options').then(function (o) { | |
| 77 | return navigator.credentials.get({ publicKey: { | |
| 78 | challenge: bytes(o.challenge), | |
| 79 | rpId: o.rpId, | |
| 80 | userVerification: 'required', | |
| 81 | timeout: 120000 | |
| 82 | } }).then(function (cred) { | |
| 83 | return post('/signin/passkey' + location.search, { | |
| 84 | challenge: o.challenge, | |
| 85 | id: text(cred.rawId), | |
| 86 | clientDataJSON: text(cred.response.clientDataJSON), | |
| 87 | authenticatorData: text(cred.response.authenticatorData), | |
| 88 | signature: text(cred.response.signature) | |
| 89 | }); | |
| 90 | }); | |
| 91 | }).then(function (r) { location.href = r.next; }); | |
| 92 | }); | |
| 93 | })(); | |
| 94 | `; |
test/passkeys.test.ts added +233
| @@ -0,0 +1,233 @@ | ||
| 1 | /** | |
| 2 | * Passkeys end to end: add two, remove one, sign in with the other (same | |
| 3 | * HttpOnly session cookie as a password), and the removed one is refused. | |
| 4 | * The software authenticator signs like a browser's. | |
| 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 { b64u } from "../src/auth/webauthn.ts"; | |
| 12 | import { createToken } from "../src/core/tokens.ts"; | |
| 13 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 14 | import { setLimit } from "../src/settings/limits.ts"; | |
| 15 | ||
| 16 | const te = new TextEncoder(); | |
| 17 | const sha = async (d: Uint8Array<ArrayBuffer>) => new Uint8Array(await crypto.subtle.digest("SHA-256", d)); | |
| 18 | const cat = (...p: Uint8Array[]): Uint8Array<ArrayBuffer> => { const o: Uint8Array<ArrayBuffer> = new Uint8Array(p.reduce((n, a) => n + a.length, 0)); let i = 0; for (const a of p) { o.set(a, i); i += a.length; } return o; }; | |
| 19 | function head(major: number, n: number): Uint8Array { | |
| 20 | if (n < 24) return Uint8Array.of((major << 5) | n); | |
| 21 | if (n < 256) return Uint8Array.of((major << 5) | 24, n); | |
| 22 | return Uint8Array.of((major << 5) | 25, n >> 8, n & 255); | |
| 23 | } | |
| 24 | type V = number | string | Uint8Array | [V, V][]; | |
| 25 | function enc(v: V): Uint8Array { | |
| 26 | if (typeof v === "number") return v >= 0 ? head(0, v) : head(1, -1 - v); | |
| 27 | if (typeof v === "string") { const b = te.encode(v); return cat(head(3, b.length), b); } | |
| 28 | if (v instanceof Uint8Array) return cat(head(2, v.length), v); | |
| 29 | return cat(head(5, v.length), ...v.flatMap(([k, x]) => [enc(k), enc(x)])); | |
| 30 | } | |
| 31 | function rawToDer(raw: Uint8Array): Uint8Array<ArrayBuffer> { | |
| 32 | const int = (b: Uint8Array) => { let i = 0; while (i < b.length - 1 && b[i] === 0) i++; b = b.subarray(i); if (b[0]! & 0x80) b = cat(Uint8Array.of(0), b); return cat(Uint8Array.of(2, b.length), b); }; | |
| 33 | const body = cat(int(raw.subarray(0, 32)), int(raw.subarray(32))); | |
| 34 | return cat(Uint8Array.of(0x30, body.length), body); | |
| 35 | } | |
| 36 | ||
| 37 | async function device(origin: string, rp: string) { | |
| 38 | const pair = (await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign"])) as CryptoKeyPair; | |
| 39 | const jwk = await crypto.subtle.exportKey("jwk", pair.publicKey); | |
| 40 | const unb = (s: string) => new Uint8Array(Buffer.from(s, "base64url")); | |
| 41 | const cose: [number, V][] = [[1, 2], [3, -7], [-1, 1], [-2, unb(jwk.x!)], [-3, unb(jwk.y!)]]; | |
| 42 | const id = crypto.getRandomValues(new Uint8Array(32)); | |
| 43 | let count = 0; | |
| 44 | const cd = (type: string, challenge: string) => te.encode(JSON.stringify({ type, challenge, origin })); | |
| 45 | const counter = (n: number) => Uint8Array.of(n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255); | |
| 46 | return { | |
| 47 | credId: b64u(id), | |
| 48 | async create(challenge: string) { | |
| 49 | const authData = cat(await sha(te.encode(rp)), Uint8Array.of(0x45), counter(0), new Uint8Array(16), Uint8Array.of(0, id.length), id, enc(cose as V)); | |
| 50 | return { clientDataJSON: b64u(cd("webauthn.create", challenge)), attestationObject: b64u(enc([["fmt", "none"], ["attStmt", []], ["authData", authData]])) }; | |
| 51 | }, | |
| 52 | async get(challenge: string) { | |
| 53 | const clientData = cd("webauthn.get", challenge); | |
| 54 | const authData = cat(await sha(te.encode(rp)), Uint8Array.of(0x05), counter(++count)); | |
| 55 | const sig = rawToDer(new Uint8Array(await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, pair.privateKey, cat(authData, await sha(clientData))))); | |
| 56 | return { id: b64u(id), clientDataJSON: b64u(clientData), authenticatorData: b64u(authData), signature: b64u(sig) }; | |
| 57 | }, | |
| 58 | }; | |
| 59 | } | |
| 60 | ||
| 61 | describe("passkeys", () => { | |
| 62 | let dataDir: string; | |
| 63 | let app: App; | |
| 64 | let servers: Servers; | |
| 65 | let base: string; | |
| 66 | let cookie = ""; | |
| 67 | ||
| 68 | const post = (path: string, body: unknown, headers: Record<string, string> = {}) => | |
| 69 | fetch(`${base}${path}`, { method: "POST", headers: { "content-type": "application/json", ...headers }, body: JSON.stringify(body), redirect: "manual" }); | |
| 70 | const authed = (path: string, body: unknown = {}) => post(path, body, { cookie }); | |
| 71 | ||
| 72 | async function enroll(name: string) { | |
| 73 | const d = await device(new URL(base).origin, new URL(base).hostname); | |
| 74 | const opts = (await (await authed("/settings/passkeys/options")).json()) as any; | |
| 75 | const res = await authed("/settings/passkeys", { name, challenge: opts.challenge, ...(await d.create(opts.challenge)) }); | |
| 76 | return { d, res }; | |
| 77 | } | |
| 78 | async function signIn(d: Awaited<ReturnType<typeof device>>, qs = "") { | |
| 79 | const o = (await (await post("/signin/passkey/options", {})).json()) as any; | |
| 80 | return post(`/signin/passkey${qs}`, { challenge: o.challenge, ...(await d.get(o.challenge)) }); | |
| 81 | } | |
| 82 | ||
| 83 | beforeAll(async () => { | |
| 84 | dataDir = await mkdtemp(join(tmpdir(), "forge-passkeys-")); | |
| 85 | app = createApp({ dataDir, port: 0 }); | |
| 86 | servers = await serve(app); | |
| 87 | base = servers.url; | |
| 88 | const res = await fetch(`${base}/signup`, { | |
| 89 | method: "POST", | |
| 90 | body: new URLSearchParams({ username: "pat", email: "[email protected]", password: "passkey-password" }), | |
| 91 | redirect: "manual", | |
| 92 | }); | |
| 93 | cookie = (res.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 94 | }); | |
| 95 | ||
| 96 | afterAll(async () => { | |
| 97 | servers.stop(); | |
| 98 | app.close(); | |
| 99 | await rm(dataDir, { recursive: true, force: true }); | |
| 100 | }); | |
| 101 | ||
| 102 | test("add two, remove one, sign in with the other; the removed one is refused", async () => { | |
| 103 | const one = await enroll("Laptop"); | |
| 104 | const two = await enroll("Phone"); | |
| 105 | expect(one.res.status).toBe(200); | |
| 106 | expect(two.res.status).toBe(200); | |
| 107 | ||
| 108 | let page = await (await fetch(`${base}/settings/passkeys`, { headers: { cookie } })).text(); | |
| 109 | expect(page).toContain("Laptop"); | |
| 110 | expect(page).toContain("<strong>Phone</strong>"); | |
| 111 | ||
| 112 | const id = app.db.query<{ id: string }, []>("SELECT id FROM passkey WHERE name = 'Laptop'").get()!.id; | |
| 113 | const removed = await fetch(`${base}/settings/passkeys/${id}/delete`, { method: "POST", headers: { cookie }, redirect: "manual" }); | |
| 114 | expect(removed.status).toBe(303); | |
| 115 | page = await (await fetch(`${base}/settings/passkeys`, { headers: { cookie } })).text(); | |
| 116 | expect(page).not.toContain("<strong>Laptop</strong>"); | |
| 117 | expect(page).toContain("<strong>Phone</strong>"); | |
| 118 | ||
| 119 | const refused = await signIn(one.d); | |
| 120 | expect(refused.status).toBe(401); | |
| 121 | expect(refused.headers.get("set-cookie")).toBeNull(); | |
| 122 | ||
| 123 | const good = await signIn(two.d, "?next=/settings"); | |
| 124 | expect(good.status).toBe(200); | |
| 125 | expect(await good.json()).toEqual({ ok: true, next: "/settings" }); | |
| 126 | const set = good.headers.get("set-cookie")!; | |
| 127 | expect(set).toContain("forge_session="); | |
| 128 | expect(set).toContain("HttpOnly"); | |
| 129 | const me = await fetch(`${base}/settings/passkeys`, { headers: { cookie: set.split(";")[0]! } }); | |
| 130 | expect(me.status).toBe(200); | |
| 131 | expect(await me.text()).toContain("Phone"); | |
| 132 | }); | |
| 133 | ||
| 134 | test("a challenge answers once, and a bad signature or a stranger's key signs nobody in", async () => { | |
| 135 | const d = await device(new URL(base).origin, new URL(base).hostname); | |
| 136 | const o = (await (await post("/signin/passkey/options", {})).json()) as any; | |
| 137 | const unknown = await post("/signin/passkey", { challenge: o.challenge, ...(await d.get(o.challenge)) }); | |
| 138 | expect(unknown.status).toBe(401); | |
| 139 | // The challenge is spent even by a refusal. | |
| 140 | const e = await enroll("Key"); | |
| 141 | const spent = await post("/signin/passkey", { challenge: o.challenge, ...(await e.d.get(o.challenge)) }); | |
| 142 | expect(spent.status).toBe(401); | |
| 143 | const o2 = (await (await post("/signin/passkey/options", {})).json()) as any; | |
| 144 | const forged = { challenge: o2.challenge, ...(await e.d.get(o2.challenge)) }; | |
| 145 | const bad = await post("/signin/passkey", { ...forged, signature: b64u(new Uint8Array(70)) }); | |
| 146 | expect(bad.status).toBe(401); | |
| 147 | }); | |
| 148 | ||
| 149 | test("registration needs a session and a same-origin request, and a challenge is single-use", async () => { | |
| 150 | expect((await post("/settings/passkeys/options", {})).status).toBe(401); | |
| 151 | const cross = await post("/settings/passkeys/options", {}, { cookie, origin: "https://evil.example" }); | |
| 152 | expect(cross.status).toBe(403); | |
| 153 | const crossSignin = await post("/signin/passkey/options", {}, { origin: "https://evil.example" }); | |
| 154 | expect(crossSignin.status).toBe(403); | |
| 155 | ||
| 156 | const d = await device(new URL(base).origin, new URL(base).hostname); | |
| 157 | const opts = (await (await authed("/settings/passkeys/options")).json()) as any; | |
| 158 | const answer = { name: "x", challenge: opts.challenge, ...(await d.create(opts.challenge)) }; | |
| 159 | expect((await authed("/settings/passkeys", answer)).status).toBe(200); | |
| 160 | expect((await authed("/settings/passkeys", answer)).status).toBe(400); | |
| 161 | // Enrolling the same device twice is also refused, with a fresh challenge. | |
| 162 | const again = (await (await authed("/settings/passkeys/options")).json()) as any; | |
| 163 | expect(again.exclude).toContain(d.credId); | |
| 164 | const dup = await authed("/settings/passkeys", { name: "x", challenge: again.challenge, ...(await d.create(again.challenge)) }); | |
| 165 | expect(dup.status).toBe(409); | |
| 166 | }); | |
| 167 | ||
| 168 | test("a name is escaped on the page, and the sign-in page offers a passkey", async () => { | |
| 169 | await enroll("<script>alert(1)</script>"); | |
| 170 | const page = await (await fetch(`${base}/settings/passkeys`, { headers: { cookie } })).text(); | |
| 171 | expect(page).not.toContain("<script>alert(1)</script>"); | |
| 172 | expect(page).toContain("<script>alert(1)"); | |
| 173 | expect(await (await fetch(`${base}/signin`)).text()).toContain("passkey-signin"); | |
| 174 | }); | |
| 175 | ||
| 176 | const signup = async (username: string) => { | |
| 177 | const res = await fetch(`${base}/signup`, { | |
| 178 | method: "POST", | |
| 179 | body: new URLSearchParams({ username, email: `${username}@f.dev`, password: "passkey-password" }), | |
| 180 | redirect: "manual", | |
| 181 | }); | |
| 182 | return (res.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 183 | }; | |
| 184 | const userId = (name: string) => app.db.query<{ id: string }, [string]>("SELECT id FROM user WHERE username = ?1").get(name)!.id; | |
| 185 | ||
| 186 | test("another account cannot remove a passkey that is not theirs", async () => { | |
| 187 | await enroll("Pat's key"); | |
| 188 | const id = app.db.query<{ id: string }, [string]>("SELECT id FROM passkey WHERE user_id = ?1 AND name = 'Pat''s key'").get(userId("pat"))!.id; | |
| 189 | const eve = await signup("eve"); | |
| 190 | const res = await fetch(`${base}/settings/passkeys/${id}/delete`, { method: "POST", headers: { cookie: eve }, redirect: "manual" }); | |
| 191 | expect(res.status).toBe(303); | |
| 192 | const carried = (res.headers.getSetCookie?.() ?? []).map((c) => c.split(";")[0]).join("; "); | |
| 193 | const flash = await (await fetch(`${base}/settings/passkeys`, { headers: { cookie: `${eve}; ${carried}` } })).text(); | |
| 194 | expect(flash).toContain("No such passkey."); | |
| 195 | expect(app.db.query("SELECT 1 FROM passkey WHERE id = ?1").get(id)).not.toBeNull(); | |
| 196 | }); | |
| 197 | ||
| 198 | test("a token cannot register a passkey", async () => { | |
| 199 | const { token } = createToken(app.db, userId("pat"), "ci", "admin"); | |
| 200 | const headers = { authorization: `Bearer ${token}` }; | |
| 201 | const before = app.db.query<{ n: number }, []>("SELECT count(*) AS n FROM passkey").get()!.n; | |
| 202 | const opts = await post("/settings/passkeys/options", {}, headers); | |
| 203 | expect(opts.status).toBe(403); | |
| 204 | const d = await device(new URL(base).origin, new URL(base).hostname); | |
| 205 | const add = await post("/settings/passkeys", { name: "t", challenge: "x", ...(await d.create("x")) }, headers); | |
| 206 | expect(add.status).toBe(403); | |
| 207 | expect(app.db.query<{ n: number }, []>("SELECT count(*) AS n FROM passkey").get()!.n).toBe(before); | |
| 208 | }); | |
| 209 | ||
| 210 | test("a suspended account cannot sign in with its passkey", async () => { | |
| 211 | const dan = await signup("dan"); | |
| 212 | const d = await device(new URL(base).origin, new URL(base).hostname); | |
| 213 | const opts = (await post("/settings/passkeys/options", {}, { cookie: dan }).then((r) => r.json())) as any; | |
| 214 | expect((await post("/settings/passkeys", { name: "d", challenge: opts.challenge, ...(await d.create(opts.challenge)) }, { cookie: dan })).status).toBe(200); | |
| 215 | app.db.query("UPDATE user SET suspended_at = ?1 WHERE username = 'dan'").run(new Date().toISOString()); | |
| 216 | const res = await signIn(d); | |
| 217 | expect(res.status).toBe(401); | |
| 218 | expect(res.headers.get("set-cookie")).toBeNull(); | |
| 219 | }); | |
| 220 | ||
| 221 | test("passkey sign-in is rate limited, options and answer share one bucket", async () => { | |
| 222 | setLimit(app.db, "signin.per_account", 2, userId("pat")); | |
| 223 | // Earlier sign-ins from this address already spent some of the minute. | |
| 224 | let third = await post("/signin/passkey/options", {}); | |
| 225 | for (let i = 0; i < 3 && third.status === 200; i++) third = await post("/signin/passkey/options", {}); | |
| 226 | expect(third.status).toBe(429); | |
| 227 | expect(Number(third.headers.get("retry-after"))).toBeGreaterThan(0); | |
| 228 | const answer = await post("/signin/passkey", { challenge: "x" }); | |
| 229 | expect(answer.status).toBe(429); | |
| 230 | expect(Number(answer.headers.get("retry-after"))).toBeGreaterThan(0); | |
| 231 | setLimit(app.db, "signin.per_account", null, userId("pat")); | |
| 232 | }); | |
| 233 | }); |
new above
offline
This user_id scoping is the authorization check on this write, and no test covers it. Add one where a second account posts to
/settings/passkeys/<someone else's id>/deleteand gets "No such passkey.", with the row still present.