2026-09-21 12:55:35 +02:00
|
|
|
"""Runs real passkey ceremonies against a locally running Website, in headless Chrome with a virtual authenticator.
|
|
|
|
|
|
|
|
|
|
swift run Website & # serves http://localhost:8080 over the in-memory database
|
|
|
|
|
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --remote-debugging-port=9333 \\
|
|
|
|
|
--user-data-dir="$(mktemp -d)" about:blank &
|
|
|
|
|
python3 Tests/Browser/passkeys.py
|
|
|
|
|
|
|
|
|
|
Needs a fresh server: it joins accounts that must not exist yet. Stdlib only.
|
|
|
|
|
"""
|
|
|
|
|
import os, sys, time
|
|
|
|
|
|
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
|
from cdp import connect
|
|
|
|
|
|
|
|
|
|
BASE = "http://localhost:8080"
|
|
|
|
|
chrome = connect(9333)
|
|
|
|
|
chrome.send("Page.enable")
|
|
|
|
|
chrome.send("Runtime.enable")
|
|
|
|
|
chrome.send("WebAuthn.enable")
|
|
|
|
|
authenticator = chrome.send("WebAuthn.addVirtualAuthenticator", options={
|
|
|
|
|
"protocol": "ctap2", "transport": "internal", "hasResidentKey": True,
|
|
|
|
|
"hasUserVerification": True, "isUserVerified": True, "automaticPresenceSimulation": True,
|
|
|
|
|
})["authenticatorId"]
|
|
|
|
|
|
|
|
|
|
def open_sign_in(return_path):
|
|
|
|
|
chrome.send("Page.navigate", url=f"{BASE}/sign-in?return={return_path}")
|
|
|
|
|
assert chrome.wait_for("document.querySelector('[data-passkey]') && !document.querySelector('[data-passkey]').hidden"), \
|
|
|
|
|
"the passkey button never appeared"
|
|
|
|
|
|
|
|
|
|
def press(email):
|
|
|
|
|
chrome.evaluate(f"document.querySelector('#email').value = {email!r}; document.querySelector('[data-passkey]').click(); true")
|
|
|
|
|
|
|
|
|
|
def status():
|
|
|
|
|
return chrome.evaluate("document.querySelector('[data-passkey-status]')?.textContent ?? ''")
|
|
|
|
|
|
|
|
|
|
def credentials():
|
|
|
|
|
return chrome.send("WebAuthn.getCredentials", authenticatorId=authenticator)["credentials"]
|
|
|
|
|
|
|
|
|
|
results = []
|
|
|
|
|
def check(name, ok, detail=""):
|
|
|
|
|
results.append(ok)
|
|
|
|
|
print(f"{'PASS' if ok else 'FAIL'} {name}{' · ' + str(detail) if detail else ''}")
|
|
|
|
|
|
|
|
|
|
# 1. An unknown email registers a passkey and creates the account, then returns to the intent.
|
|
|
|
|
open_sign_in("/search")
|
|
|
|
|
press("sam.okafor@example.com")
|
2026-09-21 13:32:05 +02:00
|
|
|
welcomed = chrome.wait_for("location.pathname === '/welcome'")
|
|
|
|
|
check("join: registers a passkey and meets the first-sign-in screen", welcomed, status() if not welcomed else "")
|
|
|
|
|
chrome.evaluate("document.querySelector('#name').value = 'Sam Okafor'; document.querySelector('form').submit(); true")
|
2026-09-21 12:55:35 +02:00
|
|
|
landed = chrome.wait_for("location.pathname === '/search'")
|
2026-09-21 13:32:05 +02:00
|
|
|
check("join: a display name later, lands on the intent", landed, chrome.evaluate("location.pathname"))
|
|
|
|
|
check("join: the page is rendered for the new account", "Account of Sam Okafor" in chrome.evaluate("document.documentElement.outerHTML"))
|
2026-09-21 12:55:35 +02:00
|
|
|
held = credentials()
|
|
|
|
|
check("join: the authenticator holds one discoverable credential", len(held) == 1 and held[0]["isResidentCredential"], f"{len(held)} held")
|
|
|
|
|
|
|
|
|
|
# 2. Signed out, the same passkey signs in with no email at all.
|
|
|
|
|
chrome.evaluate("fetch('/sign-out', {method: 'POST'}).then(() => true)")
|
|
|
|
|
open_sign_in("/grading-guide")
|
|
|
|
|
press("")
|
|
|
|
|
landed = chrome.wait_for("location.pathname === '/grading-guide'")
|
|
|
|
|
check("sign-in: a discoverable passkey signs in without an email", landed, status() if not landed else "")
|
2026-09-21 13:32:05 +02:00
|
|
|
check("sign-in: as the same account", "Account of Sam Okafor" in chrome.evaluate("document.documentElement.outerHTML"))
|
2026-09-21 12:55:35 +02:00
|
|
|
check("sign-in: the signature counter moved on", credentials()[0]["signCount"] >= 2, credentials()[0]["signCount"])
|
|
|
|
|
|
|
|
|
|
# 3. By email, the ceremony names the account's credential.
|
|
|
|
|
chrome.evaluate("fetch('/sign-out', {method: 'POST'}).then(() => true)")
|
|
|
|
|
open_sign_in("/")
|
|
|
|
|
press("Sam.Okafor@Example.com")
|
|
|
|
|
check("sign-in: by email, normalized", chrome.wait_for("location.pathname === '/'"), status())
|
|
|
|
|
|
2026-09-21 13:32:05 +02:00
|
|
|
# 3b. Signed in, the account adds a second passkey, then revokes one.
|
|
|
|
|
chrome.send("Page.navigate", url=f"{BASE}/account")
|
|
|
|
|
assert chrome.wait_for("document.querySelector('[data-passkey=add]') && !document.querySelector('[data-passkey=add]').hidden")
|
|
|
|
|
chrome.evaluate("document.querySelector('[data-passkey=add]').click(); true")
|
|
|
|
|
added = chrome.wait_for("document.querySelectorAll('.rows__row--passkey').length === 2")
|
|
|
|
|
check("account: adds a second passkey from its own session", added and len(credentials()) == 2, status())
|
|
|
|
|
chrome.evaluate("document.querySelector('.rows__row--passkey form').submit(); true")
|
|
|
|
|
check("account: revokes one, at once", chrome.wait_for("document.querySelectorAll('.rows__row--passkey').length === 1"))
|
|
|
|
|
chrome.send("WebAuthn.clearCredentials", authenticatorId=authenticator)
|
2026-09-21 12:55:35 +02:00
|
|
|
chrome.evaluate("fetch('/sign-out', {method: 'POST'}).then(() => true)")
|
2026-09-21 13:32:05 +02:00
|
|
|
chrome.send("Page.navigate", url=f"{BASE}/")
|
|
|
|
|
chrome.wait_for("document.readyState === 'complete'")
|
|
|
|
|
anonymous = chrome.evaluate("fetch('/sign-in/passkey/options', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{\"add\": true}'}).then(r => r.status)")
|
|
|
|
|
check("refusal: nobody adds a passkey signed out", anonymous == 401, anonymous)
|
2026-09-21 13:53:13 +02:00
|
|
|
|
|
|
|
|
# 3c. Every passkey lost, a one-time link sets up a new one — which replaces the others, and signs in.
|
|
|
|
|
link = chrome.evaluate("""fetch('/recover', {method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: 'email=sam.okafor%40example.com'})
|
|
|
|
|
.then(r => r.text()).then(html => (html.match(/\\/recover\\/[0-9a-f]+/) || [null])[0])""")
|
|
|
|
|
check("recover: a known email gets a one-time link", bool(link), link)
|
|
|
|
|
chrome.send("Page.navigate", url=f"{BASE}{link}")
|
|
|
|
|
assert chrome.wait_for("document.querySelector('[data-passkey=recover]') && !document.querySelector('[data-passkey=recover]').hidden")
|
|
|
|
|
chrome.evaluate("document.querySelector('[data-passkey=recover]').click(); true")
|
|
|
|
|
recovered = chrome.wait_for("location.pathname === '/account'")
|
|
|
|
|
check("recover: the new passkey signs in, on the account screen", recovered, status() if not recovered else "")
|
|
|
|
|
check("recover: it replaced every other passkey", chrome.wait_for("document.querySelectorAll('.rows__row--passkey').length === 1"))
|
|
|
|
|
chrome.send("Page.navigate", url=f"{BASE}{link}")
|
|
|
|
|
check("recover: the link works once", chrome.wait_for("document.body.textContent.includes('That link has run out')"))
|
|
|
|
|
chrome.evaluate("fetch('/sign-out', {method: 'POST'}).then(() => true)")
|
|
|
|
|
held_before = len(credentials())
|
2026-09-21 13:32:05 +02:00
|
|
|
|
|
|
|
|
# 4. A demo account has no passkey, and none can be added to it.
|
2026-09-21 12:55:35 +02:00
|
|
|
open_sign_in("/")
|
|
|
|
|
press("mara.v@example.com")
|
|
|
|
|
refused = chrome.wait_for("(document.querySelector('[data-passkey-status]')?.textContent ?? '').includes('no passkey')")
|
2026-09-21 13:32:05 +02:00
|
|
|
check("refusal: an existing account is never handed a new passkey", refused and len(credentials()) == held_before, status())
|
2026-09-21 12:55:35 +02:00
|
|
|
check("refusal: nobody was signed in", chrome.evaluate("location.pathname") == "/sign-in")
|
|
|
|
|
|
|
|
|
|
sys.exit(0 if all(results) else 1)
|