Discoverable passkey login, replacing the password #115

Open
rosa wants to merge 14 commits from passkey-login into main
Owner

Replaces username + Argon2 password authentication with discoverable (usernameless) WebAuthn passkeys as the sole login factor. Registration collects a username + email (still email-confirmed) and enrols a passkey; login is a one-tap discoverable ceremony that resolves the account from the credential's user handle. A lost passkey is recovered by an email magic-link that re-enrols a new passkey. The password machinery and the account-lockout mechanism (ADR-0011) are removed entirely.

Implements the plan in thoughts/shared/plans/011_discoverable_passkey_login_replacing_password.md.

Phases

  • 1 — WebAuthn foundation: webauthn-rs 0.5, a config-derived PasskeyAuthenticator, the passkey domain model + errors, the passkey_credentials table + a random v4 webauthn_handle on users, and the repo methods with mocks + Postgres conformance tests.
  • 2 — Domain ceremony surface: begin/complete_passkey_registration, discoverable + username-first login, atomic create_user_with_passkey, counter/clone-detection.
  • 3 — Web flows: /register + /login rewritten to the passkey ceremonies (/options + /verify JSON endpoints), assets/passkey.js, a shared session-establishment helper, and the CSRF/rate-limit wiring.
  • 4 — Management + recovery: manage passkeys on /accounts/edit (add/remove, never the last one) and email recovery via a new PasskeyRecovery confirmation action, with a dedicated governor + per-account Displace throttle + neutral flash.
  • 5 — Remove password + lockout: delete the now-dead password/lockout code across every crate, drop the password_hash / failed_login_attempts / locked_until columns, drop argon2, mark ADR-0011 superseded, and add ADR-0014.

Notable decisions

  • User handle is a dedicated random v4 webauthn_handle, not the v7 UserId — the handle is surfaced to authenticators in every assertion, so using the primary key would leak the account-creation timestamp and correlate credentials.
  • Two login paths, one session write: discoverable one-tap (primary) and a username-first allow-list fallback (via residentKey: preferred) for authenticators with no free resident slot. The username-first endpoint answers unknown usernames with a shape-matching decoy challenge (enumeration-safe).
  • Email recovery is now the primary account-takeover surface (lockout is gone), mitigated by a tighter governor, a per-account Displace throttle, and a neutral request-path flash.
  • WebAuthn ceremony orchestration lives in the domain (AppService), since webauthn-rs is a side-effect-free crypto library and the logic is authentication policy.

Testing

mise ci is green (audit, fmtcheck, lint, full suite), plus clippy, format, and sqlcheck. Registration, username-first login, recovery, the enumeration decoy, stale-state rejection, and CSRF are covered end to end by a soft-authenticator (webauthn-authenticator-rs) driving the real HTTP surface.

