porchlight/tests/test_auth_routes/test_webauthn_login.py
Johan Lundberg c175633980
fix(security): POST WebAuthn login-begin; render JS errors as text
Two WebAuthn client/route hardening fixes:

- GET /login/webauthn/begin wrote a challenge to the session on a safe method
  (login-flow DoS / CSRF surface). Make it POST with CSRF, matching the
  registration-begin endpoint; update webauthn.js and tests accordingly.

- webauthn.js rendered dynamic error text (err.message, server error fields)
  via innerHTML — an XSS-prone sink. Add showAlert() that sets textContent;
  route all dynamic error messages through it. The trusted server-rendered
  credentials partial is still injected as markup.

Refs: porchlight-cog, porchlight-t7y

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:04:24 +02:00

98 lines
3.8 KiB
Python

import os
from datetime import UTC, datetime
from cryptography.hazmat.primitives.asymmetric import ec
from fido2.cose import ES256
from fido2.webauthn import Aaguid, AttestedCredentialData
from httpx import AsyncClient
from porchlight.models import User, WebAuthnCredential
from tests.conftest import get_csrf_token
RP_ID = "localhost"
ORIGIN = "http://localhost:8000"
def _generate_credential() -> tuple[ec.EllipticCurvePrivateKey, bytes, AttestedCredentialData]:
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
async def _setup_user_with_webauthn(
client: AsyncClient,
) -> tuple[str, ec.EllipticCurvePrivateKey, bytes, AttestedCredentialData]:
app = client._transport.app # type: ignore[union-attr]
user_repo = app.state.user_repo
cred_repo = app.state.credential_repo
private_key, credential_id, attested = _generate_credential()
user = User(userid="lusab-bansen", username="alice", created_at=datetime.now(UTC), updated_at=datetime.now(UTC))
await user_repo.create(user)
await cred_repo.create_webauthn(
WebAuthnCredential(
user_id=user.userid,
credential_id=credential_id,
public_key=bytes(attested),
sign_count=0,
)
)
return user.userid, private_key, credential_id, attested
async def test_webauthn_login_begin_returns_options(client: AsyncClient) -> None:
"""Begin is a POST (mutates session) with no username — returns options
with empty allowCredentials."""
await _setup_user_with_webauthn(client)
token = await get_csrf_token(client)
res = await client.post("/login/webauthn/begin", headers={"X-CSRF-Token": token})
assert res.status_code == 200
data = res.json()
assert "publicKey" in data
# Usernameless: allowCredentials should be absent or empty
allow = data["publicKey"].get("allowCredentials", [])
assert allow is None or len(allow) == 0
async def test_webauthn_login_begin_has_user_verification_preferred(client: AsyncClient) -> None:
token = await get_csrf_token(client)
res = await client.post("/login/webauthn/begin", headers={"X-CSRF-Token": token})
assert res.status_code == 200
data = res.json()
assert data["publicKey"]["userVerification"] == "preferred"
async def test_webauthn_login_complete_without_state_returns_400(client: AsyncClient) -> None:
"""Complete without prior begin should fail."""
token = await get_csrf_token(client)
res = await client.post(
"/login/webauthn/complete",
json={"id": "fake", "rawId": "fake", "type": "public-key", "response": {}},
headers={"X-CSRF-Token": token},
)
assert res.status_code == 400
async def test_webauthn_login_complete_returns_json_redirect(client: AsyncClient) -> None:
"""After successful auth, complete endpoint returns JSON with redirect URL."""
_userid, _pk, _credential_id, _att = await _setup_user_with_webauthn(client)
# Begin to get state into session
token = await get_csrf_token(client)
res1 = await client.post("/login/webauthn/begin", headers={"X-CSRF-Token": token})
assert res1.status_code == 200
# We can't easily complete the full assertion without browser interaction,
# but we verify the endpoint returns 400 JSON (not HTML) for bad assertions
res2 = await client.post(
"/login/webauthn/complete",
json={"id": "fake", "rawId": "fake", "type": "public-key", "response": {}},
headers={"X-CSRF-Token": token},
)
# Should fail verification but not crash — returns error HTML for now
assert res2.status_code in (200, 400)