Skip to content

feat(auth): passwordless WebAuthn / passkey login#172

Merged
v0l merged 3 commits into
masterfrom
feat/webauthn-passkeys
Jul 17, 2026
Merged

feat(auth): passwordless WebAuthn / passkey login#172
v0l merged 3 commits into
masterfrom
feat/webauthn-passkeys

Conversation

@v0l

@v0l v0l commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

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 Bearer session JWT already accepted on every authenticated endpoint.

Fixes #169

Endpoints (unauthenticated fetch JSON)

  • 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 start returns 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 identity sha256("webauthn\0{user_handle}"), in a namespace provably disjoint from OAuth’s {provider}:{subject} (a unit test caught the naive webauthn: prefix colliding with a provider literally named webauthn; the NUL separator makes collision impossible).
  • New user_webauthn_credentials table (unique cred_id, FK to users) — one account may register several devices.
  • Nostr-less accounts reuse existing gating: NIP-17 DMs / npub / LIR signing already blocked for account_type != Nostr; PATCH /account rejects contact_nip17; GET /account reports account_type: "webauthn".

Config

New webauthn: section: rp-id, rp-origin, rp-name, session-secret (+ optional session-ttl). Shares the session-token space with oauth — if both are set, use the same secret.

Tests

webauthn_pubkey determinism/disjointness, AccountType display, challenge-token round-trip + purpose check, build_webauthn origin 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/browser examples + endpoints), config.yaml, API_CHANGELOG.md.

Build clean, all tests pass, clippy clean on new files, fmt applied.

v0l added 2 commits July 17, 2026 14:21
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).
@v0l

v0l commented Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

Added passkeys-to-existing-accounts (commit a143b9d).

Authenticated management endpoints so any account (Nostr / OAuth / passkey) can add and manage passkeys on itself:

  • GET /api/v1/webauthn/credentials — list the account's passkeys
  • POST /api/v1/webauthn/credentials/start — begin adding one (auto-excludes already-registered credentials)
  • POST /api/v1/webauthn/credentials/finish — store it under the current account (returns the credential, no session token since already logged in)
  • DELETE /api/v1/webauthn/credentials/{id} — remove one (owner-scoped)

Because discoverable login/finish already resolves the account by credential id → user_id, a passkey added to an existing account logs straight into that account — the session token then carries the account's real identity (e.g. a Nostr user keeps their npub). Safety: a stable per-account user handle is used, and a pure passkey account cannot delete its only credential (lock-out guard).

New db method delete_webauthn_credential(id, user_id) (trait/mysql/mock). Tests: account_handle stability + owner-scoped delete. Docs updated (API_DOCUMENTATION + changelog). Build/clippy/fmt clean, all tests pass.

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.
@v0l
v0l merged commit ab60e02 into master Jul 17, 2026
8 checks passed
@v0l
v0l deleted the feat/webauthn-passkeys branch July 17, 2026 13:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

WebAuthn

1 participant