porchlight/tests/test_authn/test_webauthn.py
Johan Lundberg 01e3382aaf
fix: resolve all ruff lint errors and type checker warnings
- Use Annotated[str, Form()] for FastAPI dependencies (FAST002)
- Add missing type annotations across src/ and tests/ (ANN001/003/201/202)
- Reduce function arguments via request.form() reads (PLR0913)
- Combine return paths to reduce return statements (PLR0911)
- Use anyio.Path for async-safe filesystem operations (ASYNC240)
- Extract constants, helpers, and dict comprehensions for clarity
- Move inline imports to top-level (PLC0415)
- Use raw strings for regex match patterns (RUF043)
- Fix redundant get_session_user call in delete_user (not-iterable)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:48:46 +02:00

260 lines
8.1 KiB
Python

import os
import pytest
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.hashes import SHA256
from fido2.cose import ES256
from fido2.utils import sha256
from fido2.webauthn import (
Aaguid,
AttestationObject,
AttestedCredentialData,
AuthenticationResponse,
AuthenticatorAssertionResponse,
AuthenticatorAttestationResponse,
AuthenticatorData,
CollectedClientData,
PublicKeyCredentialDescriptor,
PublicKeyCredentialType,
RegistrationResponse,
)
from porchlight.authn.webauthn import WebAuthnService
RP_ID = "localhost"
RP_NAME = "Test RP"
ORIGIN = "http://localhost:8000"
def _make_service() -> WebAuthnService:
return WebAuthnService(rp_id=RP_ID, rp_name=RP_NAME, origin=ORIGIN)
def _generate_credential() -> tuple[ec.EllipticCurvePrivateKey, bytes, AttestedCredentialData]:
"""Generate a test credential: (private_key, credential_id, attested_credential_data)."""
private_key = ec.generate_private_key(ec.SECP256R1())
cose_key = ES256.from_cryptography_key(private_key.public_key())
credential_id = os.urandom(32)
attested = AttestedCredentialData.create(
aaguid=Aaguid.NONE,
credential_id=credential_id,
public_key=cose_key,
)
return private_key, credential_id, attested
def _build_registration_response(
credential_id: bytes,
attested: AttestedCredentialData,
challenge: bytes,
) -> RegistrationResponse:
"""Build a valid registration response for the given challenge."""
auth_data = AuthenticatorData.create(
rp_id_hash=sha256(RP_ID.encode()),
flags=AuthenticatorData.FLAG.UP | AuthenticatorData.FLAG.AT,
counter=0,
credential_data=attested,
)
attestation_object = AttestationObject.create(fmt="none", auth_data=auth_data, att_stmt={})
client_data = CollectedClientData.create(
type=CollectedClientData.TYPE.CREATE,
challenge=challenge,
origin=ORIGIN,
)
return RegistrationResponse(
raw_id=credential_id,
response=AuthenticatorAttestationResponse(
client_data=client_data,
attestation_object=attestation_object,
),
)
def _build_authentication_response(
private_key: ec.EllipticCurvePrivateKey,
credential_id: bytes,
challenge: bytes,
counter: int = 1,
) -> AuthenticationResponse:
"""Build a valid authentication response signed with the private key."""
client_data = CollectedClientData.create(
type=CollectedClientData.TYPE.GET,
challenge=challenge,
origin=ORIGIN,
)
auth_data = AuthenticatorData.create(
rp_id_hash=sha256(RP_ID.encode()),
flags=AuthenticatorData.FLAG.UP,
counter=counter,
)
signature = private_key.sign(auth_data + client_data.hash, ec.ECDSA(SHA256()))
return AuthenticationResponse(
raw_id=credential_id,
response=AuthenticatorAssertionResponse(
client_data=client_data,
authenticator_data=auth_data,
signature=signature,
),
)
# --- Registration tests ---
def test_begin_registration_returns_options_and_state() -> None:
service = _make_service()
options, state = service.begin_registration(
user_id=b"user-123",
username="alice",
)
# Options should be a dict suitable for JSON serialization
assert "publicKey" in options
pub_key = options["publicKey"]
assert "challenge" in pub_key
assert "rp" in pub_key
assert "user" in pub_key
# State should be a dict with challenge
assert "challenge" in state
def test_complete_registration_returns_credential_data() -> None:
service = _make_service()
_private_key, credential_id, attested = _generate_credential()
_options, state = service.begin_registration(
user_id=b"user-123",
username="alice",
)
# Extract challenge from state to build a matching response
challenge = state["challenge"]
response = _build_registration_response(credential_id, attested, challenge)
result = service.complete_registration(state, response)
assert result.credential_data is not 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()
existing = [
PublicKeyCredentialDescriptor(
type=PublicKeyCredentialType.PUBLIC_KEY,
id=cred_id,
)
]
options, _state = service.begin_registration(
user_id=b"user-123",
username="alice",
existing_credentials=existing,
)
pub_key = options["publicKey"]
assert "excludeCredentials" in pub_key
assert len(pub_key["excludeCredentials"]) == 1
# --- 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()
credentials = [
PublicKeyCredentialDescriptor(
type=PublicKeyCredentialType.PUBLIC_KEY,
id=cred_id,
)
]
options, state = service.begin_authentication(credentials=credentials)
assert "publicKey" in options
assert "challenge" in state
def test_complete_authentication_verifies_signature() -> None:
service = _make_service()
private_key, credential_id, attested = _generate_credential()
credentials = [
PublicKeyCredentialDescriptor(
type=PublicKeyCredentialType.PUBLIC_KEY,
id=credential_id,
)
]
_options, state = service.begin_authentication(credentials=credentials)
challenge = state["challenge"]
response = _build_authentication_response(private_key, credential_id, challenge)
result = service.complete_authentication(
state=state,
credentials=[attested],
response=response,
)
assert result.credential_id == credential_id
def test_complete_authentication_wrong_signature_raises() -> None:
service = _make_service()
_private_key, credential_id, attested = _generate_credential()
# Generate a different key to produce a wrong signature
wrong_key = ec.generate_private_key(ec.SECP256R1())
credentials = [
PublicKeyCredentialDescriptor(
type=PublicKeyCredentialType.PUBLIC_KEY,
id=credential_id,
)
]
_options, state = service.begin_authentication(credentials=credentials)
challenge = state["challenge"]
response = _build_authentication_response(wrong_key, credential_id, challenge)
with pytest.raises(ValueError, match="Invalid signature"):
service.complete_authentication(
state=state,
credentials=[attested],
response=response,
)