Manual verification still required (the soft authenticators can't drive the discoverable, empty-allow-list flow): register with a platform authenticator → confirm email → log out → discoverable one-tap login → recovery, and confirm no password field appears anywhere.

Migration notes

Greenfield — no production accounts. Migrations are additive then subtractive: Phase 1 adds passkey_credentials + webauthn_handle; Phase 5 drops the password/lockout columns. .sqlx regenerated.

Replaces username + Argon2 password authentication with **discoverable (usernameless) WebAuthn passkeys** as the sole login factor. Registration collects a username + email (still email-confirmed) and enrols a passkey; login is a one-tap discoverable ceremony that resolves the account from the credential's user handle. A lost passkey is recovered by an email magic-link that re-enrols a new passkey. The password machinery and the account-lockout mechanism (ADR-0011) are removed entirely. Implements the plan in `thoughts/shared/plans/011_discoverable_passkey_login_replacing_password.md`. ## Phases - **1 — WebAuthn foundation:** `webauthn-rs` 0.5, a config-derived `PasskeyAuthenticator`, the passkey domain model + errors, the `passkey_credentials` table + a random v4 `webauthn_handle` on `users`, and the repo methods with mocks + Postgres conformance tests. - **2 — Domain ceremony surface:** `begin/complete_passkey_registration`, discoverable + username-first login, atomic `create_user_with_passkey`, counter/clone-detection. - **3 — Web flows:** `/register` + `/login` rewritten to the passkey ceremonies (`/options` + `/verify` JSON endpoints), `assets/passkey.js`, a shared session-establishment helper, and the CSRF/rate-limit wiring. - **4 — Management + recovery:** manage passkeys on `/accounts/edit` (add/remove, never the last one) and email recovery via a new `PasskeyRecovery` confirmation action, with a dedicated governor + per-account Displace throttle + neutral flash. - **5 — Remove password + lockout:** delete the now-dead password/lockout code across every crate, drop the `password_hash` / `failed_login_attempts` / `locked_until` columns, drop `argon2`, mark ADR-0011 superseded, and add ADR-0014. ## Notable decisions - **User handle is a dedicated random v4 `webauthn_handle`, not the v7 `UserId`** — the handle is surfaced to authenticators in every assertion, so using the primary key would leak the account-creation timestamp and correlate credentials. - **Two login paths, one session write:** discoverable one-tap (primary) and a username-first allow-list fallback (via `residentKey: preferred`) for authenticators with no free resident slot. The username-first endpoint answers unknown usernames with a shape-matching decoy challenge (enumeration-safe). - **Email recovery is now the primary account-takeover surface** (lockout is gone), mitigated by a tighter governor, a per-account Displace throttle, and a neutral request-path flash. - WebAuthn ceremony orchestration lives in the domain (`AppService`), since `webauthn-rs` is a side-effect-free crypto library and the logic *is* authentication policy. ## Testing `mise ci` is green (audit, fmtcheck, lint, full suite), plus `clippy`, `format`, and `sqlcheck`. Registration, username-first login, recovery, the enumeration decoy, stale-state rejection, and CSRF are covered end to end by a soft-authenticator (`webauthn-authenticator-rs`) driving the real HTTP surface. **Manual verification still required** (the soft authenticators can't drive the discoverable, empty-allow-list flow): register with a platform authenticator → confirm email → log out → discoverable one-tap login → recovery, and confirm no password field appears anywhere. ## Migration notes Greenfield — no production accounts. Migrations are additive then subtractive: Phase 1 adds `passkey_credentials` + `webauthn_handle`; Phase 5 drops the password/lockout columns. `.sqlx` regenerated.
Add the additive foundation for discoverable passkey login: the passkey
domain model, a config-derived relying-party wrapper, storage, and the
repository surface. No user-facing behavior changes; password login is
untouched.

- Add webauthn-rs 0.5 (danger-allow-state-serialisation + conditional-ui)
  and a webauthn-authenticator-rs dev-dependency for a soft-token test
  harness.
- models/passkey.rs: WebauthnHandle (random v4, distinct from the v7
  UserId so the handle leaks no creation timestamp), CredentialId,
  PasskeyLabel, StoredPasskey. Add PasskeyError.
- passkey.rs: PasskeyAuthenticator::from_base_url (RP id = host, origin =
  base_url), fallible and unit-tested. AppService::new stays infallible;
  Phase 2 builds the authenticator on demand from the stored base_url.
- Thread webauthn_handle through the User aggregate and every UserRow
  query.
- AppRepository: create/list/find/find-user-by-handle/update/delete/count
  passkey methods, with Postgres impls (Passkey <-> JSONB) and in-memory
  mocks. infra now depends on webauthn-rs for the JSONB (de)serialization.
- Migration adds users.webauthn_handle and the passkey_credentials table.
- Conformance suite drives a real SoftPasskey registration ceremony to
  mint genuine Passkeys across both adapters.
Add the WebAuthn registration and login ceremonies to the domain, plus the
atomic user+passkey creation they build on. Still no web wiring; password
login is untouched.

- Ceremony-state newtypes (PasskeyRegistrationState, DiscoverableLoginState,
  UsernameLoginState) and a PendingPasskeyRegistration session carrier, all
  serde so the web layer can park them without naming a webauthn-rs type.
- AppService ceremonies, authenticator built on demand from base_url:
  begin/complete_passkey_registration (complete does the atomic create +
  email confirmation), begin/complete_passkey_login (discoverable),
  begin/complete_username_login (allow-list fallback), and a persist_assertion
  helper (counter bump + last_used_at).
- create_user_with_passkey repo method: one transaction for user + first
  passkey + confirmation, with unique-violation mapping. Postgres + mock +
  a both-adapters conformance test (atomic create; rollback leaves no passkey).
- Ceremony methods exchange challenge/credential as serde_json::Value so the
  web layer stays webauthn-rs-free. Add PasskeyRegistrationError.

Coverage notes: the soft authenticators can't drive the discoverable
(empty allow-list) flow, so that happy path is verified via the username-first
tests plus a challenge-shape assertion, with the end-to-end discoverable flow
left to Phase 3 browser testing. The username-first enumeration decoy is
deferred to the Phase 3 web layer, where a shape/timing-safe neutral response
is actually achievable.
Wire the passkey ceremonies to the HTTP surface: a user can now sign up and
sign in with a passkey end to end. Password login/register handlers stay wired
(dead code, removed with the test harness in Phase 5) so existing tests are
untouched.

