feat(auth): B-2.6 WebAuthn / Passkey support (FIDO2 + biometric 2FA)

Adds phishing-resistant 2nd factor via FIDO2 hardware keys (YubiKey etc.)
and device biometrics (Touch ID, Windows Hello, etc.). Reuses the existing
B-2.5 TOTP gate so a passkey is a 3rd valid option on /2fa/verify, alongside
TOTP code and recovery code. Post-login enrolment lives at /2fa/passkey/setup.

Wraps python-webauthn==2.5.2 in a thin service layer (src/auth/webauthn.py)
that persists credentials in the existing User.webauthn_credentials JSON
column (added in B-2.1 — no schema change). Each credential dict carries
id, public_key, sign_count, transports, name, and created_at. sign_count is
updated after every successful authentication for WebAuthn anti-cloning
(§6.1.1).

Backend: 6 new auth routes (passkey_setup, register/begin, register/finish,
delete, auth/begin, auth/finish). The 4 JSON endpoints are CSRF-exempt at
Flask-WTF level because CSRFProtect cannot read tokens from a JSON body
without app-wide config; the X-CSRFToken header is still sent as
defence-in-depth. The form-POST delete route DOES enforce CSRF. The
@csrf_exempt decorator was previously a no-op label; init_auth_extensions
now walks module-level functions and applies real csrf.exempt() to any
flagged with _csrf_exempt=True.

Login gate now fires when the user has TOTP enabled OR at least one
passkey, and totp_verify_login passes has_passkeys + has_totp flags so the
template can show only the relevant sections.

Frontend: templates/auth/totp_verify.html updated IN PLACE with a passkey
button section (above TOTP) and an "ou" divider. New
templates/auth/passkey_setup.html for managing/enrolling passkeys. New
static/js/webauthn-client.js (no external deps, ES2020) wraps
navigator.credentials and exchanges base64url payloads with the backend.
Tailwind CSS rebuilt.

Tests: 22 new tests in tests/test_webauthn_passkey.py covering the service
layer (b64url helpers, RP config, list/has, begin/finish for both
registration and authentication, delete) and the route flow (CSRF-exempt
JSON endpoints, login gate redirection, sign_count anti-cloning
persistence). Mocks python-webauthn's verify_* functions so tests run
without a real authenticator. Windows manual driver follows the existing
no-conftest pattern.

Self-review: 22/22 new tests pass; 21/21 prior TOTP, 16/16 email,
21/21 OAuth tests still pass (no regression).

Env: config/env.oauth.example documents WEBAUTHN_RP_ID, WEBAUTHN_RP_NAME,
WEBAUTHN_ORIGIN with full deployment notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Allison
2026-04-28 00:27:09 -04:00
parent aa269c5bc0
commit b8fa321edd
9 changed files with 1580 additions and 7 deletions

View File

