feat(auth): passwordless WebAuthn / passkey login#172
Conversation
Adds passkey login mirroring the OAuth flow (#167): a passkey is the account, usernameless/discoverable login, issuing the same Bearer session JWT accepted on all authenticated endpoints. - New endpoints POST /api/v1/webauthn/{register,login}/{start,finish} - New account_type 'webauthn' with synthetic identity sha256("webauthn\0{user_handle}") in a namespace provably disjoint from OAuth; NIP-17/npub/LIR gating already covers it - New user_webauthn_credentials table (one account, many devices) - Ceremony state carried through the client in a signed challenge token (HS256, 5-min TTL) so the API stays stateless yet replay-safe - webauthn config section (rp-id/rp-origin/rp-name/session-secret), shares the session secret space with OAuth Fixes #169
Authenticated passkey management so any account (Nostr / OAuth / passkey)
can register additional passkeys to itself and log in with them.
- GET /api/v1/webauthn/credentials list account passkeys
- POST /api/v1/webauthn/credentials/start begin add (excludes existing)
- POST /api/v1/webauthn/credentials/finish store under current account
- DELETE /api/v1/webauthn/credentials/{id} remove (owner-scoped)
Discoverable login already resolves accounts by credential id, so a passkey
added to an existing account logs into that account with its real identity.
Stable per-account user handle; pure passkey accounts cannot delete their
only credential. New db method delete_webauthn_credential(id,user_id).
|
Added passkeys-to-existing-accounts (commit a143b9d). Authenticated management endpoints so any account (Nostr / OAuth / passkey) can add and manage passkeys on itself:
Because discoverable New db method |
The signing secret + token TTL were duplicated on oauth and webauthn with a first-wins init hack. Move them to a single top-level [session] config (session.secret, session.ttl) used by both. OAuth keeps success-redirect; webauthn keeps rp-id/rp-origin/rp-name. Bearer login requires [session] when oauth or webauthn is enabled; otherwise it's Nostr-only.
Implements passwordless WebAuthn / passkey login, mirroring the OAuth architecture from #167. A passkey is the account (standalone), login is usernameless/discoverable, and it issues the same stateless
Bearersession JWT already accepted on every authenticated endpoint.Fixes #169
Endpoints (unauthenticated
fetchJSON)POST /api/v1/webauthn/register/start→{ challenge, state }POST /api/v1/webauthn/register/finish→{ token, token_type, expires_in }(creates account)POST /api/v1/webauthn/login/start→{ challenge, state }POST /api/v1/webauthn/login/finish→{ token, ... }Each
startreturns an opaque signed challenge token (HS256, 5-min TTL) carrying the server-owned ceremony state, so the API stays stateless while remaining replay-safe (satisfies the webauthn-rs "store state server-side" requirement without a session store).Data model
AccountType::Webauthn = 2— synthetic identitysha256("webauthn\0{user_handle}"), in a namespace provably disjoint from OAuth’s{provider}:{subject}(a unit test caught the naivewebauthn:prefix colliding with a provider literally namedwebauthn; the NUL separator makes collision impossible).user_webauthn_credentialstable (uniquecred_id, FK to users) — one account may register several devices.account_type != Nostr;PATCH /accountrejectscontact_nip17;GET /accountreportsaccount_type: "webauthn".Config
New
webauthn:section:rp-id,rp-origin,rp-name,session-secret(+ optionalsession-ttl). Shares the session-token space withoauth— if both are set, use the same secret.Tests
webauthn_pubkeydeterminism/disjointness,AccountTypedisplay, challenge-token round-trip + purpose check,build_webauthnorigin validation, and a full MockDb credential CRUD round-trip. Full-ceremony e2e needs a virtual authenticator (webauthn-authenticator-rs) — noted as an optional follow-up.Docs
API_DOCUMENTATION.md(auth flow +@simplewebauthn/browserexamples + endpoints),config.yaml,API_CHANGELOG.md.Build clean, all tests pass, clippy clean on new files, fmt applied.