porchlight/docs/plans/2026-02-16-discoverable-credentials-design.md
2026-04-10 11:28:51 +02:00

88 lines
3.5 KiB
Markdown

# 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.