From 2ffe968342a2f357570964f1b4db4fc0b8df05f2 Mon Sep 17 00:00:00 2001 From: Johan Lundberg Date: Tue, 17 Feb 2026 13:18:46 +0100 Subject: [PATCH] feat: require discoverable credentials and prefer user verification in WebAuthnService --- src/porchlight/authn/webauthn.py | 9 +++++++- tests/test_authn/test_webauthn.py | 36 +++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/porchlight/authn/webauthn.py b/src/porchlight/authn/webauthn.py index fe37447..1c9d8ce 100644 --- a/src/porchlight/authn/webauthn.py +++ b/src/porchlight/authn/webauthn.py @@ -10,6 +10,8 @@ from fido2.webauthn import ( PublicKeyCredentialRpEntity, PublicKeyCredentialUserEntity, RegistrationResponse, + ResidentKeyRequirement, + UserVerificationRequirement, ) @@ -36,6 +38,8 @@ class WebAuthnService: options, state = self._server.register_begin( user=user, credentials=existing_credentials, + resident_key_requirement=ResidentKeyRequirement.REQUIRED, + user_verification=UserVerificationRequirement.PREFERRED, ) return dict(options), state @@ -58,7 +62,10 @@ class WebAuthnService: Returns (options_dict, state_dict). """ - options, state = self._server.authenticate_begin(credentials=credentials) + options, state = self._server.authenticate_begin( + credentials=credentials, + user_verification=UserVerificationRequirement.PREFERRED, + ) return dict(options), state def complete_authentication( diff --git a/tests/test_authn/test_webauthn.py b/tests/test_authn/test_webauthn.py index a53dfef..207264a 100644 --- a/tests/test_authn/test_webauthn.py +++ b/tests/test_authn/test_webauthn.py @@ -134,6 +134,23 @@ def test_complete_registration_returns_credential_data() -> None: assert result.credential_data.credential_id == credential_id +def test_begin_registration_requires_resident_key() -> None: + service = _make_service() + options, _state = service.begin_registration(user_id=b"user-123", username="alice") + pub_key = options["publicKey"] + auth_sel = pub_key["authenticatorSelection"] + assert auth_sel["residentKey"] == "required" + assert auth_sel["requireResidentKey"] is True + + +def test_begin_registration_prefers_user_verification() -> None: + service = _make_service() + options, _state = service.begin_registration(user_id=b"user-123", username="alice") + pub_key = options["publicKey"] + auth_sel = pub_key["authenticatorSelection"] + assert auth_sel["userVerification"] == "preferred" + + def test_begin_registration_with_existing_credentials() -> None: service = _make_service() _, cred_id, _attested = _generate_credential() @@ -157,6 +174,25 @@ def test_begin_registration_with_existing_credentials() -> None: # --- Authentication tests --- +def test_begin_authentication_without_credentials() -> None: + """Usernameless flow: no allowCredentials, browser shows passkey picker.""" + service = _make_service() + options, state = service.begin_authentication() + assert "publicKey" in options + assert "challenge" in state + pub_key = options["publicKey"] + # allowCredentials should be absent or empty + allow = pub_key.get("allowCredentials", []) + assert allow is None or len(allow) == 0 + + +def test_begin_authentication_prefers_user_verification() -> None: + service = _make_service() + options, _state = service.begin_authentication() + pub_key = options["publicKey"] + assert pub_key["userVerification"] == "preferred" + + def test_begin_authentication_returns_options_and_state() -> None: service = _make_service() _, cred_id, _attested = _generate_credential()