crates/gunnar-ui-server/src/passkeys_at_gunnar/first_touch.rs
//! ★ **The first touch after paying, in a real browser** (A29-20; the owner, 2026-10-22:
//! "passkey request was cancelled … Payment received — your account x14 is ready").
//!
//! The console's own router (the welcome page, the login page as a metered build serves it,
//! `/paid/…`) on loopback over the fake gunnar and the fake monetize, in headless Chromium with
//! a virtual authenticator. Chromium is driven over the DevTools protocol on a pipe
//! (`--remote-debugging-pipe`), so the test needs nothing beyond the standard library and
//! `serde_json`. Needs `CHROME` (a Chromium binary, as the browser image `browser/Containerfile`
//! sets it). Without it each test says NOT RUN and returns, except under CI's `browser` step
//! (`cargo xtask browser`, which sets `GUNNAR_UI_NEEDS_BROWSER=1`), where it is RED (RA2-7).
//!
//! Two things are held:
//!
//! * **The first passkey is made inside the press, and the sign-in after it too.** Right after
//! paying, gunnar is slow to answer the registration's start (held back 6 s, longer than
//! Chromium keeps a press live). The ceremony watch ([`WATCH`]) writes down every ceremony
//! and refuses one started without a live gesture, as Safari does. Exactly one registration
//! and one sign-in, each inside the person's gesture, no two at once, no navigation while one
//! runs; the person ends signed in.
//! * **Back from the vendor, signed in, with no touch.** The session cookie is
//! `SameSite=Strict`, so the vendor's top-level hop back to `/paid/<reference>` carries none
//! and the console answers it with `/login?next=`. The login page's own ask is same-site and
//! carries the cookie, so a live session is sent straight on to the order's page
//! (`alreadyIn`): no ceremony at all. (A32-10 named this hop as a second lead for A29-20;
//! measured here, it starts no ceremony.)
use std::io::{BufRead as _, BufReader, Write as _};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant};
use serde_json::{json, Value};
use super::{console_selling_at, Fake};
use crate::auth::signup::tests::{FakeMonetize, SESSION};
/// Longer than Chromium keeps a press live (5 s).
const SLOW: Duration = Duration::from_secs(6);
/// The ceremony watch, put before every document's own script: each ceremony's kind, whether
/// the person's gesture was live (`navigator.userActivation.isActive`), whether another one
/// was running, and whether the page navigated while one ran, kept in the tab's
/// `sessionStorage`. One started with no live gesture is refused as Safari refuses it.
const WATCH: &str = r#"(() => {
if (!navigator.credentials || window.__ceremoniesWatched) return;
window.__ceremoniesWatched = true;
const KEY = '__ceremonies';
const load = () => { try { return JSON.parse(sessionStorage.getItem(KEY) || '[]'); } catch (e) { return []; } };
const keep = (all) => { try { sessionStorage.setItem(KEY, JSON.stringify(all)); } catch (e) {} };
let running = 0;
addEventListener('pagehide', () => {
if (!running) return;
const all = load();
if (all.length) { all[all.length - 1].navigated = true; keep(all); }
});
for (const kind of ['create', 'get']) {
const real = navigator.credentials[kind].bind(navigator.credentials);
navigator.credentials[kind] = (options) => {
if (!options || !options.publicKey) return real(options);
const all = load();
all.push({ kind, gesture: !!(navigator.userActivation && navigator.userActivation.isActive), overlap: running > 0, navigated: false, path: location.pathname, allow: (options.publicKey.allowCredentials || []).length, outcome: '' });
keep(all);
const at = all.length - 1;
const settle = (outcome) => { const now = load(); if (now[at]) { now[at].outcome = outcome; keep(now); } };
if (!all[at].gesture) {
settle('NotAllowedError');
return Promise.reject(new DOMException('The request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission.', 'NotAllowedError'));
}
running += 1;
return real(options).then((c) => { running -= 1; settle('ok'); return c; }, (e) => { running -= 1; settle((e && e.name) || 'error'); throw e; });
};
}
})();"#;
/// **Chromium on a DevTools pipe**: fd 3 is what it reads, fd 4 what it writes, each message
/// one JSON object ended by a NUL. One page, attached flat (`sessionId`).
pub(crate) struct Chromium {
child: Child,
stdin: Mutex<ChildStdin>,
answers: Arc<(Mutex<Vec<Value>>, Condvar)>,
next: Mutex<u64>,
session: String,
/// The virtual authenticator's id (`WebAuthn.addCredential` plants a passkey on it).
authenticator: String,
_profile: tempfile::TempDir,
}
impl Chromium {
pub(crate) fn launch(chrome: &str) -> Chromium {
let profile = tempfile::tempdir().expect("a profile");
// The shell moves the pipes to the descriptors the protocol names; nothing else.
let mut child = Command::new("sh")
.arg("-c")
.arg("exec \"$0\" \"$@\" 3<&0 4>&1 </dev/null >/dev/null")
.arg(chrome)
.args(["--headless=new", "--remote-debugging-pipe", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage", "--no-first-run", "--no-default-browser-check"])
.arg(format!("--user-data-dir={}", profile.path().display()))
.arg("about:blank")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("chromium starts");
let stdin = child.stdin.take().expect("its pipe in");
let stdout = child.stdout.take().expect("its pipe out");
let answers: Arc<(Mutex<Vec<Value>>, Condvar)> = Arc::default();
let into = answers.clone();
std::thread::spawn(move || {
let mut read = BufReader::new(stdout);
loop {
let mut buf = Vec::new();
match read.read_until(0, &mut buf) {
Ok(0) | Err(_) => return,
Ok(_) => {}
}
buf.pop();
if let Ok(v) = serde_json::from_slice::<Value>(&buf) {
if v.get("id").is_some() {
into.0.lock().expect("answers").push(v);
into.1.notify_all();
}
}
}
});
let mut c = Chromium { child, stdin: Mutex::new(stdin), answers, next: Mutex::new(0), session: String::new(), authenticator: String::new(), _profile: profile };
let targets = c.call("Target.getTargets", json!({}));
let page = targets["targetInfos"]
.as_array()
.and_then(|t| t.iter().find(|t| t["type"] == "page"))
.map(|t| t["targetId"].as_str().unwrap_or_default().to_owned());
let page = match page {
Some(p) => p,
None => c.call("Target.createTarget", json!({ "url": "about:blank" }))["targetId"].as_str().unwrap_or_default().to_owned(),
};
let attached = c.call("Target.attachToTarget", json!({ "targetId": page, "flatten": true }));
c.session = attached["sessionId"].as_str().expect("a session").to_owned();
c.on_page("Page.enable", json!({}));
c.on_page("Page.addScriptToEvaluateOnNewDocument", json!({ "source": WATCH }));
c.on_page("WebAuthn.enable", json!({ "enableUI": false }));
let added = c.on_page(
"WebAuthn.addVirtualAuthenticator",
json!({ "options": { "protocol": "ctap2", "transport": "internal", "hasResidentKey": true, "hasUserVerification": true, "isUserVerified": true, "automaticPresenceSimulation": true } }),
);
c.authenticator = added["authenticatorId"].as_str().expect("the authenticator's id").to_owned();
c
}
fn send(&self, method: &str, params: Value, session: Option<&str>) -> Value {
match self.try_send(method, params, session) {
Ok(v) => v,
Err(v) => panic!("{method}: {v}"),
}
}
/// [`Self::send`], its error answer handed back.
fn try_send(&self, method: &str, params: Value, session: Option<&str>) -> Result<Value, Value> {
let id = {
let mut n = self.next.lock().expect("ids");
*n += 1;
*n
};
let mut msg = json!({ "id": id, "method": method, "params": params });
if let Some(s) = session {
msg["sessionId"] = json!(s);
}
let mut bytes = serde_json::to_vec(&msg).expect("json");
bytes.push(0);
self.stdin.lock().expect("the pipe").write_all(&bytes).expect("chromium reads");
let end = Instant::now() + Duration::from_secs(60);
let (lock, ready) = &*self.answers;
let mut got = lock.lock().expect("answers");
loop {
if let Some(at) = got.iter().position(|v| v["id"] == id) {
let v = got.remove(at);
if v.get("error").is_some() {
return Err(v);
}
return Ok(v["result"].clone());
}
let left = end.saturating_duration_since(Instant::now());
assert!(!left.is_zero(), "{method}: no answer within 60 s");
got = ready.wait_timeout(got, left).expect("answers").0;
}
}
fn call(&self, method: &str, params: Value) -> Value {
self.send(method, params, None)
}
pub(crate) fn on_page(&self, method: &str, params: Value) -> Value {
self.send(method, params, Some(&self.session.clone()))
}
pub(crate) fn open(&self, url: &str) {
self.on_page("Page.navigate", json!({ "url": url }));
}
/// `js` (an expression; a promise is awaited) as JSON; `Null` while a page is between documents.
pub(crate) fn js(&self, js: &str) -> Value {
match self.try_send("Runtime.evaluate", json!({ "expression": js, "returnByValue": true, "awaitPromise": true }), Some(&self.session.clone())) {
Ok(r) => r["result"]["value"].clone(),
// Asked while the page went to its next document: nothing to read yet.
Err(e) if e.to_string().contains("navigated or closed") || e.to_string().contains("context was destroyed") => Value::Null,
Err(e) => panic!("Runtime.evaluate: {e}"),
}
}
pub(crate) fn until(&self, what: &str, js: &str) {
let end = Instant::now() + Duration::from_secs(30);
while Instant::now() < end {
if self.js(js) == Value::Bool(true) {
return;
}
std::thread::sleep(Duration::from_millis(100));
}
panic!("{what}: not seen within 30 s at {}\n--- the page says ---\n{}", self.js("location.href"), self.text());
}
pub(crate) fn holds(&self, js: &str, within: Duration) -> bool {
let end = Instant::now() + within;
while Instant::now() < end {
if self.js(js) == Value::Bool(true) {
return true;
}
std::thread::sleep(Duration::from_millis(100));
}
false
}
pub(crate) fn text(&self) -> String {
self.js("document.body ? document.body.innerText : ''").as_str().unwrap_or_default().to_owned()
}
/// **Press** the shown, enabled `selector` as a person does: a real mouse press and release at
/// its middle, so the page holds the person's gesture.
pub(crate) fn press(&self, selector: &str) {
let sel = serde_json::to_string(selector).expect("json");
let find = format!("[...document.querySelectorAll({sel})].find(x => x.offsetParent !== null && !x.disabled)");
self.until(&format!("{selector} shown and enabled"), &format!("!!({find})"));
let at = self.js(&format!("(() => {{ const e = {find}; e.scrollIntoView({{block: 'center'}}); const r = e.getBoundingClientRect(); return [r.x + r.width / 2, r.y + r.height / 2]; }})()"));
let (x, y) = (at[0].as_f64().expect("x"), at[1].as_f64().expect("y"));
for kind in ["mousePressed", "mouseReleased"] {
self.on_page("Input.dispatchMouseEvent", json!({ "type": kind, "x": x, "y": y, "button": "left", "clickCount": 1 }));
}
}
/// **Move the mouse onto** the shown `selector` (a person reaching for the button).
fn hover(&self, selector: &str) {
let sel = serde_json::to_string(selector).expect("json");
let find = format!("[...document.querySelectorAll({sel})].find(x => x.offsetParent !== null)");
self.until(&format!("{selector} shown"), &format!("!!({find})"));
let at = self.js(&format!("(() => {{ const e = {find}; e.scrollIntoView({{block: 'center'}}); const r = e.getBoundingClientRect(); return [r.x + r.width / 2, r.y + r.height / 2]; }})()"));
let (x, y) = (at[0].as_f64().expect("x"), at[1].as_f64().expect("y"));
self.on_page("Input.dispatchMouseEvent", json!({ "type": "mouseMoved", "x": x, "y": y }));
}
fn ceremonies(&self) -> Vec<Value> {
self.js("JSON.parse(sessionStorage.getItem('__ceremonies') || '[]')").as_array().cloned().unwrap_or_default()
}
}
impl Drop for Chromium {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
/// One Chromium at a time: three at once on a loaded box let a 5 s press lapse on its own.
pub(crate) static ONE_BROWSER: Mutex<()> = Mutex::new(());
/// Set by CI's `browser` step (`cargo xtask browser`): the browser MUST be here.
const NEEDS_BROWSER: &str = "GUNNAR_UI_NEEDS_BROWSER";
pub(crate) fn chrome() -> Option<String> {
browser_for(std::env::var("CHROME").ok(), std::env::var(NEEDS_BROWSER).is_ok_and(|v| v == "1"))
}
/// The Chromium to drive, or `None` (NOT RUN) when there is none.
fn browser_for(chrome: Option<String>, required: bool) -> Option<String> {
let c = chrome.filter(|c| !c.is_empty());
if c.is_none() && required {
panic!("{NEEDS_BROWSER}=1 but CHROME names no Chromium: the browser step must run the first touch, never pass it as NOT RUN");
}
if c.is_none() {
eprintln!("NOT RUN: set CHROME to a Chromium binary for the first touch after paying");
}
c
}
/// RA2-7: where the CI's browser step runs these tests, a missing browser is RED, never a
/// green NOT RUN; elsewhere (a plain `cargo test`) it stays a named NOT RUN.
#[test]
#[should_panic(expected = "GUNNAR_UI_NEEDS_BROWSER")]
fn no_browser_is_red_where_the_browser_step_requires_one() {
browser_for(None, true);
}
#[test]
fn no_browser_is_a_named_not_run_elsewhere_and_a_browser_is_used() {
assert_eq!(browser_for(None, false), None);
assert_eq!(browser_for(Some(String::new()), false), None);
assert_eq!(browser_for(Some("/usr/bin/chromium".into()), true).as_deref(), Some("/usr/bin/chromium"));
}
/// The console on `http://localhost:<port>` (a secure context), its relying party `localhost`,
/// over the fake gunnar (bob holds no passkey yet) and the fake monetize (bob's paid sign-up).
async fn console_on_loopback() -> (String, u16, Fake) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("a loopback port");
let port = listener.local_addr().expect("its address").port();
let base = format!("http://localhost:{port}");
let fake = Fake::new();
fake.person("bob", None);
*fake.origin.lock().expect("the origin") = Some(base.clone());
let rig = console_selling_at(fake.clone(), Some(FakeMonetize::created("bob")), "localhost", &base).await;
let app = rig.router.clone();
tokio::spawn(async move { axum::serve(listener, app.into_make_service_with_connect_info::<std::net::SocketAddr>()).await });
// The rig's own handles stay alive with the test (its store, its gunnar).
std::mem::forget(rig);
(base, port, fake)
}
/// Back from paying (the welcome page), the passkey step, and the first touch: one registration
/// and one sign-in, each inside the person's press.
fn first_touch(c: &Chromium, base: &str) {
c.open(&format!("{base}/welcome?session={SESSION}"));
c.until("the passkey step", "location.pathname === '/login' && !!document.getElementById('register') && document.getElementById('register').textContent === 'Touch your security key'");
assert!(c.text().contains("Payment received — your account bob is ready. Touch your security key to finish."), "{}", c.text());
// The person reads the line and presses once the button is offered.
std::thread::sleep(SLOW + Duration::from_secs(1));
c.press("#register");
// Signed in, the login page goes on to `next` (`/`: the console, which this rig does not
// carry); the record is read back on a page of this origin that stays put.
let went_on = c.holds("location.pathname === '/'", Duration::from_secs(30));
let said = c.text();
c.open(&format!("{base}/signup"));
c.until("a page of the console", "document.readyState === 'complete' && location.pathname === '/signup'");
let signed_in = went_on && c.holds("fetch('/auth/status').then(r => r.json()).then(s => s.authenticated === true)", Duration::from_secs(10));
let ceremonies = c.ceremonies();
assert!(signed_in, "the first touch after paying did not sign bob in.\nceremonies: {ceremonies:?}\n--- the page said ---\n{said}");
assert_eq!(ceremonies.iter().filter(|c| c["kind"] == "create").count(), 1, "one registration: {ceremonies:?}");
assert_eq!(ceremonies.iter().filter(|c| c["kind"] == "get").count(), 1, "one sign-in, the passkey just made: {ceremonies:?}");
// The sign-in names the passkey just made and no other, so the device offers no choice.
assert!(ceremonies.iter().filter(|c| c["kind"] == "get").all(|c| c["allow"] == 1), "the sign-in names the passkey just made: {ceremonies:?}");
for one in &ceremonies {
assert_eq!(one["gesture"], true, "a ceremony started without the person's press: {one} (all: {ceremonies:?})");
assert_eq!(one["overlap"], false, "two ceremonies at once: {one}");
assert_eq!(one["navigated"], false, "the page navigated while a ceremony ran: {one}");
assert_eq!(one["outcome"], "ok", "{one}");
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_first_touch_after_paying_registers_and_signs_in_inside_the_press() {
let Some(chrome) = chrome() else { return };
let (base, _, fake) = console_on_loopback().await;
// Right after paying gunnar is slow to open the registration (its first challenge).
fake.challenge_delays.lock().expect("the delays").push_back(SLOW);
tokio::task::spawn_blocking(move || {
let _one = ONE_BROWSER.lock().unwrap_or_else(|p| p.into_inner());
let c = Chromium::launch(&chrome);
first_touch(&c, &base);
})
.await
.expect("the browser half");
assert_eq!(fake.registrations.lock().expect("registrations").len(), 1, "gunnar registered one passkey");
assert_eq!(fake.registrations.lock().expect("registrations")[0].principal_id, "bob");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_first_touch_back_from_the_vendor_finds_the_buyer_signed_in() {
let Some(chrome) = chrome() else { return };
let (base, port, fake) = console_on_loopback().await;
fake.challenge_delays.lock().expect("the delays").push_back(SLOW);
tokio::task::spawn_blocking(move || {
let _one = ONE_BROWSER.lock().unwrap_or_else(|p| p.into_inner());
let c = Chromium::launch(&chrome);
first_touch(&c, &base);
// ── signed in, bob buys more room; the vendor sends him back, from ITS site ──
// `127.0.0.1` is another site than `localhost`: a link pressed there is the vendor's
// top-level hop, and a SameSite=Strict cookie does not ride it.
c.open(&format!("http://127.0.0.1:{port}/login"));
c.until("the vendor's page", "document.readyState === 'complete'");
let back = format!("{base}/paid/ord-bob-1");
c.js(&format!("(() => {{ const a = document.createElement('a'); a.id = 'back'; a.href = {}; a.textContent = 'Back to the shop'; document.body.prepend(a); return true; }})()", serde_json::to_string(&back).unwrap()));
c.press("#back");
c.until("the way back lands", "location.host.startsWith('localhost') && document.readyState === 'complete'");
let at = c.js("location.pathname").as_str().unwrap_or_default().to_owned();
assert_eq!(at, "/paid/ord-bob-1", "back from the vendor the order's page opens, no fresh login: {}\n{}", c.js("location.href"), c.text());
assert!(
c.holds("fetch('/auth/status').then(r => r.json()).then(s => s.authenticated === true)", Duration::from_secs(10)),
"the order page's own asks carry bob's session"
);
assert_eq!(c.ceremonies().iter().filter(|c| c["kind"] == "get").count(), 1, "no second sign-in ceremony: {:?}", c.ceremonies());
})
.await
.expect("the browser half");
}
/// ★ UI-ENC: the repository encryption switch's info box, in the same browser (its driver).
mod encryption_info;
/// ★ SHADOW-SEL: a selected README shows its words once, in the same browser (its driver).
mod readme_selection;
/// ★ LOGIN-1: `/login` is the login, in the same browser (its driver).
mod login_is_login;
/// ★ RA2-17: the sign-in right after the first passkey, and the moved passkey's one more touch,
/// in the same browser (its driver).
mod sign_in_after;
/// ★ RA2-6: **the plain Log in starts its passkey inside the press.** A person who already holds
/// a passkey comes back signed out and presses Log in while gunnar is slow to hand out the
/// start (6 s, longer than a press stays live). The start is asked when the person reaches for
/// the button (hover or focus), never on every anonymous view; a press that finds it still on
/// its way waits and, with the gesture gone, asks for one more press instead of starting a
/// ceremony nobody pressed for. The next press signs in at once.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_plain_log_in_starts_its_passkey_inside_the_press() {
let Some(chrome) = chrome() else { return };
let (base, _, fake) = console_on_loopback().await;
let slow = fake.challenge_delays.clone();
tokio::task::spawn_blocking(move || {
let _one = ONE_BROWSER.lock().unwrap_or_else(|p| p.into_inner());
let c = Chromium::launch(&chrome);
first_touch(&c, &base);
let before = c.ceremonies().len();
c.on_page("Network.clearBrowserCookies", json!({}));
c.open(&format!("{base}/login"));
c.until("the login page, signed out", "document.readyState === 'complete' && location.pathname === '/login' && !!document.getElementById('login') && document.getElementById('login').offsetParent !== null");
std::thread::sleep(Duration::from_millis(500));
slow.lock().expect("the delays").push_back(SLOW);
c.hover("#login");
std::thread::sleep(Duration::from_millis(200));
c.press("#login");
let again = c.holds("document.body.innerText.includes('Ready. Touch your security key again.')", SLOW + Duration::from_secs(8));
let said = c.text();
let now: Vec<Value> = c.ceremonies().into_iter().skip(before).collect();
assert!(now.iter().all(|one| one["gesture"] == true), "a sign-in ceremony started without the person's press: {now:?}\n--- the page said ---\n{said}");
assert!(again, "the slow start did not ask for one more press.\nceremonies: {now:?}\n--- the page said ---\n{said}");
c.press("#login");
let went_on = c.holds("location.pathname === '/'", Duration::from_secs(20));
// The record is read back on a page of this origin that stays put (`/` is the console,
// which this rig does not carry).
c.open(&format!("{base}/signup"));
c.until("a page of the console", "document.readyState === 'complete' && location.pathname === '/signup'");
let now: Vec<Value> = c.ceremonies().into_iter().skip(before).collect();
assert!(went_on, "the second press did not sign in: {now:?}\n{}", c.text());
assert_eq!(now.iter().filter(|c| c["kind"] == "get").count(), 1, "one sign-in ceremony: {now:?}");
for one in &now {
assert_eq!((one["gesture"].clone(), one["outcome"].clone()), (json!(true), json!("ok")), "{one}");
}
})
.await
.expect("the browser half");
}