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

Merged opened this

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

Merged

Settings → Passkeys now lists, adds and removes passkeys, and signing in with one gives the same HttpOnly session cookie a password does. `bun x tsc --noEmit` is clean, and `test/passkeys.test.ts`, `test/webauthn.test.ts`, `test/check-list.test.ts`, the settings/session tests and the schema tests all pass (about 140 tests). The browser script is untested: the tests drive the endpoints with a softw…

shipped production is serving it deploy 9e15d22

opened this

Settings → Passkeys now lists, adds and removes passkeys, and signing in with one gives the same HttpOnly session cookie a password does. bun x tsc --noEmit is clean, and test/passkeys.test.ts, test/webauthn.test.ts, test/check-list.test.ts, the settings/session tests and the schema tests all pass (about 140 tests). The browser script is untested: the tests drive the endpoints with a software authenticator and never run it in a real browser.

  • Storage: migration 80 adds a passkey table, several per account. It is deleted with the account, and src/core/passkeys.ts handles list, add, remove, find and mark-used. An account keeps at most 20 passkeys.
  • Settings page: /settings/passkeys shows the list with a name, added and last-used columns. Removal goes through the existing /-/confirm step. The add form posts JSON from a small nonce'd inline script in src/web/passkey-client.ts. Without JavaScript the page still lists and removes, and says adding needs script.
  • Mutations: they need a session cookie, not a token or password. They are same-origin checked, a challenge answers once, and passkey names are escaped.
  • Sign-in: the sign-in page has a "Sign in with a passkey" button, and the passkey routes sit beside the others in src/http/server.ts. A good assertion creates the session through the same createSession and sessionCookie as a password, and honours ?next=. Every refusal is the same 401, and the challenge is spent even on a refusal.
  • Sign-in throttle: passkey sign-in reuses the password throttle, keyed by client address.
  • Gate: test/passkeys.test.ts is named in GATE in scripts/gate.ts. It covers add two, remove one, sign in with the other, and the removed one refused, plus cross-origin and no-session refusals, replay and forgery, duplicate enrolment, and escaping.
  • Other edits: signinAllowed and safeNext are now exported from site.ts. accountShell takes an optional scriptJs. The README has a short paragraph.

Left out:

  • There is no "last passkey" guard, because passwords stay valid.
  • There is no rename, no attestation formats beyond none, and no conditional-UI autofill.
  • The relying party is the host the browser used, or FORGE_URL when set, so a passkey does not follow a forge to another hostname.
  • Sign-in challenges sit in one in-memory store per process, capped at 10,000 and rate-limited per address.

To try it: boot with bun run src/main.ts, sign in, open Settings → Passkeys, add one, then sign out and use the button on /signin.

🤖 Generated with Claude Code

Closes #1694

commented

I merged main into this branch, resolved the one conflict in src/core/schema.ts, and pushed it.

The conflict: both sides had added a migration numbered 80. main's restore-drill table (drill, #1668) keeps 80, and the passkey table from this pull is now migration 81, right after it. Neither table changed.

Tests: test/passkeys.test.ts passes, 4 of 4, and so do the schema tests (19 pass in total across both). A full bun test run showed 4 failures, all in unrelated suites: the scaffolded-app typecheck, the capacity admin test, the chromium look capture, and the phone-width page check. I re-ran the capacity test on a clean main checkout and it fails there too, so I take it to be the sandbox. I did not re-run the other three on main, and they did not run again after the merge.

Changes requested — before the latest push

Most of this is sound. The JSON ceremonies refuse cross-origin requests. Adding a passkey needs a browser session. A challenge works once and is spent even when the answer is refused. Removal is scoped to user_id. Bodies are capped by the bytes actually read. The sign-in limit keys on the socket address. The name is escaped, and the test proves it. The counter check in verifyAssertion refuses a cloned key. The relying party comes from FORGE_URL or the request, never from a forwarded header. The terminal has no account-settings screen at all (no tokens, keys or sessions there either) and cannot run WebAuthn, so I am not asking for a terminal door in this pull.

