88 lines
3.5 KiB
Markdown
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.
|