# Discoverable Credentials (Usernameless WebAuthn) ## Problem The current WebAuthn login flow is username-first: the user types their username, the server looks up their credential IDs, and sends those as `allowCredentials` to the browser. This requires the user to remember and type their username. Additionally, the current WebAuthn login flow has bugs that make it non-functional: the login button isn't wired up in JS, the JS isn't loaded on the login page, and the server returns HX-Redirect headers but the JS tries to parse JSON. ## Decision Switch to discoverable credentials (passkeys). The browser/OS credential picker handles identity selection — no username needed. Key choices: - **Usernameless only** — remove the username field from WebAuthn login entirely - **User verification: preferred** — ask for PIN/biometric but allow authenticators that only support presence (touch) - **Resident key: required** — credentials must be stored on the authenticator as discoverable credentials - **JSON response** — the WebAuthn complete endpoint returns `{"redirect": "..."}` since the flow uses fetch(), not HTMX ## Registration changes `WebAuthnService.begin_registration()` passes `resident_key_requirement=ResidentKeyRequirement.REQUIRED` and `user_verification=UserVerificationRequirement.PREFERRED` to `Fido2Server.register_begin()`. This tells the authenticator to store a resident credential with the user's ID embedded. No schema changes needed. The existing `webauthn_credentials` table and model already store everything required. ## Authentication changes ### Begin (`POST /login/webauthn/begin`) - No username parameter (change from POST to GET since there's no form body) - Call `begin_authentication(credentials=None, user_verification=PREFERRED)` — empty `allowCredentials` triggers the browser's passkey picker - Store only `webauthn_login_state` in session (no userid — we don't know it yet) ### Complete (`POST /login/webauthn/complete`) - Extract `userHandle` from the assertion response — this contains the `user_id` bytes set during registration (`userid.encode()`) - Decode `userHandle` to get the userid string - Look up the user's stored credentials by userid - Verify the assertion against those credentials - Update sign count - Set session - Return `JSONResponse({"redirect": "/manage/credentials"})` (or the OIDC redirect target if a pending authorization exists) ### Service layer Add `user_verification` parameter to `begin_registration()` and `begin_authentication()` in `WebAuthnService`, passing them through to the fido2 server methods. ## Frontend changes ### `webauthn.js` - `beginAuthentication()` drops the username parameter and FormData body - Change fetch to GET for `/login/webauthn/begin` - On success, read `data.redirect` from JSON response (server now returns JSON) - Wire `#webauthn-login-btn` in the `DOMContentLoaded` handler ### `login.html` - Remove the username field and label from the WebAuthn section - Add `{% block scripts %}` to load `webauthn.js` ## Files changed | File | Change | |------|--------| | `src/porchlight/authn/webauthn.py` | Add `resident_key_requirement` and `user_verification` params | | `src/porchlight/authn/routes.py` | Rewrite begin (no username, GET), fix complete (userHandle lookup, JSON response) | | `src/porchlight/static/webauthn.js` | Drop username, wire login button, fix response handling | | `src/porchlight/templates/login.html` | Remove username field, add scripts block | No database migrations. No model changes. No new repository methods.