feat(security): make WebAuthn user verification configurable

User verification was hardcoded to PREFERRED. Add a webauthn_user_verification
setting (default "preferred") wired into WebAuthnService for both registration
and authentication, so identity-provider deployments can require it.

Refs: porchlight-is8

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Johan Lundberg 2026-06-05 13:48:27 +02:00
parent baef5e0e2e
commit 0f04a7daf9
No known key found for this signature in database
GPG key ID: A6C152738D03C7D1
4 changed files with 30 additions and 3 deletions

View file

@ -8,6 +8,7 @@ from urllib.parse import urlparse
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fido2.webauthn import UserVerificationRequirement
from slowapi.errors import RateLimitExceeded
from starlette.middleware.sessions import SessionMiddleware
from starlette.requests import Request
@ -55,6 +56,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
rp_id=rp_id,
rp_name=app.title,
origin=settings.issuer,
user_verification=UserVerificationRequirement(settings.webauthn_user_verification),
)
app.state.magic_link_service = MagicLinkService(

View file

@ -18,9 +18,16 @@ from fido2.webauthn import (
class WebAuthnService:
"""FIDO2 WebAuthn registration and authentication."""
def __init__(self, rp_id: str, rp_name: str, origin: str) -> None:
def __init__(
self,
rp_id: str,
rp_name: str,
origin: str,
user_verification: UserVerificationRequirement = UserVerificationRequirement.PREFERRED,
) -> None:
rp = PublicKeyCredentialRpEntity(name=rp_name, id=rp_id)
self._server = Fido2Server(rp, verify_origin=lambda o: o == origin)
self._user_verification = user_verification
def begin_registration(
self,
@ -39,7 +46,7 @@ class WebAuthnService:
user=user,
credentials=existing_credentials,
resident_key_requirement=ResidentKeyRequirement.REQUIRED,
user_verification=UserVerificationRequirement.PREFERRED,
user_verification=self._user_verification,
)
return dict(options), state
@ -64,7 +71,7 @@ class WebAuthnService:
"""
options, state = self._server.authenticate_begin(
credentials=credentials,
user_verification=UserVerificationRequirement.PREFERRED,
user_verification=self._user_verification,
)
return dict(options), state

View file

@ -49,6 +49,10 @@ class Settings(BaseSettings):
session_secret: str | None = None # If None, a random secret is generated per process
session_https_only: bool = True
# WebAuthn user verification requirement: "preferred" (default), "required",
# or "discouraged". Identity providers may want "required".
webauthn_user_verification: str = "preferred"
# Magic links
invite_ttl: int = 86400 # seconds

View file

@ -17,6 +17,7 @@ from fido2.webauthn import (
PublicKeyCredentialDescriptor,
PublicKeyCredentialType,
RegistrationResponse,
UserVerificationRequirement,
)
from porchlight.authn.webauthn import WebAuthnService
@ -194,6 +195,19 @@ def test_begin_authentication_prefers_user_verification() -> None:
assert pub_key["userVerification"] == "preferred"
def test_user_verification_is_configurable() -> None:
service = WebAuthnService(
rp_id=RP_ID,
rp_name=RP_NAME,
origin=ORIGIN,
user_verification=UserVerificationRequirement.REQUIRED,
)
reg, _ = service.begin_registration(user_id=b"user-123", username="alice")
assert reg["publicKey"]["authenticatorSelection"]["userVerification"] == "required"
auth, _ = service.begin_authentication()
assert auth["publicKey"]["userVerification"] == "required"
def test_begin_authentication_returns_options_and_state() -> None:
service = _make_service()
_, cred_id, _attested = _generate_credential()