"""Passkeys through the HTTP endpoints, with a real authenticator on the end. `test_passkeys.py` covers the ceremonies; this covers what the browser actually talks to — the CSRF, the cookies, the redirects, and the one behaviour that makes passkeys worth having: signing in without the password or the code. Requests are made over `https://` because WebAuthn is refused outside a secure context, and the app refuses to offer it there too. """ from __future__ import annotations import json import pytest from sqlalchemy import select from test_passkeys import ORIGIN, RP_ID, VerifyingDevice, _as_json, _for_device import factories as fac from app import twofactor from app.models import User, WebauthnCredential from test_routes import PASSWORD, make_login, pre_auth_csrf, session_csrf HTML = {"accept": "text/html"} @pytest.fixture def secure(app_module, session_factory): """A client on HTTPS, with passkeys configured as a deployment would.""" from fastapi.testclient import TestClient wa = app_module.settings.auth.webauthn before = (wa.enabled, wa.rp_id, wa.rp_name, list(wa.origins)) wa.enabled, wa.rp_id, wa.rp_name, wa.origins = True, RP_ID, "Stocktake", [ORIGIN] original_local = app_module.SessionLocal original_state = app_module.app.state.session_factory app_module.SessionLocal = session_factory app_module.app.state.session_factory = session_factory try: yield TestClient(app_module.app, base_url=ORIGIN) finally: wa.enabled, wa.rp_id, wa.rp_name, wa.origins = before app_module.SessionLocal = original_local app_module.app.state.session_factory = original_state def _add_passkey(client, session_factory, device=None): """Enrol through the two real endpoints, as the script does.""" device = device or VerifyingDevice() csrf = session_csrf(session_factory) options = client.post("/profile/passkeys/options", data={"_csrf": csrf}).json() attestation = device.create(_for_device(json.dumps(options["options"])), ORIGIN) resp = client.post("/profile/passkeys", follow_redirects=False, data={ "_csrf": csrf, "token": options["token"], "credential": _as_json(attestation), "name": "My phone"}) return device, resp def _sign_in_with(client, device): csrf = pre_auth_csrf(client) options = client.post("/login/passkey/options", data={"_csrf": csrf}).json() assertion = device.get(_for_device(json.dumps(options["options"])), ORIGIN) return client.post("/login/passkey", data={ "_csrf": csrf, "token": options["token"], "credential": _as_json(assertion)}) # --------------------------------------------------------------------------- # # Enrolling # --------------------------------------------------------------------------- # def test_a_passkey_can_be_added_from_the_account(secure, session_factory): make_login(secure, session_factory) _, resp = _add_passkey(secure, session_factory) assert resp.headers["location"] == "/profile/passkeys?saved=added" with session_factory() as db: row = db.scalar(select(WebauthnCredential)) assert row.name == "My phone" page = secure.get("/profile/passkeys", headers=HTML).text assert "My phone" in page def test_the_account_page_reports_passkeys_as_a_way_in(secure, session_factory): """The whole point of the methods table: every way into this account and whether it is on, without having to infer it from which forms appear.""" make_login(secure, session_factory) import re def row() -> str: page = secure.get("/profile", headers=HTML).text table = re.search(r'.*?
', page, re.S).group(0) return re.search(r"(?:(?!).)*Passkeys.*?", table, re.S).group(0) assert ">Off<" in row() _add_passkey(secure, session_factory) assert ">On<" in row() and "Manage" in row() def test_a_passkey_can_be_removed(secure, session_factory): make_login(secure, session_factory) _add_passkey(secure, session_factory) with session_factory() as db: key_id = db.scalar(select(WebauthnCredential)).id secure.post(f"/profile/passkeys/{key_id}/remove", follow_redirects=False, data={"_csrf": session_csrf(session_factory)}) with session_factory() as db: assert db.scalar(select(WebauthnCredential)) is None def test_one_person_cannot_remove_another_persons_passkey(secure, session_factory): """The id comes from a form, so it must never reach a row that is not theirs — scoping the delete to the signed-in user is the whole defence.""" make_login(secure, session_factory) _add_passkey(secure, session_factory) with session_factory() as db: key_id = db.scalar(select(WebauthnCredential)).id secure.post("/logout", data={"_csrf": session_csrf(session_factory)}, follow_redirects=False) make_login(secure, session_factory, email="them@example.test") secure.post(f"/profile/passkeys/{key_id}/remove", follow_redirects=False, data={"_csrf": session_csrf(session_factory)}) with session_factory() as db: assert db.get(WebauthnCredential, key_id) is not None # --------------------------------------------------------------------------- # # Signing in # --------------------------------------------------------------------------- # def test_a_passkey_signs_in_with_no_password(secure, session_factory): make_login(secure, session_factory) device, _ = _add_passkey(secure, session_factory) secure.post("/logout", data={"_csrf": session_csrf(session_factory)}, follow_redirects=False) assert secure.get("/", headers=HTML, follow_redirects=False).status_code == 303 resp = _sign_in_with(secure, device) assert resp.status_code == 200, resp.text assert resp.json()["next"] == "/" assert secure.get("/", headers=HTML, follow_redirects=False).status_code == 200 def test_a_passkey_does_not_stop_at_the_code_step(secure, session_factory): """The behaviour that makes them worth having. A password sign-in with two-factor on lands on `/login/code` holding a session that `load_session` refuses. A passkey has already proved both factors — possession of the authenticator and a biometric or PIN — so stopping to ask for a code would be asking for a third. decisions.md #113. """ make_login(secure, session_factory) device, _ = _add_passkey(secure, session_factory) with session_factory() as db: user = db.scalar(select(User)) twofactor.enable(db, user, "JBSWY3DPEHPK3PXP") db.commit() secure.post("/logout", data={"_csrf": session_csrf(session_factory)}, follow_redirects=False) # The password route does stop, which is what makes the comparison mean # something rather than proving two-factor was simply off. token = pre_auth_csrf(secure) password_login = secure.post( "/login", follow_redirects=False, headers=HTML, data={"email": "user@example.test", "password": PASSWORD, "_csrf": token}) assert password_login.headers["location"] == "/login/code" assert _sign_in_with(secure, device).json()["next"] == "/" assert secure.get("/", headers=HTML, follow_redirects=False).status_code == 200 def test_an_unregistered_key_is_refused(secure, session_factory): make_login(secure, session_factory) _add_passkey(secure, session_factory) secure.post("/logout", data={"_csrf": session_csrf(session_factory)}, follow_redirects=False) stranger = VerifyingDevice() stranger.cred_init(RP_ID, b"stranger") resp = _sign_in_with(secure, stranger) assert resp.status_code == 400 assert resp.json() == {"error": "That passkey is not registered here."} assert secure.get("/", headers=HTML, follow_redirects=False).status_code == 303 def test_a_registration_that_does_not_verify_says_so_in_the_apps_words( secure, session_factory, caplog): """The library's own reason stays in the log, where whoever runs the install can read it. The person adding a key gets one sentence: an exception's text is not written for them, and CodeQL flags it reaching a page for that reason.""" make_login(secure, session_factory) csrf = session_csrf(session_factory) options = secure.post("/profile/passkeys/options", data={"_csrf": csrf}).json() attestation = VerifyingDevice().create(_for_device(json.dumps(options["options"])), "https://elsewhere.example.test") with caplog.at_level("WARNING"): resp = secure.post("/profile/passkeys", follow_redirects=False, data={ "_csrf": csrf, "token": options["token"], "credential": _as_json(attestation), "name": "My phone"}) assert resp.headers["location"] == ( "/profile/passkeys?error=That+passkey+could+not+be+registered.") assert "elsewhere.example.test" in caplog.text with session_factory() as db: assert db.scalar(select(WebauthnCredential)) is None # --------------------------------------------------------------------------- # # When they cannot be offered # --------------------------------------------------------------------------- # def test_plain_http_is_offered_nothing(client, app_module, session_factory): """A browser refuses WebAuthn outside a secure context, so a button here would fail inside the browser where the reason is invisible.""" wa = app_module.settings.auth.webauthn before = (wa.enabled, wa.rp_id, list(wa.origins)) wa.enabled, wa.rp_id, wa.origins = True, RP_ID, [ORIGIN] try: # The account comes first: with none, `/login` redirects into the setup # wizard, and the assertion below would pass by reading that page. make_login(client, session_factory) assert client.post("/profile/passkeys/options", data={ "_csrf": session_csrf(session_factory)}).status_code == 400 assert "HTTPS" in client.get("/profile/passkeys", headers=HTML).text client.post("/logout", data={"_csrf": session_csrf(session_factory)}, follow_redirects=False) assert "passkeysignin" not in client.get("/login", headers=HTML).text finally: wa.enabled, wa.rp_id, wa.origins = before def test_the_login_page_offers_a_passkey_when_it_can(secure, session_factory): make_login(secure, session_factory) secure.post("/logout", data={"_csrf": session_csrf(session_factory)}, follow_redirects=False) assert "passkeysignin" in secure.get("/login", headers=HTML).text def test_the_options_endpoints_need_a_csrf_token(secure, session_factory): make_login(secure, session_factory) assert secure.post("/profile/passkeys/options", data={"_csrf": "wrong"} ).status_code in (400, 403) assert secure.post("/login/passkey/options", data={"_csrf": "wrong"} ).status_code in (400, 403) # --------------------------------------------------------------------------- # # When the login page offers a passkey # --------------------------------------------------------------------------- # # Both halves are needed: configured by the deployment, and reached over HTTPS, # because a browser refuses the ceremony outside a secure context. Nothing # loaded the login page to see which: offering it on plain HTTP passed. def _offers_passkeys(browser) -> bool: return "/static/passkeys.js" in browser.get("/login", headers=HTML).text def test_the_login_page_offers_a_passkey_only_when_one_can_work(secure, client, session_factory, app_module): with session_factory() as s: fac.make_user(s, "user@example.test") s.commit() assert _offers_passkeys(secure) is True # configured, over HTTPS assert _offers_passkeys(client) is False # configured, plain HTTP app_module.settings.auth.webauthn.enabled = False # restored by `secure` assert _offers_passkeys(secure) is False # HTTPS, not configured