feat(auth): passkey (WebAuthn/FIDO2) dashboard login (#5981)#6129
Conversation
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).
Deploying librefang-docs with
|
| 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 |
… 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
left a comment
There was a problem hiding this comment.
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 inALWAYS_PUBLIC;registration-*andDELETE …/credentials/{id}are inis_owner_only_write— correct. - Rate-limiting:
authentication-optionsandauthentication-verifyare added toauth_rate_limit_layeralongsidedashboard-login— correct. - Config fields: all three new fields have
#[serde(default)], are in theDefaultimpl, and appear inclassified_reload_fields()andbuild_reload_plan— correct. AppStatenew fields:passkey_storeisArc<dyn Trait>(notOption);passkey_engineisOption<Arc<…>>butAppStatedoes not deriveSerialize/Deserialize/Clone, so no#[serde(skip)]is needed — correct.- Dashboard data layer:
SettingsPageusesusePasskeys/useRegisterPasskey/useRevokePasskey;loginWithPasskeyis called directly inApp.tsxwhich matches the existing pattern fordashboardLoginin that file — correct. - Query key factory
passkeyKeysis hierarchical (all→lists()→list()) and mutations invalidate viapasskeyKeys.all— correct. PasskeyEngineusesHashMapfor ceremony state behind aMutex; 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
…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.
Deploying librefang with
|
| 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 |
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.
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
librefang-memory) — SQLite migration v44 adds thewebauthn_credentialstable;PasskeyStore(trait +SqlitePasskeyStore) reuses the substrate pool so credentials survive restarts. The whole serializedwebauthn-rsPasskeyis stored so the sign-count persists across assertions (a sign-count regression is how a cloned authenticator is detected).librefang-api::passkey) —PasskeyEnginewrapswebauthn-rs0.5, owns the two WebAuthn ceremonies, and keeps short-TTL (5 min) in-memory challenge state correlated by an opaqueceremony_id. Identity binds to the resolveddashboard_user; the WebAuthn user handle is a stable UUIDv5 of the principal name.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.verifymints a session identical todashboard_loginvia a sharedmint_dashboard_sessionhelper, so middleware/RBAC/logout/Bearer flow are unchanged.GET/DELETEcredentials[/{id}]— list (authenticated read) and revoke (Owner-only, principal-scoped).passkey_enabled(default off),passkey_rp_id,passkey_rp_origin. Classified restart-required inbuild_reload_plan; the ops table (docs/operations/config-reload.md) and the schema-overlay drift guard updated. A bad RP config logs aWARNand disables passkeys (503) rather than aborting boot.Frontend
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
http://<id>; origin-only → derive host). Documented in the architecture doc.user_name: yes — each row carries the principal to stay forward-compatible with the multi-user[[users]]path.New dependencies (maintainer-approved)
webauthn-rs0.5.5@simplewebauthn/browser^13.3.0Verification
cargo check --workspace --libclean;cargo clippyon touched crates clean;cargo fmt --checkclean.librefang-memory: 303 lib tests pass (incl.PasskeyStoreround-trip/scoping + the migration ladder).librefang-api: 787 lib tests pass. Newpasskey_routes_integration(6 tests: public vs auth gating, 503-when-disabled, authenticated list) plusdead_route_audit,openapi_path_coverage,config_schema_overlay,auth_public_allowlist, andconfig_reloaddrift guards all pass.typecheck,build,eslint(no new warnings), i18n parity (3540 keys/locale),SettingsPage+ i18n vitest suites pass.openapi.jsonregenerated (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.