- New fetch/JSON endpoints in a dedicated passkey_routes() group (30/min):
  /register/options+/verify, /login/options+/verify (discoverable),
  /login/username-options+/username-verify (allow-list fallback).
- handlers/passkey.rs: ParkedCeremony (single session key, single-use, 5-min
  freshness independent of the 24h session), establish_session mirroring the
  password login tail (cycle id, user_id, resume pending IndieAuth).
  /register/verify auto-signs-in. Request DTOs live in dtos.rs; CeremonyError
  is a thiserror enum in error.rs rendering JSON bodies.
- GET /register and /login render passkey pages (username+email / one-tap
  button + username-first fallback); passkey.js loaded from the head via
  render_with_head + defer (CSP script-src 'self').
- assets/passkey.js: base64url<->ArrayBuffer marshalling and the three client
  states (no WebAuthn / discoverable-miss reveals the username fallback /
  cancel).
- Enumeration decoy at the web layer: an unknown username returns a shape-
  matching 200 challenge with no parked state (timing residual documented).
- Add AppService::find_user_by_email for the registration courtesy check.

CSRF (#7) verified with a correction: tower-http's CsrfLayer passes missing-
Origin requests (non-browser) and rejects a present untrusted Origin, so no
token is needed; the test asserts that real behavior. Discoverable login stays
a manual browser check; the username-first path gives automated login coverage.

5 soft-authenticator integration tests; full workspace suite green.
Let a signed-in user list, add, and remove passkeys from /accounts/edit.

- Domain: list_passkeys, begin/complete_passkey_addition (add a credential to
  the existing user, reusing their handle and excluding held credentials), and
  delete_passkey with a last-passkey guard (DeletePasskeyError::LastPasskey).
  CredentialId gains url-safe encoding for the delete route.
- Web: /accounts/passkeys/options+/verify (a new AddPasskey parked ceremony
  behind AuthUser) and /accounts/passkeys/{id}/delete. The account page gains a
  Passkeys section (list with add/remove), and passkey.js an add-passkey button.
- Integration tests: a second passkey can be added and removed; the last
  passkey cannot be deleted.
Add a passkey-recovery flow mirroring the password-reset background
pipeline, so an account that lost its device can prove control of its
email and enrol a new passkey. Adds `ConfirmationAction::PasskeyRecovery`
alongside `PasswordReset` (the reset flow stays green until Phase 5).

Domain: initiate/run recovery (enqueue → look up → throttle → mint →
send) plus a token-gated begin/complete ceremony that reuses the
add-passkey path and consumes the confirmation. A per-account resend
throttle (2 min, keyed on an unexpired recovery confirmation) blunts
email-bombing; Displace still caps to one valid link.

Infra: mint + find recovery-confirmation repo methods (Postgres + mock),
two apalis storages/jobs, and the Mailer trait methods. Web: a /recover
request form behind a dedicated 5/min governor, the PasskeyRecovery
branch of /confirm rendering an enrolment page, and /recover/verify.

Covered by domain, infra-conformance, and server integration tests
(mint/throttle/no-op, displace/find, and the full link→enrol→sign-in),
plus SMTP job tests for the two new jobs.
feat(passkey): Phase 5 — remove password + lockout machinery
All checks were successful
ci/woodpecker/push/clippy Pipeline was successful
ci/woodpecker/push/test Pipeline was successful
9656a63c99
Passkeys are now the sole login factor. Delete the dead password and
account-lockout code across every crate and drop the columns that backed
them.

- domain: remove authenticate, hash_password, the Password newtype,
  register_user/reset_password, initiate/run_password_reset, and the lockout
  surface (LockoutPolicy/Outcome, record_failed_login, clear_lockout,
  get_user_password_hash, update_user_password, mint_password_reset_confirmation,
  delete_password_reset_tokens_for_user). Drop AuthenticationError,
  ResetPasswordError, InitiatePasswordResetError, ConfirmationAction::PasswordReset,
  DomainError::Password*, User.failed_login_attempts/locked_until, and the
  argon2 dependency.
- infra: drop the password/lockout repo impls and column references, the
  password-reset mailer/jobs and apalis storages; delete the lockout
  conformance suite; reduce the confirmation conformance suite to recovery.
- web: delete the dead password register/login/reset handlers, routes, forms,
  pages, and the AuthenticationError/ResetPasswordError/InvalidCredentials
  error surface.
- server: remove the password-reset workers; rewrite the integration harness so
  register drives the real WebAuthn ceremony (auto-signs-in) and log_in is a
  compatibility no-op, with a retained-authenticator username_login for the
  IndieAuth return-to-flow test.
- schema/docs: migration drops password_hash/failed_login_attempts/locked_until;
  regenerate .sqlx; mark ADR-0011 superseded; add ADR-0014 (passkeys are the
  sole login factor); retire the lockout/password vocabulary in CONTEXT.md.
webauthn-rs 0.5 hardcodes residentKey: discouraged for the Passkey type,
so authenticators that honor the hint (notably security keys) would enrol
non-discoverable credentials and silently break one-tap login — while
ADR-0014 and CONTEXT.md already claimed preferred. Patch the serialized
creation challenge at the existing serde_json boundary (the key names are
fixed by the WebAuthn spec) and pin the emitted shape with a test.
begin_passkey_recovery does not consume the link, so two sessions could
race the same link to complete and each enrol a passkey. Add
consume_confirmation_by_id (DELETE gated on the row count, mirroring
consume_authorization_code) and make it the first step of
complete_passkey_recovery: exactly one completion wins the consume; the
loser is rejected with ConfirmationInvalid before it can enrol. A
transient failure after the consume burns the link, which is the safer
side of that trade.
fix(passkey): remove enumeration distinguishers from the decoy login challenge
All checks were successful
ci/woodpecker/push/clippy Pipeline was successful
ci/woodpecker/push/test Pipeline was successful
5ccfa68dfc
The unknown-username decoy differed from a real user's challenge in three
observable ways: its allow-list entry had no transports (real entries
carry the browser-reported list), its timeout was 60s where webauthn-rs
emits its 300s default, and its credential id was re-randomized per
request where a real user's id never changes — so repeat-querying a
username trivially revealed whether it exists. The entry now carries a
plausible transports list, the default timeout, and a per-username id
keyed by a process-lifetime random secret (unpredictable offline, stable
across requests; rotates only on restart). The decoy e2e test pins the
shape and the repeat-query stability.
webauthn-rs rejects an assertion whose signature counter did not advance —
a possible cloned authenticator — with CredentialPossibleCompromise, which
complete_passkey_login and complete_username_login both flattened into the
generic Rejected. Map it to the typed PasskeyError::CounterRegression (with
a warn! log, since a suspected clone is a security signal worth more than a
generic rejection). The variant existed but was never constructed; it now
carries meaning. Unit-tests the mapping, since the soft authenticator can't
regress and isn't Clone, so the path can't be driven end to end.
AddPasskeyBody.label was read by post_add_passkey_options, but passkey.js
only ever posts {} and there is no rename surface, so the field was an
untested input path. Remove the DTO, drop the Json body extractor, and
enrol with PasskeyLabel::default() (as recovery already does), parking no
label with the AddPasskey ceremony.
Passwords are gone (ADR-0014), but the test harness still carried them:
register/log_in took dead _password/_username params and two suites kept a
PASSWORD const. Drop the params and consts, update the call sites, and
reword the one lingering source comment — satisfying the Phase 5 grep gate
(no 'password' outside history/ADRs; the SMTP credential is unrelated).
test(passkey): cover stale ceremony state, the username race, and the recovery burst
Some checks failed
ci/woodpecker/push/test Pipeline failed
ci/woodpecker/push/clippy Pipeline was successful
338f8ca7ac
Three plan success criteria had no test. Add: a web-crate unit test of the
5-minute ceremony-freshness boundary (extracted as within_ceremony_ttl,
since the opaque ceremony state can't be built to drive it over HTTP); an
integration test that a second /register/verify for a now-taken username
returns a clean 409, not a 500; and one that a burst of /recover POSTs
trips the dedicated governor with 429. Also simplifies the add_passkey
helper now that the label is gone.
rosa force-pushed passkey-login from 338f8ca7ac
Some checks failed
ci/woodpecker/push/test Pipeline failed
ci/woodpecker/push/clippy Pipeline was successful
to 99dfb05924
All checks were successful
ci/woodpecker/push/clippy Pipeline was successful
ci/woodpecker/push/test Pipeline was successful
2026-07-07 01:04:48 +00:00
Compare
All checks were successful
ci/woodpecker/push/clippy Pipeline was successful
ci/woodpecker/push/test Pipeline was successful
This pull request has changes conflicting with the target branch.
  • Cargo.lock
View command line instructions

Manual merge helper

Use this merge commit message when completing the merge manually.

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin passkey-login:passkey-login
git switch passkey-login

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch main
git merge --no-ff passkey-login
git switch passkey-login
git rebase main
git switch main
git merge --ff-only passkey-login
git switch passkey-login
git rebase main
git switch main
git merge --no-ff passkey-login
git switch main
git merge --squash passkey-login
git switch main
git merge --ff-only passkey-login
git switch main
git merge passkey-login
git push origin main
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
rosa/vernier!115
No description provided.