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

3.5 KiB

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.