""" Regression tests for passkey user verification (review finding #4). Both ceremonies used UserVerificationRequirement.PREFERRED with require_user_verification=False, so an authenticator was free to skip the biometric/PIN check. Since a passkey assertion here replaces BOTH the password and the TOTP second factor, that reduced a full login to possession of an unlocked device — enough to enumerate and delete vault items. A full ceremony needs a real authenticator, so these tests pin the negotiated options (what the server asks the browser for) and the rejection path. The enforcement half — require_user_verification=True passed to py-webauthn — is asserted directly against the source. """ import inspect import re from tests.conftest import auth_headers, make_user import app.routes.webauthn as webauthn_routes def test_registration_options_require_user_verification(client, app): token, _ = make_user(client) res = client.post('/api/webauthn/register/begin', headers=auth_headers(token), json={}) assert res.status_code == 200, res.get_json() body = res.get_json() assert body['authenticatorSelection']['userVerification'] == 'required' def test_authentication_options_require_user_verification(client, app): make_user(client) res = client.post('/api/webauthn/authenticate/begin', json={'email': 'user@example.com'}) assert res.status_code == 200, res.get_json() assert res.get_json()['userVerification'] == 'required' def test_verification_calls_enforce_user_verification(): """ Negotiating 'required' is only a request to the browser. The server must also refuse an assertion that comes back without the UV flag set, or the hint is decorative. """ src = inspect.getsource(webauthn_routes) calls = re.findall(r'require_user_verification=(\w+)', src) assert calls, 'no require_user_verification argument found' assert all(v == 'True' for v in calls), ( f'require_user_verification must be True everywhere, found: {calls}' ) def test_unknown_credential_is_rejected(client, app): make_user(client) client.post('/api/webauthn/authenticate/begin', json={'email': 'user@example.com'}) res = client.post('/api/webauthn/authenticate/complete', json={'id': 'bm9wZQ', 'rawId': 'bm9wZQ'}) assert res.status_code == 401 assert 'not recognised' in res.get_json()['error'] def test_registration_failure_does_not_leak_exception_text(client, app): """ CLAUDE.md forbids returning str(e) to clients; this handler used to embed the raw py-webauthn message, which quotes attestation internals. """ token, _ = make_user(client) client.post('/api/webauthn/register/begin', headers=auth_headers(token), json={}) res = client.post('/api/webauthn/register/complete', headers=auth_headers(token), json={'id': 'garbage'}) assert res.status_code == 400 error = res.get_json()['error'] assert error == 'Could not verify this passkey. Please try again.', error def test_register_complete_requires_a_pending_challenge(client, app): token, _ = make_user(client) res = client.post('/api/webauthn/register/complete', headers=auth_headers(token), json={'id': 'x'}) assert res.status_code == 400 assert 'No pending registration challenge' in res.get_json()['error']