@@ -0,0 +1,77 @@
"""Windows manual driver for tests/test_webauthn_passkey.py.
src/init_db.py imports `fcntl`, which is POSIX-only. On Windows we stub it
before src.app gets imported, then run each test_* function and report.
Run from the repo root:
py -3 tests/_run_webauthn_passkey_windows.py
This script is local-dev only (not picked up by pytest collection).
"""
import os
import sys
import types
import traceback
# 1) Stub fcntl BEFORE any import of src.* happens.
if 'fcntl' not in sys.modules:
fcntl_stub = types.ModuleType('fcntl')
fcntl_stub.LOCK_EX = 2
fcntl_stub.LOCK_NB = 4
fcntl_stub.LOCK_UN = 8
fcntl_stub.LOCK_SH = 1
fcntl_stub.flock = lambda *_args, **_kw: None
fcntl_stub.fcntl = lambda *_args, **_kw: 0
sys.modules['fcntl'] = fcntl_stub
# 2) Make repo root importable
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(HERE)
sys.path.insert(0, REPO)
# 3) Set test config
os.environ.setdefault('SQLALCHEMY_DATABASE_URI', 'sqlite:///:memory:')
os.environ.setdefault('SECRET_KEY', 'test-secret-key-webauthn')
os.environ.setdefault('ENABLE_EMAIL_VERIFICATION', 'false')
os.environ.setdefault('REQUIRE_EMAIL_VERIFICATION', 'false')
os.environ.setdefault('TRANSCRIPTION_BASE_URL', 'http://test-stub')
os.environ.setdefault('TRANSCRIPTION_API_KEY', 'test-stub')
os.environ.setdefault('RATELIMIT_ENABLED', 'false')
# Force UTF-8 stdout so src.app's emoji prints don't crash on cp1252 Windows.
try:
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
except Exception:
pass
# 4) Import the test module and run every test_* function it defines
import importlib.util # noqa: E402
spec = importlib.util.spec_from_file_location(
'test_webauthn_passkey',
os.path.join(HERE, 'test_webauthn_passkey.py'),
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
tests = [(name, fn) for name, fn in vars(mod).items()
if name.startswith('test_') and callable(fn)]
passed = 0
failed = []
for name, fn in tests:
try:
fn()
print(f' PASS {name}')
passed += 1
except Exception as e: # noqa: BLE001
print(f' FAIL {name}: {type(e).__name__}: {e}')
failed.append((name, traceback.format_exc()))
total = len(tests)
print()
print(f'Result: {passed}/{total} passed, {len(failed)} failed')
if failed:
print('\n--- Failures ---\n')
for name, tb in failed:
print(f'### {name}\n{tb}\n')
sys.exit(0 if not failed else 1)

View File

@@ -0,0 +1,681 @@
"""Tests for B-2.6 — WebAuthn / Passkey support.
Covers:
- Service layer helpers: b64url encode/decode, RP getters, list/has_passkeys.
- begin_registration returns json-safe options + challenge.
- finish_registration persists the credential dict in user.webauthn_credentials.
- begin_authentication raises if user has no credentials.
- finish_authentication increments sign_count (anti-cloning) + rejects unknown id.
- delete_credential removes by id.
- Routes: register/begin, register/finish (success + missing challenge + invalid),
auth/begin (with/without pending session), auth/finish (success + invalid),
delete (form POST), and the login gate that defers when has_passkeys.
Mocks the python-webauthn library functions so the tests don't need a real
authenticator. The library is installed (webauthn==2.5.2) so imports work,
but the verify_* functions are patched.
Note: pytest cannot collect this file on Windows native because src/init_db.py
imports `fcntl` (POSIX-only). Use tests/_run_webauthn_passkey_windows.py.
"""
import os
import sys
from types import SimpleNamespace
from unittest.mock import patch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault('SQLALCHEMY_DATABASE_URI', 'sqlite:///:memory:')
os.environ.setdefault('SECRET_KEY', 'test-secret-key-webauthn')
os.environ.setdefault('ENABLE_EMAIL_VERIFICATION', 'false')
os.environ.setdefault('REQUIRE_EMAIL_VERIFICATION', 'false')
os.environ.setdefault('RATELIMIT_ENABLED', 'false')
from src.app import app, db, bcrypt # noqa: E402
from src.models.user import User # noqa: E402
from src.auth import webauthn as wa # noqa: E402
def _disable_csrf():
app.config['WTF_CSRF_ENABLED'] = False
def _make_user(email='passkey@example.qc.ca', password='Password!123',
username=None, webauthn_credentials=None, totp_enabled=False,
totp_secret_encrypted=None, totp_recovery_codes=None):
"""Create a User row with bcrypt-hashed password. Returns the User."""
hashed = bcrypt.generate_password_hash(password).decode('utf-8')
u = User(
username=username or email.split('@', 1)[0][:20],
email=email,
password=hashed,
email_verified=True,
webauthn_credentials=webauthn_credentials,
totp_enabled=totp_enabled,
totp_secret_encrypted=totp_secret_encrypted,
totp_recovery_codes=totp_recovery_codes,
)
db.session.add(u)
db.session.commit()
return u
def _login_session(client, user):
"""Mark a Flask-Login session as logged in for `user`."""
with client.session_transaction() as sess:
sess['_user_id'] = str(user.id)
sess['_fresh'] = True
# ----------------------------------------------------------------------
# 1-3. Helpers + RP config
# ----------------------------------------------------------------------
def test_b64url_encode_decode_round_trips():
"""bytes → str → bytes round-trips for various lengths (incl. padding edge)."""
samples = [b'', b'A', b'AB', b'ABC', b'ABCD', b'\x00\xff\x10' * 20]
for raw in samples:
encoded = wa._b64url_encode(raw)
assert isinstance(encoded, str)
assert '=' not in encoded # no padding
assert wa._b64url_decode(encoded) == raw
def test_get_rp_id_default():
"""get_rp_id returns 'localhost' when WEBAUTHN_RP_ID not set."""
saved = os.environ.pop('WEBAUTHN_RP_ID', None)
try:
assert wa.get_rp_id() == 'localhost'
finally:
if saved is not None:
os.environ['WEBAUTHN_RP_ID'] = saved
def test_get_rp_id_from_env():
"""get_rp_id returns the env var value when set."""
saved = os.environ.get('WEBAUTHN_RP_ID')
try:
os.environ['WEBAUTHN_RP_ID'] = 'dictia.ca'
assert wa.get_rp_id() == 'dictia.ca'
finally:
if saved is None:
os.environ.pop('WEBAUTHN_RP_ID', None)
else:
os.environ['WEBAUTHN_RP_ID'] = saved
# ----------------------------------------------------------------------
# 4-5. list_user_credentials / has_passkeys
# ----------------------------------------------------------------------
def test_list_user_credentials_empty():
"""Returns [] when webauthn_credentials is None."""
with app.app_context():
db.create_all()
try:
user = _make_user(email='listempty@example.qc.ca')
assert wa.list_user_credentials(user) == []
assert wa.has_passkeys(user) is False
finally:
db.session.rollback()
db.drop_all()
def test_has_passkeys_true_after_credential_added():
"""has_passkeys returns True after a credential dict is appended."""
with app.app_context():
db.create_all()
try:
user = _make_user(
email='hasone@example.qc.ca',
webauthn_credentials=[{
'id': 'AQID', 'public_key': 'oLDA',
'sign_count': 0, 'transports': ['usb'],
'name': 'Test', 'created_at': '2026-04-27T00:00:00+00:00',
}],
)
assert wa.has_passkeys(user) is True
assert len(wa.list_user_credentials(user)) == 1
finally:
db.session.rollback()
db.drop_all()
# ----------------------------------------------------------------------
# 6. begin_registration
# ----------------------------------------------------------------------
def test_begin_registration_returns_options_and_challenge():
"""Mocks generate_registration_options; asserts json-safe dict + challenge str."""
with app.app_context():
db.create_all()
try:
user = _make_user(email='regbegin@example.qc.ca')
fake_challenge = b'\x01\x02\x03\x04challenge-bytes\xff'
with patch('src.auth.webauthn.generate_registration_options') as gen, \
patch('src.auth.webauthn.options_to_json') as to_json:
gen.return_value = SimpleNamespace(challenge=fake_challenge)
to_json.return_value = (
'{"challenge": "AQIDBGNoYWxsZW5nZS1ieXRlc_8", '
'"rp": {"name": "DictIA", "id": "localhost"}, '
'"user": {"id": "MQ", "name": "x", "displayName": "x"}}'
)
opts, challenge_b64 = wa.begin_registration(user)
assert isinstance(opts, dict)
assert 'challenge' in opts
assert 'rp' in opts
assert isinstance(challenge_b64, str)
assert wa._b64url_decode(challenge_b64) == fake_challenge
gen.assert_called_once()
finally:
db.session.rollback()
db.drop_all()
# ----------------------------------------------------------------------
# 7. finish_registration
# ----------------------------------------------------------------------
def test_finish_registration_persists_credential():
"""Mocks verify_registration_response; asserts credential dict appended."""
with app.app_context():
db.create_all()
try:
user = _make_user(email='regfin@example.qc.ca')
challenge = b'challenge-bytes-xyz'
challenge_b64 = wa._b64url_encode(challenge)
mock_verification = SimpleNamespace(
credential_id=b'\xaa\xbb\xcc',
credential_public_key=b'\x01\x02\x03',
sign_count=0,
)
response_json = {
'id': wa._b64url_encode(b'\xaa\xbb\xcc'),
'rawId': wa._b64url_encode(b'\xaa\xbb\xcc'),
'type': 'public-key',
'response': {
'clientDataJSON': 'aaa',
'attestationObject': 'bbb',
'transports': ['usb', 'nfc'],
},
}
with patch('src.auth.webauthn.verify_registration_response',
return_value=mock_verification) as ver:
cred = wa.finish_registration(
user, response_json, challenge_b64, label='YubiKey 5C'
)
ver.assert_called_once()
assert cred['id'] == wa._b64url_encode(b'\xaa\xbb\xcc')
assert cred['public_key'] == wa._b64url_encode(b'\x01\x02\x03')
assert cred['sign_count'] == 0
assert cred['transports'] == ['usb', 'nfc']
assert cred['name'] == 'YubiKey 5C'
assert 'created_at' in cred
db.session.refresh(user)
assert len(user.webauthn_credentials) == 1
assert user.webauthn_credentials[0]['id'] == cred['id']
finally:
db.session.rollback()
db.drop_all()
# ----------------------------------------------------------------------
# 8. begin_authentication
# ----------------------------------------------------------------------
def test_begin_authentication_raises_when_no_credentials():
"""ValueError when user has no registered passkeys."""
with app.app_context():
db.create_all()
try:
user = _make_user(email='noauthcreds@example.qc.ca')
try:
wa.begin_authentication(user)
raise AssertionError('Expected ValueError')
except ValueError:
pass
finally:
db.session.rollback()
db.drop_all()
# ----------------------------------------------------------------------
# 9-10. finish_authentication
# ----------------------------------------------------------------------
def test_finish_authentication_increments_sign_count():
"""Pre-set 1 cred sign_count=5; mock verify returns 6; assert persisted."""
with app.app_context():
db.create_all()
try:
cred_id = wa._b64url_encode(b'\xde\xad\xbe\xef')
user = _make_user(
email='signinc@example.qc.ca',
webauthn_credentials=[{
'id': cred_id,
'public_key': wa._b64url_encode(b'pub'),
'sign_count': 5,
'transports': ['internal'],
'name': 'Touch ID',
'created_at': '2026-04-27T00:00:00+00:00',
}],
)
challenge_b64 = wa._b64url_encode(b'auth-challenge')
mock_ver = SimpleNamespace(new_sign_count=6)
response_json = {'id': cred_id, 'type': 'public-key', 'response': {}}
with patch('src.auth.webauthn.verify_authentication_response',
return_value=mock_ver):
matched = wa.finish_authentication(
user, response_json, challenge_b64
)
assert matched['id'] == cred_id
assert matched['sign_count'] == 6
db.session.refresh(user)
assert user.webauthn_credentials[0]['sign_count'] == 6
finally:
db.session.rollback()
db.drop_all()
def test_finish_authentication_rejects_unknown_credential_id():
"""Response.id not in user's credentials → ValueError."""
with app.app_context():
db.create_all()
try:
user = _make_user(
email='unknownid@example.qc.ca',
webauthn_credentials=[{
'id': 'KNOWN', 'public_key': 'pub', 'sign_count': 0,
'transports': [], 'name': 'A',
'created_at': '2026-04-27T00:00:00+00:00',
}],
)
response_json = {'id': 'NOT-REGISTERED', 'response': {}}
try:
wa.finish_authentication(
user, response_json, wa._b64url_encode(b'c')
)
raise AssertionError('Expected ValueError')
except ValueError:
pass
finally:
db.session.rollback()
db.drop_all()
# ----------------------------------------------------------------------
# 11-12. delete_credential
# ----------------------------------------------------------------------
def test_delete_credential_removes_by_id():
"""Pre-set 2 creds; delete one; remaining list shrinks."""
with app.app_context():
db.create_all()
try:
user = _make_user(
email='delok@example.qc.ca',
webauthn_credentials=[
{'id': 'A', 'public_key': 'p1', 'sign_count': 0,
'transports': [], 'name': 'A',
'created_at': '2026-04-27T00:00:00+00:00'},
{'id': 'B', 'public_key': 'p2', 'sign_count': 0,
'transports': [], 'name': 'B',
'created_at': '2026-04-27T00:00:00+00:00'},
],
)
assert wa.delete_credential(user, 'A') is True
db.session.refresh(user)
assert len(user.webauthn_credentials) == 1
assert user.webauthn_credentials[0]['id'] == 'B'
finally:
db.session.rollback()
db.drop_all()
def test_delete_credential_returns_false_for_unknown_id():
"""Bogus id → False, list unchanged."""
with app.app_context():
db.create_all()
try:
user = _make_user(
email='delno@example.qc.ca',
webauthn_credentials=[
{'id': 'X', 'public_key': 'p', 'sign_count': 0,
'transports': [], 'name': 'X',
'created_at': '2026-04-27T00:00:00+00:00'},
],
)
assert wa.delete_credential(user, 'NOPE') is False
db.session.refresh(user)
assert len(user.webauthn_credentials) == 1
finally:
db.session.rollback()
db.drop_all()
# ----------------------------------------------------------------------
# 13-16. /2fa/passkey/register routes
# ----------------------------------------------------------------------
def test_passkey_register_begin_returns_options_for_logged_in_user():
"""POST /2fa/passkey/register/begin → JSON options + session challenge set."""
with app.app_context():
_disable_csrf()
db.create_all()
try:
user = _make_user(email='regbeginrt@example.qc.ca')
fake_challenge = b'route-begin-challenge'
with app.test_client() as client:
_login_session(client, user)
with patch('src.auth.webauthn.generate_registration_options') as gen, \
patch('src.auth.webauthn.options_to_json') as to_json:
gen.return_value = SimpleNamespace(challenge=fake_challenge)
to_json.return_value = '{"challenge": "x", "rp": {"id": "localhost"}}'
resp = client.post('/2fa/passkey/register/begin', json={})
assert resp.status_code == 200
data = resp.get_json()
assert 'challenge' in data
assert 'rp' in data
with client.session_transaction() as sess:
assert sess.get('webauthn_register_challenge') == \
wa._b64url_encode(fake_challenge)
finally:
db.session.rollback()
db.drop_all()
def test_passkey_register_finish_persists_credential():
"""POST /2fa/passkey/register/finish with challenge in session + mocked verify."""
with app.app_context():
_disable_csrf()
db.create_all()
try:
user = _make_user(email='regfinrt@example.qc.ca')
challenge = b'finish-route-challenge'
challenge_b64 = wa._b64url_encode(challenge)
mock_ver = SimpleNamespace(
credential_id=b'\x10\x20\x30',
credential_public_key=b'\x40\x50',
sign_count=0,
)
with app.test_client() as client:
_login_session(client, user)
with client.session_transaction() as sess:
sess['webauthn_register_challenge'] = challenge_b64
with patch('src.auth.webauthn.verify_registration_response',
return_value=mock_ver):
resp = client.post('/2fa/passkey/register/finish', json={
'label': 'My Key',
'response': {
'id': wa._b64url_encode(b'\x10\x20\x30'),
'rawId': wa._b64url_encode(b'\x10\x20\x30'),
'type': 'public-key',
'response': {
'clientDataJSON': 'aa', 'attestationObject': 'bb',
'transports': ['usb'],
},
},
})
assert resp.status_code == 200
data = resp.get_json()
assert data['ok'] is True
assert data['credential']['name'] == 'My Key'
db.session.refresh(user)
assert len(user.webauthn_credentials) == 1
# Challenge consumed
with client.session_transaction() as sess:
assert 'webauthn_register_challenge' not in sess
finally:
db.session.rollback()
db.drop_all()
def test_passkey_register_finish_rejects_without_challenge():
"""POST /2fa/passkey/register/finish without session challenge → 400 FR."""
with app.app_context():
_disable_csrf()
db.create_all()
try:
user = _make_user(email='regnoch@example.qc.ca')
with app.test_client() as client:
_login_session(client, user)
resp = client.post('/2fa/passkey/register/finish', json={
'response': {'id': 'x', 'response': {}},
})
assert resp.status_code == 400
data = resp.get_json()
assert data['error'] == 'no_challenge'
# French message
assert 'défi' in data['message'].lower() or 'attente' in data['message'].lower()
finally:
db.session.rollback()
db.drop_all()
def test_passkey_register_finish_rejects_invalid_response():
"""verify_registration_response raises → 400 with FR message."""
with app.app_context():
_disable_csrf()
db.create_all()
try:
user = _make_user(email='regbad@example.qc.ca')
challenge_b64 = wa._b64url_encode(b'c')
with app.test_client() as client:
_login_session(client, user)
with client.session_transaction() as sess:
sess['webauthn_register_challenge'] = challenge_b64
with patch('src.auth.webauthn.verify_registration_response',
side_effect=Exception('bad')):
resp = client.post('/2fa/passkey/register/finish', json={
'response': {'id': 'x', 'response': {}},
})
assert resp.status_code == 400
data = resp.get_json()
assert data['error'] == 'verification_failed'
assert 'vérification' in data['message'].lower() or \
'échec' in data['message'].lower()
finally:
db.session.rollback()
db.drop_all()
# ----------------------------------------------------------------------
# 17-20. /2fa/passkey/auth routes
# ----------------------------------------------------------------------
def test_passkey_auth_begin_uses_pending_session_user():
"""POST /2fa/passkey/auth/begin with pending_totp_user_id → options."""
with app.app_context():
_disable_csrf()
db.create_all()
try:
cred_id = wa._b64url_encode(b'authcred')
user = _make_user(
email='authbegin@example.qc.ca',
webauthn_credentials=[{
'id': cred_id, 'public_key': wa._b64url_encode(b'pub'),
'sign_count': 0, 'transports': [], 'name': 'A',
'created_at': '2026-04-27T00:00:00+00:00',
}],
)
fake_challenge = b'auth-route-challenge'
with app.test_client() as client:
with client.session_transaction() as sess:
sess['pending_totp_user_id'] = user.id
with patch('src.auth.webauthn.generate_authentication_options') as gen, \
patch('src.auth.webauthn.options_to_json') as to_json:
gen.return_value = SimpleNamespace(challenge=fake_challenge)
to_json.return_value = '{"challenge": "x", "rpId": "localhost"}'
resp = client.post('/2fa/passkey/auth/begin', json={})
assert resp.status_code == 200
data = resp.get_json()
assert 'challenge' in data
with client.session_transaction() as sess:
assert sess.get('webauthn_auth_challenge') == \
wa._b64url_encode(fake_challenge)
finally:
db.session.rollback()
db.drop_all()
def test_passkey_auth_begin_returns_400_without_pending_session():
"""No pending_totp_user_id → 400 with FR error."""
with app.app_context():
_disable_csrf()
db.create_all()
try:
with app.test_client() as client:
resp = client.post('/2fa/passkey/auth/begin', json={})
assert resp.status_code == 400
data = resp.get_json()
assert data['error'] == 'no_session'
finally:
db.session.rollback()
db.drop_all()
def test_passkey_auth_finish_logs_in_user_and_returns_redirect():
"""Successful auth → ok+redirect, user logged in via session."""
with app.app_context():
_disable_csrf()
db.create_all()
try:
cred_id = wa._b64url_encode(b'\xaa\xbb')
user = _make_user(
email='authfin@example.qc.ca',
webauthn_credentials=[{
'id': cred_id, 'public_key': wa._b64url_encode(b'p'),
'sign_count': 3, 'transports': [], 'name': 'X',
'created_at': '2026-04-27T00:00:00+00:00',
}],
)
challenge_b64 = wa._b64url_encode(b'authch')
mock_ver = SimpleNamespace(new_sign_count=4)
with app.test_client() as client:
with client.session_transaction() as sess:
sess['pending_totp_user_id'] = user.id
sess['webauthn_auth_challenge'] = challenge_b64
sess['pending_totp_remember'] = True
with patch('src.auth.webauthn.verify_authentication_response',
return_value=mock_ver):
resp = client.post('/2fa/passkey/auth/finish', json={
'response': {'id': cred_id, 'response': {}},
})
assert resp.status_code == 200
data = resp.get_json()
assert data['ok'] is True
assert 'redirect' in data
with client.session_transaction() as sess:
assert sess.get('_user_id') == str(user.id)
assert 'pending_totp_user_id' not in sess
assert 'webauthn_auth_challenge' not in sess
# sign_count incremented in DB
db.session.refresh(user)
assert user.webauthn_credentials[0]['sign_count'] == 4
finally:
db.session.rollback()
db.drop_all()
def test_passkey_auth_finish_rejects_invalid():
"""verify raises → 400 verification_failed; user NOT logged in."""
with app.app_context():
_disable_csrf()
db.create_all()
try:
cred_id = wa._b64url_encode(b'\x99')
user = _make_user(
email='authbad@example.qc.ca',
webauthn_credentials=[{
'id': cred_id, 'public_key': wa._b64url_encode(b'p'),
'sign_count': 0, 'transports': [], 'name': 'X',
'created_at': '2026-04-27T00:00:00+00:00',
}],
)
challenge_b64 = wa._b64url_encode(b'c')
with app.test_client() as client:
with client.session_transaction() as sess:
sess['pending_totp_user_id'] = user.id
sess['webauthn_auth_challenge'] = challenge_b64
with patch('src.auth.webauthn.verify_authentication_response',
side_effect=Exception('nope')):
resp = client.post('/2fa/passkey/auth/finish', json={
'response': {'id': cred_id, 'response': {}},
})
assert resp.status_code == 400
data = resp.get_json()
assert data['error'] == 'verification_failed'
with client.session_transaction() as sess:
assert '_user_id' not in sess
finally:
db.session.rollback()
db.drop_all()
# ----------------------------------------------------------------------
# 21. Delete route (form POST)
# ----------------------------------------------------------------------
def test_passkey_delete_route_removes_credential():
"""POST /2fa/passkey/credentials/<id>/delete removes it."""
with app.app_context():
_disable_csrf()
db.create_all()
try:
user = _make_user(
email='delroute@example.qc.ca',
webauthn_credentials=[{
'id': 'TARGET', 'public_key': 'p', 'sign_count': 0,
'transports': [], 'name': 'Target',
'created_at': '2026-04-27T00:00:00+00:00',
}],
)
with app.test_client() as client:
_login_session(client, user)
resp = client.post(
'/2fa/passkey/credentials/TARGET/delete',
follow_redirects=False,
)
assert resp.status_code == 302
db.session.refresh(user)
assert (user.webauthn_credentials or []) == []
finally:
db.session.rollback()
db.drop_all()
# ----------------------------------------------------------------------
# 22. Login gate extension
# ----------------------------------------------------------------------
def test_login_gate_redirects_to_2fa_when_user_has_passkey():
"""Login with valid password + has_passkeys → 302 to /2fa/verify."""
with app.app_context():
_disable_csrf()
db.create_all()
try:
password = 'CorrectHorseBattery!42'
user = _make_user(
email='gatepasskey@example.qc.ca', password=password,
totp_enabled=False,
webauthn_credentials=[{
'id': 'GATE', 'public_key': 'p', 'sign_count': 0,
'transports': [], 'name': 'Gate',
'created_at': '2026-04-27T00:00:00+00:00',
}],
)
with app.test_client() as client:
resp = client.post('/login', data={
'email': 'gatepasskey@example.qc.ca',
'password': password,
'remember': 'y',
})
assert resp.status_code == 302
assert '/2fa/verify' in resp.headers['Location']
with client.session_transaction() as sess:
assert sess.get('pending_totp_user_id') == user.id
assert sess.get('pending_totp_remember') is True
# NOT logged in yet
assert '_user_id' not in sess
finally:
db.session.rollback()
db.drop_all()