From cb67d97b0f9dfbe7a00bda3e8a15a6be90018032 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sun, 19 Jul 2026 23:24:18 +0200 Subject: [PATCH] added webgtk recaptcha --- Cargo.lock | 105 ++++++++++++++-- Cargo.toml | 4 +- crates/pdfs-cli/src/main.rs | 13 +- crates/pdfs-core/src/auth.rs | 80 ++++++++++++- crates/pdfs-core/src/error.rs | 14 ++- crates/pdfs-gui/Cargo.toml | 1 + crates/pdfs-gui/src/app/main.rs | 1 + crates/pdfs-gui/src/app/pages/login.rs | 72 ++++++++--- crates/pdfs-gui/src/app/pages/mod.rs | 1 + crates/pdfs-gui/src/app/pages/verify.rs | 151 ++++++++++++++++++++++++ 10 files changed, 407 insertions(+), 35 deletions(-) create mode 100644 crates/pdfs-gui/src/app/pages/verify.rs diff --git a/Cargo.lock b/Cargo.lock index 27c0737..85d151f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1567,7 +1567,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2697,6 +2697,29 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "javascriptcore6" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03b28ed9c7c08f906b2a51bc2365eae2ba5e7db1249b89892f7ae4cbd602d1f4" +dependencies = [ + "glib", + "javascriptcore6-sys", + "libc", +] + +[[package]] +name = "javascriptcore6-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4741e2a31c2145050dd4971f8dd51e92c840d5839a7124cc68a33c7325523a12" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "jni" version = "0.21.1" @@ -3179,7 +3202,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3558,6 +3581,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "webkit6", ] [[package]] @@ -3805,9 +3829,9 @@ dependencies = [ [[package]] name = "proton-drive-rs" -version = "0.1.10" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91976e4b6427b43ec8db405ee277d88d2c17d67d08bd5106a0e519a7d33dda79" +checksum = "9a76197ffe7c9588c0bc1fa38a51e8aa8642b10e81ec16272d4962bd60525920" dependencies = [ "async-trait", "base64", @@ -3832,9 +3856,9 @@ dependencies = [ [[package]] name = "proton-sdk" -version = "0.1.10" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f75bc4244b97c64255268d2a22a0045d3dceb35f9fa99bb3854a7aced9e92c7" +checksum = "805df5ff2805e0e935ac7f5a237482231208a706d8c6ab3dd91157ae2e95c9f6" dependencies = [ "aes-gcm 0.11.0", "async-trait", @@ -3951,7 +3975,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -4366,7 +4390,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4423,7 +4447,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4778,6 +4802,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "soup3" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b84ccd1f4aee0854a16b0b489ba843798e2eb4cdcddd4a61248f7db9ce8b6df1" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8869997193d52a61a1db48627bdaa57343f76e2c5132ee6d351245a6ab30631e" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "spin" version = "0.9.8" @@ -4890,7 +4940,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5477,6 +5527,39 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webkit6" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c359ef247305dcade3363c281c505b943e0e6162a42eac76ff76ed8e7cebfbd" +dependencies = [ + "gdk4", + "gio", + "glib", + "gtk4", + "javascriptcore6", + "libc", + "soup3", + "webkit6-sys", +] + +[[package]] +name = "webkit6-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96284c5280af5984dbdae8dae3cfeea11b44b214f9bd42b35c0ca75903bccce2" +dependencies = [ + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk4-sys", + "javascriptcore6-sys", + "libc", + "soup3-sys", + "system-deps", +] + [[package]] name = "webpki-root-certs" version = "1.0.8" @@ -5523,7 +5606,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index e495213..e3fe9b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,8 +10,8 @@ authors = ["Nils Pukropp "] rust-version = "1.96" [workspace.dependencies] -proton-sdk = { version = "0.1.10" } -proton-drive-rs = { version = "0.1.10" } +proton-sdk = { version = "0.1" } +proton-drive-rs = { version = "0.1" } # Internal crates. pdfs-core = { path = "crates/pdfs-core" } diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index 25ab446..79f286a 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -817,8 +817,19 @@ fn cmd_login(username: Option) -> Result<()> { }; let rt = tokio::runtime::Runtime::new()?; + // Solving a CAPTCHA needs a browser engine, which the CLI has no business + // carrying. Point at the app that does rather than failing with the raw API + // message, which reads as "your login is broken". rt.block_on(auth::login(&username, &password, get_totp)) - .context("login failed")?; + .map_err(|e| match e { + pdfs_core::Error::HumanVerificationRequired(_) => anyhow::anyhow!( + "Proton is asking for a CAPTCHA before it will accept this sign-in.\n\ + Run `pdfs-app` and sign in there — it can show the verification page.\n\ + (This is usually triggered by a VPN or an unfamiliar IP; signing in from \ + your usual network often avoids it.)" + ), + other => anyhow::Error::new(other).context("login failed"), + })?; println!("Logged in as {username}. Session stored in the system keyring."); Ok(()) diff --git a/crates/pdfs-core/src/auth.rs b/crates/pdfs-core/src/auth.rs index 60bf6b4..7edd1f8 100644 --- a/crates/pdfs-core/src/auth.rs +++ b/crates/pdfs-core/src/auth.rs @@ -8,6 +8,7 @@ use keyring::Entry; use proton_drive_rs::{KeySalt, ProtonDriveClient}; +use proton_sdk::api::{HumanVerification, HumanVerificationCredential}; use proton_sdk::config::ProtonClientConfiguration; use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters}; use serde::{Deserialize, Serialize}; @@ -84,14 +85,68 @@ fn keyring_entry() -> Result { /// Run an interactive SRP + (optional) 2FA login and persist the session. /// /// `get_totp` is only invoked when the account requires a second factor, so -/// callers can defer prompting until it is actually needed. +/// callers can defer prompting until it is actually needed. It may be called +/// more than once — [`login_interactive`] re-runs the whole login after human +/// verification, and a 2FA account needs a fresh code on that second attempt. +/// +/// A gated login fails with [`Error::HumanVerificationRequired`]; a front-end +/// that can present the challenge should use [`login_interactive`] instead. pub async fn login( username: &str, password: &str, - get_totp: impl FnOnce() -> Result, + get_totp: impl Fn() -> Result, ) -> Result<()> { - let mut session = - ProtonApiSession::begin(client_config(), username, password.as_bytes()).await?; + login_verified(username, password, None, get_totp).await +} + +/// [`login`], able to answer a human-verification gate rather than fail on it. +/// +/// `get_hv` is invoked only when the API actually gates the login, mirroring how +/// `get_totp` is only invoked for accounts with a second factor: neither costs a +/// prompt the user did not need. It receives the challenge and returns the token +/// the user earned by solving it. +/// +/// The retry lives here rather than in the front-ends because the recovery is +/// not a UI concern: the gated attempt burned its SRP handshake, so the login +/// has to start over with the credential attached, and every front-end would +/// otherwise have to know that. Gating the *retry* is not handled — a second +/// challenge in a row means the token was rejected, and looping on it would trap +/// the user in a CAPTCHA that never clears. +pub async fn login_interactive( + username: &str, + password: &str, + get_totp: impl Fn() -> Result, + get_hv: impl FnOnce(HumanVerification) -> Result, +) -> Result<()> { + match login_verified(username, password, None, &get_totp).await { + Err(Error::HumanVerificationRequired(hv)) => { + let credential = get_hv(*hv)?; + login_verified(username, password, Some(&credential), &get_totp).await + } + other => other, + } +} + +/// [`login`], replaying a human-verification token the user has already earned. +/// +/// A gated login fails with [`Error::HumanVerificationRequired`] carrying the +/// challenge. The front-end presents it, collects the solved token, and calls +/// this. The login starts over from the SRP handshake rather than resuming: the +/// gated attempt never got a session, and its `srp_session` is spent. +pub async fn login_verified( + username: &str, + password: &str, + verification: Option<&HumanVerificationCredential>, + get_totp: impl Fn() -> Result, +) -> Result<()> { + let mut session = ProtonApiSession::begin_verified( + client_config(), + username, + password.as_bytes(), + verification, + ) + .await + .map_err(classify_verification_gate)?; if session.is_waiting_for_second_factor() { let code = get_totp()?; @@ -110,6 +165,23 @@ pub async fn login( Ok(()) } +/// Lift a human-verification gate out of the generic API error into the typed +/// variant a front-end can act on. +/// +/// Only a gate that names a solvable method is converted. A `9001` with no +/// `Details`, or one offering only `email`/`sms`, stays a plain API error: +/// promoting it would send the UI to open a verification page it cannot +/// complete, which reads to the user as a hang rather than a refusal. +fn classify_verification_gate(e: proton_sdk::error::ProtonError) -> Error { + let proton_sdk::error::ProtonError::Api(api) = &e else { + return e.into(); + }; + match api.human_verification() { + Some(hv) if hv.supports_captcha() => Error::HumanVerificationRequired(Box::new(hv)), + _ => e.into(), + } +} + /// Persist the session's current tokens, mailbox password and key salts. pub async fn save( session: &ProtonApiSession, diff --git a/crates/pdfs-core/src/error.rs b/crates/pdfs-core/src/error.rs index 8d1bfa3..5d9fa41 100644 --- a/crates/pdfs-core/src/error.rs +++ b/crates/pdfs-core/src/error.rs @@ -1,5 +1,6 @@ //! Crate-wide error type. +use proton_sdk::api::HumanVerification; use thiserror::Error; use crate::control::ErrorKind; @@ -31,6 +32,15 @@ pub enum Error { #[error("saved session can no longer unlock your keys — run `pdfs login` again")] ReloginRequired, + /// The login was gated behind human verification. Not a failure the user can + /// fix by retrying: the challenge has to be solved and the login restarted + /// with the resulting token (see [`auth::login_verified`](crate::auth::login_verified)). + /// + /// Carries the challenge rather than just a message so a front-end can + /// present it; a front-end that cannot (no webview) still has prose to show. + #[error("sign-in needs human verification — complete the CAPTCHA to continue")] + HumanVerificationRequired(Box), + #[error("{0}")] Other(String), } @@ -154,7 +164,9 @@ impl From for CoreError { impl From for CoreError { fn from(e: Error) -> Self { let kind = match &e { - Error::NotLoggedIn | Error::ReloginRequired => ErrorKind::Denied, + Error::NotLoggedIn | Error::ReloginRequired | Error::HumanVerificationRequired(_) => { + ErrorKind::Denied + } Error::Proton(proton_sdk::ProtonError::Api(api)) => api_kind(api.code), // A `ProtonError` that never reached the API is a transport failure. Error::Proton(_) => ErrorKind::Offline, diff --git a/crates/pdfs-gui/Cargo.toml b/crates/pdfs-gui/Cargo.toml index c19af54..9784288 100644 --- a/crates/pdfs-gui/Cargo.toml +++ b/crates/pdfs-gui/Cargo.toml @@ -30,3 +30,4 @@ gtk4 = { version = "0.9", features = ["v4_12"] } adw = { package = "libadwaita", version = "0.7", features = ["v1_5"] } async-channel = "2" exif = { version = "0.6", package = "kamadak-exif" } +webkit6 = "0.4" diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index 4a6ae42..7071806 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -11,6 +11,7 @@ use pages::shared::*; use pages::shared_by_me::*; use pages::status::*; use pages::trash::*; +use pages::verify::*; use widgets::details::*; use widgets::share_dialog::*; diff --git a/crates/pdfs-gui/src/app/pages/login.rs b/crates/pdfs-gui/src/app/pages/login.rs index 1857273..6f42328 100644 --- a/crates/pdfs-gui/src/app/pages/login.rs +++ b/crates/pdfs-gui/src/app/pages/login.rs @@ -1,4 +1,5 @@ use crate::*; +use pdfs_core::proton_sdk::api::HumanVerificationCredential; pub(crate) struct LoginState { // Login page. @@ -104,18 +105,35 @@ pub(crate) fn wire_login(ui: &Rc) { ui.login.login_button.set_sensitive(false); ui.login.login_status.set_text("Signing in…"); - let (rx, totp_req_rx) = spawn_login(username, password); + let (rx, totp_req_rx, hv_req_rx) = spawn_login(username, password); // Surface the 2FA dialog only if the SDK actually asks for a code (i.e. // the account has 2FA enabled). The worker blocks until the dialog feeds // back a code (or is cancelled, dropping the sender). + // + // Loops rather than awaiting once: a login gated behind a CAPTCHA is + // restarted after verification, so a 2FA account is asked for a second, + // unexpired code on the retry. let ui_2fa = ui.clone(); glib::spawn_future_local(async move { - if let Ok(code_tx) = totp_req_rx.recv().await { + while let Ok(code_tx) = totp_req_rx.recv().await { prompt_2fa(&ui_2fa, code_tx); } }); + // Same shape for human verification: only fires when the API gates the + // sign-in, and the worker is blocked on the token until it does. + let ui_hv = ui.clone(); + glib::spawn_future_local(async move { + if let Ok((url, token_tx)) = hv_req_rx.recv().await { + ui_hv + .login + .login_status + .set_text("Complete the verification to continue…"); + prompt_human_verification(&ui_hv, &url, token_tx); + } + }); + let ui = ui.clone(); glib::spawn_future_local(async move { let result = rx @@ -155,9 +173,13 @@ pub(crate) fn spawn_login( ) -> ( async_channel::Receiver>, async_channel::Receiver>, + async_channel::Receiver<(String, std::sync::mpsc::Sender)>, ) { let (tx, rx) = async_channel::bounded(1); - let (totp_req_tx, totp_req_rx) = async_channel::bounded(1); + // Two, not one: a CAPTCHA-gated login restarts, so a 2FA account is asked + // for a code on each attempt and a single slot would deadlock the worker. + let (totp_req_tx, totp_req_rx) = async_channel::bounded(2); + let (hv_req_tx, hv_req_rx) = async_channel::bounded(1); std::thread::spawn(move || { let rt = match tokio::runtime::Builder::new_current_thread() .enable_all() @@ -170,24 +192,42 @@ pub(crate) fn spawn_login( } }; let result = rt.block_on(async move { - auth::login(&username, &password, || { - // 2FA required: hand a one-shot sender to the UI and block until - // the dialog supplies the code. A dropped sender (cancelled - // dialog) surfaces as a cancelled login. - let (code_tx, code_rx) = std::sync::mpsc::channel::(); - totp_req_tx - .send_blocking(code_tx) - .map_err(|_| pdfs_core::Error::Other("two-factor prompt closed".into()))?; - code_rx - .recv() - .map_err(|_| pdfs_core::Error::Other("two-factor entry cancelled".into())) - }) + auth::login_interactive( + &username, + &password, + || { + // 2FA required: hand a one-shot sender to the UI and block + // until the dialog supplies the code. A dropped sender + // (cancelled dialog) surfaces as a cancelled login. + let (code_tx, code_rx) = std::sync::mpsc::channel::(); + totp_req_tx + .send_blocking(code_tx) + .map_err(|_| pdfs_core::Error::Other("two-factor prompt closed".into()))?; + code_rx + .recv() + .map_err(|_| pdfs_core::Error::Other("two-factor entry cancelled".into())) + }, + |hv| { + // Gated: hand the UI the page to show, block on the token the + // user earns by solving it. + let (token_tx, token_rx) = std::sync::mpsc::channel::(); + hv_req_tx + .send_blocking((hv.verification_url(), token_tx)) + .map_err(|_| { + pdfs_core::Error::Other("verification prompt closed".into()) + })?; + let token = token_rx + .recv() + .map_err(|_| pdfs_core::Error::Other("verification cancelled".into()))?; + Ok(HumanVerificationCredential::captcha(token)) + }, + ) .await .map_err(|e| e.to_string()) }); let _ = tx.send_blocking(result); }); - (rx, totp_req_rx) + (rx, totp_req_rx, hv_req_rx) } /// Show the lazy two-factor dialog and feed the entered code back to the waiting diff --git a/crates/pdfs-gui/src/app/pages/mod.rs b/crates/pdfs-gui/src/app/pages/mod.rs index acce5a2..1ff8658 100644 --- a/crates/pdfs-gui/src/app/pages/mod.rs +++ b/crates/pdfs-gui/src/app/pages/mod.rs @@ -8,3 +8,4 @@ pub(crate) mod shared; pub(crate) mod shared_by_me; pub(crate) mod status; pub(crate) mod trash; +pub(crate) mod verify; diff --git a/crates/pdfs-gui/src/app/pages/verify.rs b/crates/pdfs-gui/src/app/pages/verify.rs new file mode 100644 index 0000000..9ed6469 --- /dev/null +++ b/crates/pdfs-gui/src/app/pages/verify.rs @@ -0,0 +1,151 @@ +//! The human-verification (CAPTCHA) dialog. +//! +//! Proton gates logins it does not recognise — a new IP, a VPN exit — behind a +//! CAPTCHA. The API answers the login with a challenge instead of a session, and +//! expects the client to present Proton's hosted verification page, collect the +//! token the user earns by solving it, and retry the login carrying that token. +//! +//! The page is hosted in a `WebKitWebView` rather than handed to the system +//! browser because it reports completion by posting a message to its host, not +//! by redirecting: a browser has nowhere to post it back to. + +use crate::*; +use webkit6::prelude::*; + +/// Bridge the verification page's `postMessage` to a handler this side can read. +/// +/// The page targets its embedding host (`window.parent`), which in a webview is +/// the page itself — so nothing arrives unless the message is forwarded +/// explicitly. Injecting at document start guarantees the listener is installed +/// before the page can post anything. +const BRIDGE: &str = r#" +window.addEventListener('message', function (event) { + try { + window.webkit.messageHandlers.hv.postMessage(JSON.stringify(event.data)); + } catch (e) {} +}); +"#; + +/// Present the challenge and hand the solved token back through `token_tx`. +/// +/// Closing the dialog without solving drops the sender, which the waiting login +/// worker reads as a cancelled sign-in — the same contract as the 2FA prompt. +pub(crate) fn prompt_human_verification( + ui: &Rc, + url: &str, + token_tx: std::sync::mpsc::Sender, +) { + let content = webkit6::UserContentManager::new(); + content.add_script(&webkit6::UserScript::new( + BRIDGE, + webkit6::UserContentInjectedFrames::AllFrames, + webkit6::UserScriptInjectionTime::Start, + &[], + &[], + )); + // Registration is what makes `messageHandlers.hv` exist in the page; without + // it the bridge above throws on every message. + content.register_script_message_handler("hv", None); + + let webview = webkit6::WebView::builder() + .user_content_manager(&content) + .vexpand(true) + .hexpand(true) + .build(); + + let dialog = adw::Dialog::builder() + .title("Verification") + .content_width(420) + .content_height(560) + .build(); + + let header = adw::HeaderBar::new(); + let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + body.append(&header); + body.append(&webview); + dialog.set_child(Some(&body)); + + // Taken on the first success *or* on close, so the token is sent at most + // once and a cancelled dialog always drops the sender. + let token_tx = Rc::new(RefCell::new(Some(token_tx))); + + let tx = token_tx.clone(); + let dlg = dialog.clone(); + content.connect_script_message_received(Some("hv"), move |_, value| { + let Some(token) = extract_token(&value.to_str()) else { + // Every `postMessage` on the page reaches here, most of them the + // verification app's own chatter. Anything that is not a completion + // is simply not ours. + return; + }; + if let Some(tx) = tx.borrow_mut().take() { + let _ = tx.send(token); + } + dlg.close(); + }); + + // Covers the close button, Escape, and the user giving up. + dialog.connect_closed(move |_| { + token_tx.borrow_mut().take(); + }); + + webview.load_uri(url); + + let parent = ui.login.login_button.root().and_downcast::(); + // Returns immediately; the login worker stays blocked on the channel until + // the handler above sends, or the dialog closes and drops the sender. + dialog.present(parent.as_ref()); +} + +/// Pull the verification token out of a message posted by the verification page. +/// +/// Returns `None` for anything that is not a completion message. The page posts +/// a good deal besides — resize requests, readiness pings — and treating an +/// unrecognised shape as success would hand the API an empty token and fail the +/// login with a confusing error. +fn extract_token(raw: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(raw).ok()?; + // The payload is nested under `payload` and the message names itself in + // `type`; both spellings of the success type have shipped. + let kind = value.get("type")?.as_str()?; + if !matches!( + kind, + "HUMAN_VERIFICATION_SUCCESS" | "human_verification_success" + ) { + return None; + } + let payload = value.get("payload")?; + let token = payload.get("token")?.as_str()?; + if token.is_empty() { + return None; + } + Some(token.to_owned()) +} + +#[cfg(test)] +mod tests { + use super::extract_token; + + #[test] + fn a_completion_message_yields_its_token() { + let raw = + r#"{"type":"HUMAN_VERIFICATION_SUCCESS","payload":{"token":"tok-1","type":"captcha"}}"#; + assert_eq!(extract_token(raw).as_deref(), Some("tok-1")); + } + + /// The page posts plenty that is not a completion; none of it may be + /// mistaken for one, or the login retries with a garbage token. + #[test] + fn unrelated_messages_are_ignored() { + for raw in [ + r#"{"type":"resize","payload":{"height":400}}"#, + r#"{"type":"HUMAN_VERIFICATION_SUCCESS"}"#, + r#"{"payload":{"token":"tok"}}"#, + r#"{"type":"HUMAN_VERIFICATION_SUCCESS","payload":{"token":""}}"#, + "not json at all", + "", + ] { + assert!(extract_token(raw).is_none(), "accepted: {raw}"); + } + } +}