Skip to content

feat(auth): passkey (WebAuthn/FIDO2) dashboard login (#5981)#6129

Merged
houko merged 5 commits into
mainfrom
feat/passkey-webauthn
Jun 17, 2026
Merged

feat(auth): passkey (WebAuthn/FIDO2) dashboard login (#5981)#6129
houko merged 5 commits into
mainfrom
feat/passkey-webauthn

Conversation

@houko

@houko houko commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Closes #5981.

Adds passkeys (WebAuthn/FIDO2) as an additive, opt-in dashboard login method alongside username/password. Sign in with Touch ID, Face ID, Windows Hello, Android biometrics, or a roaming security key — no password typed. Password login is untouched and remains the fallback.

What changed

Backend

  • Storage (librefang-memory) — SQLite migration v44 adds the webauthn_credentials table; PasskeyStore (trait + SqlitePasskeyStore) reuses the substrate pool so credentials survive restarts. The whole serialized webauthn-rs Passkey is stored so the sign-count persists across assertions (a sign-count regression is how a cloned authenticator is detected).
  • Ceremonies (librefang-api::passkey)PasskeyEngine wraps webauthn-rs 0.5, owns the two WebAuthn ceremonies, and keeps short-TTL (5 min) in-memory challenge state correlated by an opaque ceremony_id. Identity binds to the resolved dashboard_user; the WebAuthn user handle is a stable UUIDv5 of the principal name.
  • Routes (librefang-api::routes::passkey) — six endpoints under /api/auth/passkey/*:
    • registration-options / registration-verify — Owner-only (is_owner_only_write), like TOTP enrollment.
    • authentication-options / authentication-verify — public (in the middleware allowlist) and rate-limited as a login brute-force surface. verify mints a session identical to dashboard_login via a shared mint_dashboard_session helper, so middleware/RBAC/logout/Bearer flow are unchanged.
    • GET / DELETE credentials[/{id}] — list (authenticated read) and revoke (Owner-only, principal-scoped).
  • Configpasskey_enabled (default off), passkey_rp_id, passkey_rp_origin. Classified restart-required in build_reload_plan; the ops table (docs/operations/config-reload.md) and the schema-overlay drift guard updated. A bad RP config logs a WARN and disables passkeys (503) rather than aborting boot.

Frontend

  • "Sign in with passkey" button on the login screen (App.tsx) and a Passkeys panel under Settings → Security to register / list / revoke devices, via @simplewebauthn/browser. Query/mutation hooks + hierarchical query keys follow the dashboard data-layer rules. i18n for en / zh / uk.

Open-question decisions

  • TOTP interaction: a successful passkey assertion mints the session directly and bypasses the password-path TOTP challenge — a passkey is already a phishing-resistant second factor. Password login still enforces TOTP when configured.
  • RP-ID/origin: explicit config fields with localhost-friendly fallbacks (id-only → http://<id>; origin-only → derive host). Documented in the architecture doc.
  • Per-credential user_name: yes — each row carries the principal to stay forward-compatible with the multi-user [[users]] path.

New dependencies (maintainer-approved)

  • Rust: webauthn-rs 0.5.5
  • npm: @simplewebauthn/browser ^13.3.0

Verification

  • cargo check --workspace --lib clean; cargo clippy on touched crates clean; cargo fmt --check clean.
  • librefang-memory: 303 lib tests pass (incl. PasskeyStore round-trip/scoping + the migration ladder).
  • librefang-api: 787 lib tests pass. New passkey_routes_integration (6 tests: public vs auth gating, 503-when-disabled, authenticated list) plus dead_route_audit, openapi_path_coverage, config_schema_overlay, auth_public_allowlist, and config_reload drift guards all pass.
  • Dashboard: typecheck, build, eslint (no new warnings), i18n parity (3540 keys/locale), SettingsPage + i18n vitest suites pass.
  • openapi.json regenerated (6 new paths); sha256 baseline updated.

Docs

  • docs/architecture/passkey-webauthn.md — config, flow, TOTP-bypass rationale, identity binding, storage, browser-support matrix.

Not covered (webauthn-rs' responsibility)

The integration tests exercise wiring, gating, the engine gate, and the challenge-issuing path. The attestation/assertion cryptography itself is not re-verified here — it requires a virtual authenticator and is webauthn-rs' own tested surface. A live end-to-end check with a real authenticator (a human-only step per the repo's testing policy) is the remaining manual validation.

Add passkeys as an additive, opt-in dashboard login method alongside username/password. Sign in with Touch ID, Face ID, Windows Hello, Android biometrics, or a roaming security key — no password typed. Password login is untouched and remains the fallback.

Backend:
- SQLite migration v44 adds the `webauthn_credentials` table; `PasskeyStore` (trait + `SqlitePasskeyStore`) in librefang-memory reuses the substrate pool so credentials survive restarts. The whole serialized `webauthn-rs` `Passkey` is stored so the sign-count persists across assertions.
- `PasskeyEngine` in librefang-api wraps `webauthn-rs` 0.5, owns the two WebAuthn ceremonies, and keeps short-TTL in-memory challenge state correlated by an opaque `ceremony_id`.
- Six routes under `/api/auth/passkey/*`: registration-options/verify (Owner-only), authentication-options/verify (public, rate-limited as a login surface), and credential list/revoke. A successful assertion mints a session identical to `dashboard_login` and bypasses the password-path TOTP challenge (a passkey is already a phishing-resistant second factor).
- Config: `passkey_enabled` (default off), `passkey_rp_id`, `passkey_rp_origin`; classified restart-required in the config-reload plan, with the ops table and schema-overlay drift guards updated.

Frontend:
- "Sign in with passkey" button on the login screen and a Passkeys panel under Settings → Security (register / list / revoke), via `@simplewebauthn/browser`. i18n for en / zh / uk.

Verification:
- cargo check --workspace --lib clean; cargo clippy on touched crates clean; cargo fmt clean.
- librefang-memory: 303 lib tests pass (incl. PasskeyStore + migration ladder).
- librefang-api: 787 lib tests pass; passkey_routes_integration (6), dead_route_audit, openapi_path_coverage, config_schema_overlay, auth_public_allowlist, config_reload drift guards all pass.
- dashboard: typecheck, build, eslint (no new warnings), i18n parity (3540 keys/locale), SettingsPage + i18n vitest suites pass.
- openapi.json regenerated; sha256 baseline updated.

Docs: docs/architecture/passkey-webauthn.md (config, flow, TOTP interaction, browser-support matrix).
@github-actions github-actions Bot added size/XL 1000+ lines changed area/docs Documentation and guides area/kernel Core kernel (scheduling, RBAC, workflows) labels Jun 16, 2026
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 16, 2026

Copy link
Copy Markdown

Deploying librefang-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 64be04a
Status: ✅  Deploy successful!
Preview URL: https://eda057de.librefang-docs.pages.dev
Branch Preview URL: https://feat-passkey-webauthn.librefang-docs.pages.dev

View logs

@github-actions github-actions Bot added the ready-for-review PR is ready for maintainer review label Jun 16, 2026
@github-actions github-actions Bot added the area/sdk JavaScript and Python SDKs label Jun 16, 2026
… harness

PasskeyStore::get swallowed all rusqlite errors via .ok(), turning
schema or pool failures into a silent None (credential-not-found). Use
an explicit match to distinguish QueryReturnedNoRows from real errors so
storage faults surface as Err rather than being misidentified as a
missing credential at the route layer.

base_config in the integration test took a &TempDir whose home_dir/data_dir
were immediately overridden in boot(); remove the dead parameter and the
two now-unnecessary local tempdir bindings in boot_enabled and
endpoints_503_when_disabled.

@houko houko left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated code review pass. Two mechanical fixes were pushed directly to this branch:

Fix 1 — PasskeyStore::get now propagates real DB errors

crates/librefang-memory/src/passkey_store.rs

The original implementation used .query_row(...).ok(), which converts every rusqlite::Error — including pool exhaustion, schema mismatches, and I/O failures — into None.
At the route layer that None triggers the unknown_credential 400 branch, so a real storage fault silently looked like "passkey not found" and authentication-verify would return a confusing client error instead of a 500.
Fixed to a three-arm match that passes QueryReturnedNoRows through as None and returns Err(PasskeyStoreError::Sqlite(e)) for everything else, consistent with how list_for_user, update_cred, and delete already propagate errors.

Fix 2 — base_config test helper no longer takes a dead &TempDir argument

crates/librefang-api/tests/passkey_routes_integration.rs

boot() always creates its own tempfile::TempDir and immediately rebinds home_dir/data_dir, so the directory passed to base_config was never used.
Removed the parameter and the now-unnecessary let tmp locals in boot_enabled and endpoints_503_when_disabled.


Everything else checked out:

  • Middleware public-route allowlist: both authentication-* endpoints are in ALWAYS_PUBLIC; registration-* and DELETE …/credentials/{id} are in is_owner_only_write — correct.
  • Rate-limiting: authentication-options and authentication-verify are added to auth_rate_limit_layer alongside dashboard-login — correct.
  • Config fields: all three new fields have #[serde(default)], are in the Default impl, and appear in classified_reload_fields() and build_reload_plan — correct.
  • AppState new fields: passkey_store is Arc<dyn Trait> (not Option); passkey_engine is Option<Arc<…>> but AppState does not derive Serialize/Deserialize/Clone, so no #[serde(skip)] is needed — correct.
  • Dashboard data layer: SettingsPage uses usePasskeys / useRegisterPasskey / useRevokePasskey; loginWithPasskey is called directly in App.tsx which matches the existing pattern for dashboardLogin in that file — correct.
  • Query key factory passkeyKeys is hierarchical (alllists()list()) and mutations invalidate via passkeyKeys.all — correct.
  • PasskeyEngine uses HashMap for ceremony state behind a Mutex; this is not LLM-facing data so the BTreeMap determinism rule does not apply.
  • Migration v44 creates the table and its index, inserts the audit row, and is sequenced correctly in run_migrations.
  • Integration tests cover: public vs auth gating, 503-when-disabled, 400-with-no-passkeys-registered, authenticated-list-is-empty — adequate for what is testable without a virtual authenticator.
  • CHANGELOG prose wraps at sentence boundaries as required.

Generated by Claude Code

@github-actions github-actions Bot added has-conflicts PR has merge conflicts that need resolution and removed ready-for-review PR is ready for maintainer review labels Jun 16, 2026
…ets baseline

The passkey_routes_integration test uses "test-secret-key" as the dummy dashboard api_key (and the harness's OLLAMA_API_KEY env-var name), which the detect-secrets Secret-Keyword heuristic flags as a potential secret — the same false positive already whitelisted for every other integration-test file. Adds the two fixtures to the baseline so the CI scan matches the committed baseline.
@houko
houko enabled auto-merge (squash) June 16, 2026 07:13
@github-actions github-actions Bot added ready-for-review PR is ready for maintainer review and removed has-conflicts PR has merge conflicts that need resolution labels Jun 17, 2026
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying librefang with  Cloudflare Pages  Cloudflare Pages

Latest commit: 64be04a
Status: ✅  Deploy successful!
Preview URL: https://9755cbb4.librefang-7oe.pages.dev
Branch Preview URL: https://feat-passkey-webauthn.librefang-7oe.pages.dev

View logs

@houko
houko merged commit a1db655 into main Jun 17, 2026
42 checks passed
@houko
houko deleted the feat/passkey-webauthn branch June 17, 2026 08:12
GQAdonis pushed a commit to GQAdonis/librefang that referenced this pull request Jun 17, 2026
The merge added 6 /api/auth/passkey/* endpoints (WebAuthn, librefang#6129). Marked them
`covered` — upstream ships crates/librefang-api/tests/passkey_routes_integration.rs
which exercises them. Keeps openapi_paths_are_mapped_to_integration_coverage green.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/docs Documentation and guides area/kernel Core kernel (scheduling, RBAC, workflows) area/sdk JavaScript and Python SDKs ready-for-review PR is ready for maintainer review size/XL 1000+ lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: Passkey (WebAuthn/FIDO2) authentication for web UI

2 participants