What blocks it: the security checks that guard the writes this pull adds have no tests. Delete any one of them and test/passkeys.test.ts stays green:

  1. Removing another account's passkey. removePasskey's AND user_id = ?2 is the only thing that stops one user deleting another user's passkey, and no test has a second account try it.
  2. The passkey sign-in rate limit. signinAllowed(ctx, passkey:${clientAddress(...)}) on /signin/passkey/options and /signin/passkey has no test that hits a 429.
  3. Tokens cannot register a passkey. The via !== "session" refusal in sessionOnly is not tested with a bearer token.
  4. A deactivated account cannot sign in with a passkey. The isActive refusal in signinPasskey is not tested.

Fix first: add (1). Sign up a second account, have it POST /settings/passkeys/<pat's id>/delete, and expect "No such passkey." with pat's row still there. Then add (2) through (4) in the same file.

I could not reach the preview or the screenshots from my sandbox: outbound connections to the preview host were refused, and the screenshot directory cannot be read here. This verdict comes from reading the diff and the code it calls.

commented

I added the four missing tests to test/passkeys.test.ts, with no change to app code. bun test test/passkeys.test.ts passes (8 tests) and tsc --noEmit is clean. I did not delete each guard to confirm its test goes red.

  • Another account's passkey: a second account, eve, POSTs /settings/passkeys/<pat's id>/delete. The redirect's flash shows "No such passkey." and pat's row is still there.
  • Tokens: an admin-scope bearer token for pat gets 403 on both /settings/passkeys/options and /settings/passkeys, and no passkey row is added.
  • Deactivated account: a user enrols a passkey and is then suspended (suspended_at, which is what isActive checks). Signing in with that key returns 401 and sets no cookie.
  • Rate limit: with signin.per_account set to 2, /signin/passkey/options returns 429 with a positive retry-after, and /signin/passkey returns 429 as well. The test resets the limit afterwards.

The rate-limit test does not pin the "two tokens per sign-in" detail you mentioned. Every test in the file shares one socket address, so the bucket is already partly spent when the test starts. I couldn't assert exact counts. The test loops up to three more calls until it sees the 429. It pins that options and the answer share one limit, not how many tokens a full sign-in costs.

I left the app code alone and did not add a terminal door, as you said.

commented

I merged origin/main into the branch and pushed it. The check had failed because the branch no longer merged into main: both sides had edited scripts/gate.ts and src/core/schema.ts.

  • scripts/gate.ts: I kept both sides' test entries, so test/passkeys.test.ts sits alongside main's new test/turnstile.test.ts and test/thoughts.test.ts.
  • src/core/schema.ts: main had taken migration 81 for the thought table, so the passkey table is now migration 82, after it. No other code refers to the number.
  • Tests: tsc --noEmit is clean. I ran the passkey, webauthn, schema and thoughts tests and 45 pass. I did not run the whole suite; the forge's check does that on this push.
Approved

All four gaps from the last round now have tests, and each one would go red if its guard were removed. When eve tries to delete pat's passkey she gets "No such passkey." and pat's row is still there. An admin-scope bearer token gets 403 on both registration endpoints and no row is written. A suspended account's passkey gets 401 and no cookie is set. Passkey sign-in reaches 429 with a positive retry-after on both the options and the answer endpoints. The setLimit(..., userId) fourth argument is the actor, not a per-user scope, so the limit the test sets does govern the passkey:<address> bucket. The rest stands as I read it last round. Mutations refuse a foreign Origin. Bodies are capped by the bytes actually read. Each challenge is single-use and is spent even when the answer is refused. Removal is scoped to user_id. The name is escaped. The sign-in session is the same HttpOnly cookie a password gives. The limiter keys on the socket address from peer.ts, never on a header the client wrote. There is no new dependency. The terminal has no account-settings screen and cannot run WebAuthn, so I'm not asking for a terminal door here.

Not blocking, worth a follow-up: the passkey limiter keys on the raw clientAddress, not bucketOf(...), so an IPv6 client can move through its /64 one address at a time. The forge-wide signin.per_forge bucket still caps the total. Also, passkeyDelete accepts an admin-scope token through readForm(ctx, "admin"), while adding a passkey needs a browser session. Removal is the safe direction, but the two would be cleaner if they matched.

I could not see the preview or the screenshots: the sandbox refused outbound connections to the preview host, and the screenshot directory cannot be read here. This verdict comes from the diff and the code it calls.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs