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

View file

@ -18,9 +18,16 @@ from fido2.webauthn import (
class WebAuthnService: class WebAuthnService:
"""FIDO2 WebAuthn registration and authentication.""" """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) rp = PublicKeyCredentialRpEntity(name=rp_name, id=rp_id)
self._server = Fido2Server(rp, verify_origin=lambda o: o == origin) self._server = Fido2Server(rp, verify_origin=lambda o: o == origin)
self._user_verification = user_verification
def begin_registration( def begin_registration(
self, self,
@ -39,7 +46,7 @@ class WebAuthnService:
user=user, user=user,
credentials=existing_credentials, credentials=existing_credentials,
resident_key_requirement=ResidentKeyRequirement.REQUIRED, resident_key_requirement=ResidentKeyRequirement.REQUIRED,
user_verification=UserVerificationRequirement.PREFERRED, user_verification=self._user_verification,
) )
return dict(options), state return dict(options), state
@ -64,7 +71,7 @@ class WebAuthnService:
""" """
options, state = self._server.authenticate_begin( options, state = self._server.authenticate_begin(
credentials=credentials, credentials=credentials,
user_verification=UserVerificationRequirement.PREFERRED, user_verification=self._user_verification,
) )
return dict(options), state 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_secret: str | None = None # If None, a random secret is generated per process
session_https_only: bool = True 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 # Magic links
invite_ttl: int = 86400 # seconds invite_ttl: int = 86400 # seconds

View file

@ -17,6 +17,7 @@ from fido2.webauthn import (
PublicKeyCredentialDescriptor, PublicKeyCredentialDescriptor,
PublicKeyCredentialType, PublicKeyCredentialType,
RegistrationResponse, RegistrationResponse,
UserVerificationRequirement,
) )
from porchlight.authn.webauthn import WebAuthnService from porchlight.authn.webauthn import WebAuthnService
@ -194,6 +195,19 @@ def test_begin_authentication_prefers_user_verification() -> None:
assert pub_key["userVerification"] == "preferred" 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: def test_begin_authentication_returns_options_and_state() -> None:
service = _make_service() service = _make_service()
_, cred_id, _attested = _generate_credential() _, cred_id, _attested = _generate_credential()