diff --git a/src/porchlight/app.py b/src/porchlight/app.py index d399923..38f801f 100644 --- a/src/porchlight/app.py +++ b/src/porchlight/app.py @@ -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( diff --git a/src/porchlight/authn/webauthn.py b/src/porchlight/authn/webauthn.py index 1c9d8ce..5c4c4e2 100644 --- a/src/porchlight/authn/webauthn.py +++ b/src/porchlight/authn/webauthn.py @@ -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 diff --git a/src/porchlight/config.py b/src/porchlight/config.py index dff55a5..a8225d2 100644 --- a/src/porchlight/config.py +++ b/src/porchlight/config.py @@ -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 diff --git a/tests/test_authn/test_webauthn.py b/tests/test_authn/test_webauthn.py index 9c3da22..339d5a5 100644 --- a/tests/test_authn/test_webauthn.py +++ b/tests/test_authn/test_webauthn.py @@ -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()