From 3c88e5fdf1a3d5cf9c6b3eef7408bf9ee056ef75 Mon Sep 17 00:00:00 2001 From: Robin Schreiber Date: Thu, 27 Aug 2026 13:36:39 +0200 Subject: [PATCH 1/2] Migrate browser simulator to first-party credentials --- .codex/skills/run-headless-simulator/SKILL.md | 2 +- Cargo.lock | 48 -- Cargo.toml | 2 +- browser/src/auth.rs | 493 ++++++++---------- browser/src/participant/cloudflare/mod.rs | 208 ++------ browser/src/participant/device_farm/mod.rs | 10 +- .../device_farm/webdriver_driver.rs | 23 +- browser/src/participant/frontend/builder.rs | 34 +- browser/src/participant/frontend/commands.rs | 2 +- browser/src/participant/frontend/core.rs | 26 +- browser/src/participant/frontend/driver.rs | 28 +- browser/src/participant/frontend/mod.rs | 1 + .../src/participant/local/chromium_driver.rs | 19 +- browser/src/participant/local/session.rs | 10 +- browser/src/participant/mod.rs | 66 +-- browser/src/participant/shared/store.rs | 14 +- browser/tests/cloudflare_driver.rs | 73 +-- browser/tests/device_farm_driver.rs | 26 +- cloudflare-browser-simulator | 2 +- config/src/args.rs | 7 - docs/browser-driver.md | 6 +- justfile | 4 +- src/main.rs | 34 +- tui/examples/join-hyper-session.rs | 4 +- 24 files changed, 417 insertions(+), 725 deletions(-) diff --git a/.codex/skills/run-headless-simulator/SKILL.md b/.codex/skills/run-headless-simulator/SKILL.md index 18f405a..3ab79aa 100644 --- a/.codex/skills/run-headless-simulator/SKILL.md +++ b/.codex/skills/run-headless-simulator/SKILL.md @@ -88,7 +88,7 @@ The command applies settings in this order: A later value wins. Browser logs are the exception. The `headless` subcommand sets them to `true` unless you pass `--browser-logs false`. -Each participant needs a session URL from `config.yaml`, `--url`, or its JSON object. A `/m` or `/m/...` path selects Hyper Lite. Other paths select Hyper Core and may require a session cookie. +Each participant needs a session URL from `config.yaml`, `--url`, or its JSON object. A `/m` or `/m/...` path selects Hyper Lite. Other paths select Hyper Core; the simulator obtains and seeds first-party guest credentials before navigation. With no `--participant`, the command starts one participant from the shared settings. With one or more `--participant` values, it starts only those participants. It does not start an extra participant from the shared settings. diff --git a/Cargo.lock b/Cargo.lock index a4aea2a..40ad0f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1032,35 +1032,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "cookie" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" -dependencies = [ - "percent-encoding", - "time", - "version_check", -] - -[[package]] -name = "cookie_store" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" -dependencies = [ - "cookie", - "document-features", - "idna", - "log", - "publicsuffix", - "serde", - "serde_derive", - "serde_json", - "time", - "url", -] - [[package]] name = "core-foundation" version = "0.9.4" @@ -2943,22 +2914,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "psl-types" -version = "2.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" - -[[package]] -name = "publicsuffix" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" -dependencies = [ - "idna", - "psl-types", -] - [[package]] name = "quinn" version = "0.11.9" @@ -3269,8 +3224,6 @@ checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" dependencies = [ "base64", "bytes", - "cookie", - "cookie_store", "encoding_rs", "futures-channel", "futures-core", @@ -4154,7 +4107,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", - "itoa", "libc", "num-conv", "num_threads", diff --git a/Cargo.toml b/Cargo.toml index 5683a59..e53248e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,7 +39,7 @@ libc = "0.2.186" pretty_assertions = "1.4.1" names = { version = "0.14.0", default-features = false } ratatui = { version = "0.30.0", features = ["serde", "macros", "crossterm_0_29"] } -reqwest = { version = "0.13.3", features = ["cookies", "json", "blocking"] } +reqwest = { version = "0.13.3", features = ["json", "blocking"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" sha1 = "0.11.0" diff --git a/browser/src/auth.rs b/browser/src/auth.rs index e1dfb4d..1830478 100644 --- a/browser/src/auth.rs +++ b/browser/src/auth.rs @@ -1,5 +1,3 @@ -use chromiumoxide::cdp::browser_protocol::network::CookieParam; -use chrono::prelude::*; use eyre::{ Context as _, OptionExt as _, @@ -24,140 +22,144 @@ use std::{ }, }; -/// Manages cookies. Provides access to borrowed cookies. +/// Reuses guest credentials between simulator participants without sharing one identity concurrently. #[derive(Clone, Debug)] -pub struct HyperSessionCookieManger { +pub struct FirstPartyCredentialsManager { stash_file: PathBuf, - available_cookies: Arc>>>, + available_credentials: Arc>>>, } -impl HyperSessionCookieManger { +impl FirstPartyCredentialsManager { pub fn new(stash_file: impl Into) -> Self { Self { stash_file: stash_file.into(), - available_cookies: Default::default(), + available_credentials: Default::default(), } } - pub fn give_cookie(&self, domain: impl ToString) -> Option { - let domain = domain.to_string(); - let mut available_cookies = self.available_cookies.lock().unwrap(); - let available_cookies = available_cookies.entry(domain.clone()).or_default(); - // A cookie can expire while it sits in the queue. Never hand out an expired one; the caller fetches - // a fresh cookie instead. - available_cookies.retain(|cookie| !cookie.is_expired()); - available_cookies + pub fn give_credentials(&self, base_url: &url::Url) -> Option { + let server_url = server_key(base_url); + self.available_credentials + .lock() + .unwrap() + .entry(server_url.clone()) + .or_default() .pop_front() - .map(|cookie| BorrowedCookie::new(domain, cookie, self.clone())) - .inspect(|cookie| { - debug!(name = cookie.username(), "borrowed cookie"); + .map(|credentials| BorrowedCredentials::new(server_url, credentials, self.clone())) + .inspect(|credentials| { + debug!(name = credentials.username(), "borrowed first-party credentials"); }) } - fn return_cookie(&self, domain: impl ToString, cookie: HyperSessionCookie) { - debug!(name = cookie.username, "returned cookie"); - let mut available_cookies = self.available_cookies.lock().unwrap(); - let available_cookies = available_cookies.entry(domain.to_string()).or_default(); - available_cookies.push_back(cookie); + fn return_credentials(&self, server_url: String, credentials: FirstPartyCredentials) { + debug!(name = credentials.username, "returned first-party credentials"); + self.available_credentials + .lock() + .unwrap() + .entry(server_url) + .or_default() + .push_back(credentials); } - pub async fn fetch_new_cookie(&self, base_url: url::Url, username: impl AsRef) -> Result { - let cookie = HyperSessionCookie::fetch_token_and_set_name(base_url.clone(), username).await?; - - // Save the new cookie so we can reuse it later. + pub async fn fetch_new_credentials( + &self, + base_url: url::Url, + username: impl AsRef, + ) -> Result { + let credentials = FirstPartyCredentials::fetch(&base_url, username).await?; + let server_url = server_key(&base_url); - let mut stash = HyperSessionCookieStash::load(&self.stash_file); + let mut stash = FirstPartyCredentialsStash::load(&self.stash_file); stash - .cookies - .entry(base_url.to_string()) + .credentials + .entry(server_url.clone()) .or_default() - .push(cookie.clone()); + .push(credentials.clone()); stash.save()?; - Ok(BorrowedCookie::new(base_url, cookie, self.clone())) + Ok(BorrowedCredentials::new(server_url, credentials, self.clone())) } - pub async fn give_or_fetch_cookie(&self, base_url: url::Url, username: impl AsRef) -> Result { - let username = username.as_ref(); - - if let Some(cookie) = self.give_cookie(base_url.clone()) { - return Ok(cookie); + pub async fn give_or_fetch_credentials( + &self, + base_url: url::Url, + username: impl AsRef, + ) -> Result { + if let Some(credentials) = self.give_credentials(&base_url) { + return Ok(credentials); } - self.fetch_new_cookie(base_url, username).await + self.fetch_new_credentials(base_url, username).await } } -impl From for HyperSessionCookieManger { - fn from(stash: HyperSessionCookieStash) -> Self { +impl From for FirstPartyCredentialsManager { + fn from(stash: FirstPartyCredentialsStash) -> Self { Self { stash_file: stash.stash_file, - available_cookies: Arc::new(Mutex::new( + available_credentials: Arc::new(Mutex::new( stash - .cookies + .credentials .into_iter() - .map(|(domain, cookies)| (domain, VecDeque::from(cookies))) + .map(|(server_url, credentials)| (server_url, VecDeque::from(credentials))) .collect(), )), } } } -// -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- - -/// A cookie that will be returned to the manager when dropped. +/// Credentials that return to the manager when their participant stops. #[derive(Debug)] -pub struct BorrowedCookie { - domain: Domain, - pub(crate) cookie: HyperSessionCookie, - manager: HyperSessionCookieManger, +pub struct BorrowedCredentials { + server_url: String, + credentials: FirstPartyCredentials, + manager: FirstPartyCredentialsManager, } -impl Drop for BorrowedCookie { +impl Drop for BorrowedCredentials { fn drop(&mut self) { - self.manager.return_cookie(self.domain.clone(), self.cookie.clone()); + self.manager + .return_credentials(self.server_url.clone(), self.credentials.clone()); } } -impl BorrowedCookie { - pub(crate) fn new(domain: impl ToString, cookie: HyperSessionCookie, manager: HyperSessionCookieManger) -> Self { +impl BorrowedCredentials { + fn new(server_url: String, credentials: FirstPartyCredentials, manager: FirstPartyCredentialsManager) -> Self { Self { - domain: domain.to_string(), - cookie, + server_url, + credentials, manager, } } - pub fn as_browser_cookie_for(&self, domain: impl AsRef) -> Result { - self.cookie.as_browser_cookie_for(domain) + pub fn username(&self) -> &str { + &self.credentials.username } - pub fn username(&self) -> &str { - &self.cookie.username + pub fn realm(&self) -> &str { + &self.credentials.realm } - pub fn raw_value(&self) -> &str { - &self.cookie.cookie + pub fn envelope_json(&self) -> Result { + self.credentials.envelope_json() } } -// -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- - -pub type Domain = String; +fn server_key(base_url: &url::Url) -> String { + base_url.origin().ascii_serialization() +} -/// Only store cookies for selected hyper servers. For these servers we don't want to needlessly create new guest -/// accounts, for other (dev) servers guest creation does not matter. +/// Persist guest identities only where creating a new account for every run is undesirable. const PERSISTENCE_WHITELIST: [&str; 3] = ["latest.dev.hyper.video", "staging.hyper.video", "meet.hyper.video"]; -/// List of cookies that can be stored and retrieved. #[derive(Clone, Debug, Serialize, Deserialize)] -pub struct HyperSessionCookieStash { +pub(crate) struct FirstPartyCredentialsStash { + #[serde(skip)] stash_file: PathBuf, - cookies: HashMap>, + credentials: HashMap>, } -impl HyperSessionCookieStash { - /// Load the cookies from the simulator data directory. +impl FirstPartyCredentialsStash { fn load(file: impl AsRef) -> Self { let file = file.as_ref(); let mut stash: Self = file @@ -165,202 +167,112 @@ impl HyperSessionCookieStash { .then(|| { std::fs::File::open(file) .ok() - .and_then(|f| serde_json::from_reader(f).ok()) + .and_then(|file| serde_json::from_reader(file).ok()) }) .flatten() - .inspect(|_| { - debug!(?file, "loaded hyper_session cookies"); - }) + .inspect(|_| debug!(?file, "loaded first-party credentials")) .unwrap_or_else(|| { - debug!(?file, "no hyper_session cookies found"); + debug!(?file, "no first-party credentials found"); Self { stash_file: file.to_path_buf(), - cookies: Default::default(), + credentials: Default::default(), } }); - - // Drop expired cookies here so the next `save` does not write them back and the stash does not grow - // with dead entries. - for cookies in stash.cookies.values_mut() { - cookies.retain(|cookie| !cookie.is_expired()); - } - + stash.stash_file = file.to_path_buf(); stash } - /// Load the cookies from the given directory. - pub fn load_from_data_dir(data_dir: impl AsRef) -> Self { - const HYPER_COOKIES_FILE: &str = "hyper_session_cookies.json"; - let file = data_dir.as_ref().join(HYPER_COOKIES_FILE); - Self::load(file) + pub(crate) fn load_from_data_dir(data_dir: impl AsRef) -> Self { + Self::load(data_dir.as_ref().join("first_party_credentials.json")) } - fn with_whitelisted_domains(&self) -> Self { - let cookies = self - .cookies + fn with_whitelisted_servers(&self) -> Self { + let credentials = self + .credentials .iter() - .filter(|(domain, _)| { - PERSISTENCE_WHITELIST - .iter() - .any(|whitelisted| domain.contains(whitelisted)) + .filter(|(server_url, _)| { + url::Url::parse(server_url) + .ok() + .and_then(|url| url.host_str().map(str::to_owned)) + .is_some_and(|host| PERSISTENCE_WHITELIST.contains(&host.as_str())) }) - .map(|(domain, cookies)| (domain.clone(), cookies.clone())) + .map(|(server_url, credentials)| (server_url.clone(), credentials.clone())) .collect(); Self { stash_file: self.stash_file.clone(), - cookies, + credentials, } } - /// Save the cookies to the given directory. fn save(&self) -> Result<()> { let dir = self.stash_file.parent().ok_or_eyre("failed to get parent directory")?; std::fs::create_dir_all(dir)?; - let file = std::fs::File::create(&self.stash_file)?; - serde_json::to_writer_pretty(&file, &self.with_whitelisted_domains())?; - debug!(?file, "saved hyper_session cookies"); + let mut options = std::fs::OpenOptions::new(); + options.create(true).truncate(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + let file = options.open(&self.stash_file)?; + serde_json::to_writer_pretty(&file, &self.with_whitelisted_servers())?; + debug!(?file, "saved first-party credentials"); Ok(()) } } -/// A token (actually a cookie) to authenticate against the hyper.video server. #[derive(Clone, Debug, Serialize, Deserialize)] -pub struct HyperSessionCookie { - domain: Domain, - created_at: DateTime, - expires_at: DateTime, - pub username: String, - cookie: String, +struct FirstPartyCredentials { + username: String, + realm: String, + first_party_access_token: String, + renewal_token: String, } -impl HyperSessionCookie { - pub(crate) fn new(domain: impl ToString, cookie: impl ToString) -> Self { - let created_at = Utc::now(); - Self { - domain: domain.to_string(), - created_at, - // TODO: Currently we use a year expiration date on the server but we should dynamically determine this - // value here as it is likely to change. - expires_at: created_at + chrono::Duration::days(365), - username: Default::default(), - cookie: cookie.to_string(), - } - } - - pub(crate) fn is_expired(&self) -> bool { - Utc::now() > self.expires_at - } - - fn cookie_header(&self) -> Result { - reqwest::header::HeaderValue::from_str(&format!("hyper_session={}", self.cookie)) - .context("failed to create cookie header") - } - - fn client() -> Result { - reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .danger_accept_invalid_certs(true) - .build() - .context("failed to build reqwest client") - } - - async fn fetch_token_and_set_name(base_url: url::Url, name: impl AsRef) -> Result { - let mut auth = HyperSessionCookie::fetch_token(&base_url).await?; - auth.set_name(name, &base_url).await?; - Ok(auth) - } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct GuestAuthResponse { + first_party_access_token: String, + renewal_token: String, +} - async fn fetch_token(base_url: &url::Url) -> Result { +impl FirstPartyCredentials { + async fn fetch(base_url: &url::Url, username: impl AsRef) -> Result { + let username = username.as_ref(); let url = base_url .join("/api/v1/auth/guest") .context("failed to join base URL with /api/v1/auth/guest")?; - debug!(%url, "Requesting guest cookie"); - - let response = Self::client()? + debug!(%url, %username, "requesting guest credentials"); + let response = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .danger_accept_invalid_certs(true) + .build() + .context("failed to build reqwest client")? .post(url) - .query(&[("username", "guest")]) + .query(&[("username", username)]) .send() .await? - .error_for_status()?; - - let cookie = response - .cookies() - .find(|cookie| cookie.name() == "hyper_session") - .ok_or_eyre("api/v1/auth/guest did not return a cookie")? - .value() - .to_string(); - - Ok(Self::new(base_url, cookie)) + .error_for_status()? + .json::() + .await?; + + Ok(Self { + username: username.to_owned(), + realm: base_url.origin().ascii_serialization(), + first_party_access_token: response.first_party_access_token, + renewal_token: response.renewal_token, + }) } - #[expect(unused)] - pub(crate) async fn check_validity(&self, server_base_url: &url::Url) -> bool { - let header = match self.cookie_header() { - Ok(h) => h, - _ => return false, - }; - - let Ok(client) = Self::client() else { return false }; - - let url = server_base_url - .join("/api/v1/auth/me") - .expect("failed to join base URL with /api/v1/auth/me"); - - client - .get(url) - .header("Cookie", header) - .send() - .await - .map(|response| response.status().is_success()) - .unwrap_or(false) - } - - #[expect(unused)] - pub(crate) async fn logout(&self, server_base_url: &url::Url) -> Result<()> { - let url = server_base_url - .join("/api/v1/auth/logout") - .context("failed to join base URL with /api/v1/auth/logout")?; - Self::client()? - .post(url) - .header("Content-Type", "application/json") - .header("Cookie", self.cookie_header()?) - .body("{}") - .send() - .await? - .error_for_status()?; - Ok(()) - } - - pub(crate) async fn set_name(&mut self, name: impl AsRef, server_base_url: &url::Url) -> Result<()> { - let name = name.as_ref(); - let url = server_base_url - .join("/api/v1/auth/me/name") - .context("failed to join base URL with /api/v1/auth/me/name")?; - Self::client()? - .put(url) - .header("Content-Type", "application/json") - .header("Cookie", self.cookie_header()?) - .json(&serde_json::json!({ - "name": name, - })) - .send() - .await? - .error_for_status()?; - self.username = name.to_string(); - Ok(()) - } - - pub(crate) fn as_browser_cookie_for(&self, domain: impl AsRef) -> Result { - CookieParam::builder() - .name("hyper_session") - .value(self.cookie.clone()) - .domain(domain.as_ref()) - .path("/") - .build() - .map_err(|e| eyre::eyre!(e)) - .context("failed to build cookie") + fn envelope_json(&self) -> Result { + serde_json::to_string(&serde_json::json!({ + "realm": self.realm, + "first_party_access_token": self.first_party_access_token, + "renewal_token": self.renewal_token, + })) + .context("failed to serialize first-party credentials") } } @@ -371,24 +283,29 @@ mod tests { SystemTime, UNIX_EPOCH, }; + use tokio::{ + io::{ + AsyncReadExt as _, + AsyncWriteExt as _, + }, + net::TcpListener, + }; - /// A whitelisted domain so that `save` keeps the cookies. - const DOMAIN: &str = "https://staging.hyper.video/"; + const SERVER_URL: &str = "https://staging.hyper.video"; - fn cookie(username: &str, expires_in: chrono::Duration) -> HyperSessionCookie { - HyperSessionCookie { - domain: DOMAIN.to_string(), - created_at: Utc::now(), - expires_at: Utc::now() + expires_in, + fn credentials(username: &str) -> FirstPartyCredentials { + FirstPartyCredentials { username: username.to_string(), - cookie: format!("{username}-session"), + realm: "https://staging.hyper.video".to_string(), + first_party_access_token: format!("{username}-access"), + renewal_token: format!("{username}-renewal"), } } - fn stash_with(stash_file: PathBuf, cookies: Vec) -> HyperSessionCookieStash { - HyperSessionCookieStash { + fn stash_with(stash_file: PathBuf, credentials: Vec) -> FirstPartyCredentialsStash { + FirstPartyCredentialsStash { stash_file, - cookies: HashMap::from([(DOMAIN.to_string(), cookies)]), + credentials: HashMap::from([(SERVER_URL.to_string(), credentials)]), } } @@ -400,47 +317,79 @@ mod tests { } #[test] - fn give_cookie_returns_none_when_only_expired_cookies_exist() { - let stash = stash_with( - "unused.json".into(), - vec![cookie("expired", -chrono::Duration::hours(1))], - ); - let manager = HyperSessionCookieManger::from(stash); + fn borrowed_credentials_return_to_the_pool() { + let manager = + FirstPartyCredentialsManager::from(stash_with("unused.json".into(), vec![credentials("simulator")])); - assert!(manager.give_cookie(DOMAIN).is_none()); + let server_url = url::Url::parse(SERVER_URL).unwrap(); + let borrowed = manager.give_credentials(&server_url).unwrap(); + assert_eq!(borrowed.username(), "simulator"); + assert!(manager.give_credentials(&server_url).is_none()); + drop(borrowed); + + assert_eq!(manager.give_credentials(&server_url).unwrap().username(), "simulator"); } #[test] - fn give_cookie_skips_expired_cookies_and_returns_the_next_valid_one() { - let stash = stash_with( - "unused.json".into(), - vec![ - cookie("expired", -chrono::Duration::hours(1)), - cookie("valid", chrono::Duration::hours(1)), - ], - ); - let manager = HyperSessionCookieManger::from(stash); + fn stash_round_trips_credentials() { + let stash_file = unique_temp_dir().join("first_party_credentials.json"); + stash_with(stash_file.clone(), vec![credentials("simulator")]) + .save() + .unwrap(); + + let stored = std::fs::read_to_string(&stash_file).unwrap(); + assert!(!stored.contains("stash_file")); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + assert_eq!( + std::fs::metadata(&stash_file).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + + let loaded = FirstPartyCredentialsStash::load(stash_file); - let borrowed = manager.give_cookie(DOMAIN).expect("the valid cookie"); - assert_eq!(borrowed.username(), "valid"); + assert_eq!(loaded.credentials[SERVER_URL][0].username, "simulator"); } #[test] - fn load_drops_expired_cookies() { - let stash_file = unique_temp_dir().join("hyper_session_cookies.json"); - stash_with( - stash_file.clone(), - vec![ - cookie("expired", -chrono::Duration::hours(1)), - cookie("valid", chrono::Duration::hours(1)), - ], - ) - .save() - .unwrap(); - - let loaded = HyperSessionCookieStash::load(&stash_file); - - let usernames: Vec<_> = loaded.cookies[DOMAIN].iter().map(|c| c.username.as_str()).collect(); - assert_eq!(usernames, ["valid"]); + fn envelope_matches_hyper_core_storage_shape() { + assert_eq!( + serde_json::from_str::(&credentials("simulator").envelope_json().unwrap()).unwrap(), + serde_json::json!({ + "realm": "https://staging.hyper.video", + "first_party_access_token": "simulator-access", + "renewal_token": "simulator-renewal", + }) + ); + } + + #[tokio::test] + async fn fetches_credentials_for_the_requested_username() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = url::Url::parse(&format!("http://{}", listener.local_addr().unwrap())).unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = vec![0_u8; 4096]; + let bytes_read = stream.read(&mut request).await.unwrap(); + let request = String::from_utf8_lossy(&request[..bytes_read]); + assert!(request.starts_with("POST /api/v1/auth/guest?username=simulator HTTP/1.1")); + + let body = r#"{"firstPartyAccessToken":"access","renewalToken":"renewal"}"#; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).await.unwrap(); + }); + + let credentials = FirstPartyCredentials::fetch(&base_url, "simulator").await.unwrap(); + server.await.unwrap(); + + assert_eq!(credentials.username, "simulator"); + assert_eq!(credentials.realm, base_url.origin().ascii_serialization()); + assert_eq!(credentials.first_party_access_token, "access"); + assert_eq!(credentials.renewal_token, "renewal"); } } diff --git a/browser/src/participant/cloudflare/mod.rs b/browser/src/participant/cloudflare/mod.rs index 471a179..8e42799 100644 --- a/browser/src/participant/cloudflare/mod.rs +++ b/browser/src/participant/cloudflare/mod.rs @@ -1,25 +1,19 @@ -use crate::{ - auth::{ - BorrowedCookie, - HyperSessionCookieManger, +use crate::participant::shared::{ + browser_log::{ + console_level, + emit_browser_log_batch, + BrowserLogEntry, + BrowserLogSource, }, - participant::shared::{ - browser_log::{ - console_level, - emit_browser_log_batch, - BrowserLogEntry, - BrowserLogSource, - }, - messages::{ - ParticipantLogMessage, - ParticipantMessage, - }, - DriverTermination, - ParticipantDriverSession, - ParticipantLaunchSpec, - ParticipantState, - ResolvedFrontendKind, + messages::{ + ParticipantLogMessage, + ParticipantMessage, }, + DriverTermination, + ParticipantDriverSession, + ParticipantLaunchSpec, + ParticipantState, + ResolvedFrontendKind, }; use client_simulator_config::{ media::FakeMedia, @@ -56,14 +50,6 @@ use tokio::{ time::MissedTickBehavior, }; -enum CloudflareAuth { - HyperCore { - cookie: Option, - cookie_manager: HyperSessionCookieManger, - }, - HyperLite, -} - #[derive(Debug, Clone, PartialEq)] pub(super) struct CloudflareLaunchOptions { headless: bool, @@ -85,7 +71,6 @@ pub(super) struct CloudflareSession { launch_spec: ParticipantLaunchSpec, launch_options: CloudflareLaunchOptions, cloudflare_config: CloudflareConfig, - auth: CloudflareAuth, session_id: Option, cached_state: Arc>, termination_tx: watch::Sender>, @@ -140,25 +125,14 @@ impl CloudflareSession { launch_spec: ParticipantLaunchSpec, launch_options: CloudflareLaunchOptions, cloudflare_config: CloudflareConfig, - cookie: Option, - cookie_manager: HyperSessionCookieManger, ) -> Self { - Self::build( - launch_spec, - launch_options, - cloudflare_config, - cookie, - cookie_manager, - true, - ) + Self::build(launch_spec, launch_options, cloudflare_config, true) } fn build( launch_spec: ParticipantLaunchSpec, launch_options: CloudflareLaunchOptions, cloudflare_config: CloudflareConfig, - cookie: Option, - cookie_manager: HyperSessionCookieManger, _track_spawn: bool, ) -> Self { #[cfg(test)] @@ -171,10 +145,6 @@ impl CloudflareSession { } } - let auth = match launch_spec.frontend_kind { - ResolvedFrontendKind::HyperCore => CloudflareAuth::HyperCore { cookie, cookie_manager }, - ResolvedFrontendKind::HyperLite => CloudflareAuth::HyperLite, - }; let (termination_tx, termination_rx) = watch::channel(None); Self { @@ -185,7 +155,6 @@ impl CloudflareSession { launch_spec, launch_options, cloudflare_config, - auth, session_id: None, termination_tx, termination_rx, @@ -199,17 +168,8 @@ impl CloudflareSession { launch_spec: ParticipantLaunchSpec, launch_options: CloudflareLaunchOptions, cloudflare_config: CloudflareConfig, - cookie: Option, - cookie_manager: HyperSessionCookieManger, ) -> Self { - Self::build( - launch_spec, - launch_options, - cloudflare_config, - cookie, - cookie_manager, - false, - ) + Self::build(launch_spec, launch_options, cloudflare_config, false) } fn log_message(&self, level: &str, message: impl ToString) { @@ -267,32 +227,9 @@ impl CloudflareSession { settings } - async fn ensure_hyper_session_cookie(&mut self) -> Result> { - match &mut self.auth { - CloudflareAuth::HyperCore { cookie, cookie_manager } => { - if cookie.is_none() { - *cookie = Some( - cookie_manager - .give_or_fetch_cookie(self.launch_spec.base_url(), &self.launch_spec.username) - .await?, - ); - } - - Ok(cookie.as_ref().map(|cookie| cookie.raw_value().to_owned())) - } - CloudflareAuth::HyperLite => Ok(None), - } - } - async fn build_create_request(&mut self) -> Result { self.log_backend_limitations(); let normalized_settings = self.normalized_settings(); - let hyper_session_cookie = self - .ensure_hyper_session_cookie() - .await? - .map(types::SessionCreateRequestHyperSessionCookie::try_from) - .transpose() - .map_err(|error| eyre!("Failed to encode Hyper Core session cookie for the worker: {error}"))?; Ok(types::SessionCreateRequest { browser_logs: Some(self.launch_options.browser_logs), @@ -300,7 +237,7 @@ impl CloudflareSession { display_name: types::SessionCreateRequestDisplayName::try_from(self.launch_spec.username.clone()) .map_err(|error| eyre!("Invalid Cloudflare display name: {error}"))?, frontend_kind: map_frontend_kind(self.launch_spec.frontend_kind), - hyper_session_cookie, + hyper_session_cookie: None, navigation_timeout_ms: Some(self.cloudflare_config.navigation_timeout_ms as f64), room_url: self.launch_spec.session_url.to_string(), selector_timeout_ms: Some(self.cloudflare_config.selector_timeout_ms as f64), @@ -878,17 +815,14 @@ mod tests { CloudflareLaunchOptions, CloudflareSession, }; - use crate::{ - auth::HyperSessionCookieManger, - participant::shared::{ - browser_log::BrowserLogSource, - messages::ParticipantMessage, - ParticipantDriverSession, - ParticipantLaunchSpec, - ParticipantSettings, - ParticipantState, - ResolvedFrontendKind, - }, + use crate::participant::shared::{ + browser_log::BrowserLogSource, + messages::ParticipantMessage, + ParticipantDriverSession, + ParticipantLaunchSpec, + ParticipantSettings, + ParticipantState, + ResolvedFrontendKind, }; use chrono::Utc; use client_simulator_config::{ @@ -904,21 +838,15 @@ mod tests { }; use std::{ collections::VecDeque, - fs, io::{ Result as IoResult, Write, }, - path::PathBuf, sync::{ Arc, Mutex, }, - time::{ - Duration, - SystemTime, - UNIX_EPOCH, - }, + time::Duration, }; use tokio::{ io::{ @@ -950,7 +878,6 @@ mod tests { struct CapturedRequest { method: String, path: String, - headers: Vec<(String, String)>, body: String, } @@ -994,14 +921,8 @@ mod tests { } #[tokio::test] - async fn start_fetches_cookie_creates_worker_session_and_close_tears_it_down() { + async fn start_creates_worker_session_and_close_tears_it_down() { let responses = VecDeque::from(vec![ - MockResponse::new( - 200, - "Set-Cookie: hyper_session=fetched-cookie; Path=/; HttpOnly\r\n", - "", - ), - MockResponse::json(200, json!({ "ok": true })), MockResponse::json( 200, json!({ @@ -1044,7 +965,6 @@ mod tests { ), ]); let (base_url, requests, server) = spawn_http_server(responses).await; - let cookie_manager = HyperSessionCookieManger::new(unique_temp_dir().join("cookies.json")); let mut session = CloudflareSession::new_for_test( launch_spec(ResolvedFrontendKind::HyperCore, &format!("{base_url}/room/demo")), launch_options(false, FakeMedia::None), @@ -1057,8 +977,6 @@ mod tests { debug: true, health_poll_interval_ms: 5_000, }, - None, - cookie_manager, ); session.start().await.unwrap(); @@ -1078,32 +996,17 @@ mod tests { server.abort(); let requests = requests.lock().unwrap().clone(); - assert_eq!(requests.len(), 4); + assert_eq!(requests.len(), 2); assert_eq!(requests[0].method, "POST"); - assert_eq!(requests[0].path, "/api/v1/auth/guest?username=guest"); - - assert_eq!(requests[1].method, "PUT"); - assert_eq!(requests[1].path, "/api/v1/auth/me/name"); - assert_eq!( - header_value(&requests[1], "cookie").as_deref(), - Some("hyper_session=fetched-cookie") - ); - assert_eq!( - serde_json::from_str::(&requests[1].body).unwrap(), - json!({ "name": "cloudflare-sim" }) - ); - - assert_eq!(requests[2].method, "POST"); - assert_eq!(requests[2].path, "/sessions"); + assert_eq!(requests[0].path, "/sessions"); assert_eq!( - serde_json::from_str::(&requests[2].body).unwrap(), + serde_json::from_str::(&requests[0].body).unwrap(), json!({ "debug": true, "browserLogs": false, "displayName": "cloudflare-sim", "frontendKind": "hyper-core", - "hyperSessionCookie": "fetched-cookie", "navigationTimeoutMs": 30000.0, "roomUrl": format!("{base_url}/room/demo"), "selectorTimeoutMs": 10000.0, @@ -1123,8 +1026,8 @@ mod tests { }) ); - assert_eq!(requests[3].method, "POST"); - assert_eq!(requests[3].path, "/sessions/cf-session-123/close"); + assert_eq!(requests[1].method, "POST"); + assert_eq!(requests[1].path, "/sessions/cf-session-123/close"); } #[tokio::test] @@ -1162,7 +1065,6 @@ mod tests { ), ]); let (base_url, _requests, server) = spawn_http_server(responses).await; - let cookie_manager = HyperSessionCookieManger::new(unique_temp_dir().join("cookies.json")); let mut session = CloudflareSession::new_for_test( launch_spec(ResolvedFrontendKind::HyperLite, &format!("{base_url}/room/demo")), launch_options(false, FakeMedia::None), @@ -1175,8 +1077,6 @@ mod tests { debug: false, health_poll_interval_ms: 60_000, }, - None, - cookie_manager, ); session.start().await.unwrap(); @@ -1227,7 +1127,6 @@ mod tests { ), ]); let (base_url, requests, server) = spawn_http_server(responses).await; - let cookie_manager = HyperSessionCookieManger::new(unique_temp_dir().join("cookies.json")); let mut options = launch_options(true, FakeMedia::None); options.browser_logs = true; let mut session = CloudflareSession::new_for_test( @@ -1242,8 +1141,6 @@ mod tests { debug: false, health_poll_interval_ms: 60_000, }, - None, - cookie_manager, ); session.start().await.unwrap(); @@ -1288,7 +1185,6 @@ mod tests { ), ]); let (base_url, _requests, server) = spawn_http_server(responses).await; - let cookie_manager = HyperSessionCookieManger::new(unique_temp_dir().join("cookies.json")); let mut options = launch_options(true, FakeMedia::None); options.browser_logs = true; let mut session = CloudflareSession::new_for_test( @@ -1303,8 +1199,6 @@ mod tests { debug: false, health_poll_interval_ms: 60_000, }, - None, - cookie_manager, ); session.start().await.unwrap(); @@ -1417,7 +1311,6 @@ mod tests { ), ]); let (base_url, requests, server) = spawn_http_server(responses).await; - let cookie_manager = HyperSessionCookieManger::new(unique_temp_dir().join("cookies.json")); let mut session = CloudflareSession::new_for_test( launch_spec(ResolvedFrontendKind::HyperLite, &format!("{base_url}/room/demo")), launch_options(false, FakeMedia::None), @@ -1430,8 +1323,6 @@ mod tests { debug: false, health_poll_interval_ms: 60_000, }, - None, - cookie_manager, ); session.start().await.unwrap(); @@ -1614,7 +1505,6 @@ mod tests { ), ]); let (base_url, requests, server) = spawn_http_server(responses).await; - let cookie_manager = HyperSessionCookieManger::new(unique_temp_dir().join("cookies.json")); let mut spec = launch_spec(ResolvedFrontendKind::HyperLite, &format!("{base_url}/room/demo")); spec.settings.transport = TransportMode::WebTransport; let mut session = CloudflareSession::new_for_test( @@ -1629,8 +1519,6 @@ mod tests { debug: false, health_poll_interval_ms: 60_000, }, - None, - cookie_manager, ); session.start().await.unwrap(); @@ -1677,7 +1565,6 @@ mod tests { ), ]); let (base_url, _requests, server) = spawn_http_server(responses).await; - let cookie_manager = HyperSessionCookieManger::new(unique_temp_dir().join("cookies.json")); let mut session = CloudflareSession::new_for_test( launch_spec(ResolvedFrontendKind::HyperLite, &format!("{base_url}/room/demo")), launch_options( @@ -1693,8 +1580,6 @@ mod tests { debug: false, health_poll_interval_ms: 60_000, }, - None, - cookie_manager, ); session.start().await.unwrap(); @@ -1730,7 +1615,6 @@ mod tests { ), ]); let (base_url, requests, server) = spawn_http_server(responses).await; - let cookie_manager = HyperSessionCookieManger::new(unique_temp_dir().join("cookies.json")); let mut session = CloudflareSession::new_for_test( launch_spec(ResolvedFrontendKind::HyperLite, &format!("{base_url}/room/demo")), launch_options(false, FakeMedia::None), @@ -1743,8 +1627,6 @@ mod tests { debug: false, health_poll_interval_ms: 5, }, - None, - cookie_manager, ); session.start().await.unwrap(); @@ -1920,7 +1802,6 @@ mod tests { let method = request_line.next().unwrap().to_owned(); let path = request_line.next().unwrap().to_owned(); - let mut headers = Vec::new(); let mut content_length = 0_usize; for line in lines.filter(|line| !line.is_empty()) { let (name, value) = line.split_once(':').unwrap(); @@ -1928,7 +1809,6 @@ mod tests { if name.eq_ignore_ascii_case("content-length") { content_length = value.parse().unwrap(); } - headers.push((name.to_ascii_lowercase(), value)); } let body_start = header_end + 4; @@ -1942,7 +1822,6 @@ mod tests { CapturedRequest { method, path, - headers, body: String::from_utf8(body).unwrap(), } } @@ -1951,21 +1830,6 @@ mod tests { buffer.windows(4).position(|window| window == b"\r\n\r\n") } - fn header_value(request: &CapturedRequest, name: &str) -> Option { - request - .headers - .iter() - .find(|(header_name, _)| header_name == &name.to_ascii_lowercase()) - .map(|(_, value)| value.clone()) - } - - fn unique_temp_dir() -> PathBuf { - let nonce = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); - let dir = std::env::temp_dir().join(format!("hyper-browser-simulator-cloudflare-{nonce}")); - fs::create_dir_all(&dir).unwrap(); - dir - } - fn status_text(status: u16) -> &'static str { match status { 200 => "OK", @@ -1981,14 +1845,6 @@ mod tests { } impl MockResponse { - fn new(status: u16, headers: &str, body: &str) -> Self { - Self { - status, - headers: headers.to_owned(), - body: body.to_owned(), - } - } - fn json(status: u16, body: Value) -> Self { Self { status, diff --git a/browser/src/participant/device_farm/mod.rs b/browser/src/participant/device_farm/mod.rs index e5d2cb9..fb3d39a 100644 --- a/browser/src/participant/device_farm/mod.rs +++ b/browser/src/participant/device_farm/mod.rs @@ -4,8 +4,8 @@ mod webdriver_driver; use crate::{ auth::{ - BorrowedCookie, - HyperSessionCookieManger, + BorrowedCredentials, + FirstPartyCredentialsManager, }, participant::{ frontend::{ @@ -192,11 +192,11 @@ impl DeviceFarmSession { launch_spec: ParticipantLaunchSpec, launch_options: DeviceFarmLaunchOptions, config: DeviceFarmConfig, - cookie: Option, - cookie_manager: HyperSessionCookieManger, + credentials: Option, + credentials_manager: FirstPartyCredentialsManager, api: Arc, ) -> Self { - let auth = FrontendAuth::for_kind(launch_spec.frontend_kind, cookie, cookie_manager); + let auth = FrontendAuth::for_kind(launch_spec.frontend_kind, credentials, credentials_manager); let (termination_tx, termination_rx) = watch::channel(None); Self { cached_state: ParticipantState { diff --git a/browser/src/participant/device_farm/webdriver_driver.rs b/browser/src/participant/device_farm/webdriver_driver.rs index 8a89eb2..d5a819c 100644 --- a/browser/src/participant/device_farm/webdriver_driver.rs +++ b/browser/src/participant/device_farm/webdriver_driver.rs @@ -9,8 +9,8 @@ use futures::{ }; use std::time::Duration; use thirtyfour::{ + extensions::cdp::ChromeDevTools, By, - Cookie, WebDriver, }; @@ -133,21 +133,16 @@ impl BrowserDriver for WebDriverDriver { .boxed() } - fn set_cookie(&self, domain: &str, name: &str, value: &str) -> BoxFuture<'_, Result<()>> { - let domain = domain.to_owned(); - let name = name.to_owned(); - let value = value.to_owned(); + fn seed_first_party_credentials(&self, realm: &str, credentials: &str) -> BoxFuture<'_, Result<()>> { + let script = super::super::frontend::first_party_credentials_init_script(realm, credentials); async move { - // WebDriver requires being on the target origin before adding a cookie. - let origin = format!("https://{domain}/"); - self.driver - .goto(&origin) + ChromeDevTools::new(self.driver.handle.clone()) + .execute_cdp_with_params( + "Page.addScriptToEvaluateOnNewDocument", + serde_json::json!({ "source": script? }), + ) .await - .with_context(|| format!("failed to open origin {origin} before setting cookie"))?; - let mut cookie = Cookie::new(name, value); - cookie.set_domain(domain); - cookie.set_path("/"); - self.driver.add_cookie(cookie).await.context("failed to add cookie")?; + .context("failed to install first-party credential init script")?; Ok(()) } .boxed() diff --git a/browser/src/participant/frontend/builder.rs b/browser/src/participant/frontend/builder.rs index 2f05368..47b107e 100644 --- a/browser/src/participant/frontend/builder.rs +++ b/browser/src/participant/frontend/builder.rs @@ -8,18 +8,18 @@ use super::{ }; use crate::{ auth::{ - BorrowedCookie, - HyperSessionCookieManger, + BorrowedCredentials, + FirstPartyCredentialsManager, }, participant::shared::ResolvedFrontendKind, }; use eyre::Result; -/// How to authenticate the frontend. HyperLite needs no cookie. +/// How to authenticate the frontend. Hyper Lite does not need Hyper Core credentials. pub(in crate::participant) enum FrontendAuth { HyperCore { - cookie: Option, - cookie_manager: HyperSessionCookieManger, + credentials: Option, + credentials_manager: FirstPartyCredentialsManager, }, HyperLite, } @@ -27,11 +27,14 @@ pub(in crate::participant) enum FrontendAuth { impl FrontendAuth { pub(in crate::participant) fn for_kind( kind: ResolvedFrontendKind, - cookie: Option, - cookie_manager: HyperSessionCookieManger, + credentials: Option, + credentials_manager: FirstPartyCredentialsManager, ) -> Self { match kind { - ResolvedFrontendKind::HyperCore => Self::HyperCore { cookie, cookie_manager }, + ResolvedFrontendKind::HyperCore => Self::HyperCore { + credentials, + credentials_manager, + }, ResolvedFrontendKind::HyperLite => Self::HyperLite, } } @@ -46,15 +49,18 @@ impl FrontendKindBuilder { auth: FrontendAuth, ) -> Result> { match auth { - FrontendAuth::HyperCore { cookie, cookie_manager } => { - let cookie = if let Some(cookie) = cookie { - cookie + FrontendAuth::HyperCore { + credentials, + credentials_manager, + } => { + let credentials = if let Some(credentials) = credentials { + credentials } else { - cookie_manager - .fetch_new_cookie(context.launch_spec.base_url(), context.participant_name()) + credentials_manager + .fetch_new_credentials(context.launch_spec.base_url(), context.participant_name()) .await? }; - Ok(Box::new(ParticipantInner::new(context, cookie))) + Ok(Box::new(ParticipantInner::new(context, credentials))) } FrontendAuth::HyperLite => Ok(Box::new(ParticipantInnerLite::new(context))), } diff --git a/browser/src/participant/frontend/commands.rs b/browser/src/participant/frontend/commands.rs index 86bd2df..9d2a84b 100644 --- a/browser/src/participant/frontend/commands.rs +++ b/browser/src/participant/frontend/commands.rs @@ -178,7 +178,7 @@ pub(in crate::participant::frontend) mod tests { async move { Ok(value) }.boxed() } - fn set_cookie(&self, _domain: &str, _name: &str, _value: &str) -> BoxFuture<'_, Result<()>> { + fn seed_first_party_credentials(&self, _realm: &str, _credentials: &str) -> BoxFuture<'_, Result<()>> { async { Ok(()) }.boxed() } } diff --git a/browser/src/participant/frontend/core.rs b/browser/src/participant/frontend/core.rs index b82a11f..64fd34b 100644 --- a/browser/src/participant/frontend/core.rs +++ b/browser/src/participant/frontend/core.rs @@ -28,7 +28,7 @@ use super::{ }, selectors::classic, }; -use crate::auth::BorrowedCookie; +use crate::auth::BorrowedCredentials; use client_simulator_config::{ NoiseSuppression, TransportMode, @@ -48,31 +48,23 @@ use std::time::Duration; #[derive(Debug)] pub(super) struct ParticipantInner { context: FrontendContext, - auth: BorrowedCookie, + auth: BorrowedCredentials, } impl ParticipantInner { - pub(super) fn new(context: FrontendContext, auth: BorrowedCookie) -> Self { + pub(super) fn new(context: FrontendContext, auth: BorrowedCredentials) -> Self { Self { context, auth } } - async fn set_cookie(&self) -> Result<()> { - let domain = self - .context - .launch_spec - .session_url - .host_str() - .unwrap_or("localhost") - .to_owned(); - let value = self.auth.raw_value().to_owned(); + async fn seed_credentials(&self) -> Result<()> { + let credentials = self.auth.envelope_json()?; self.context .driver - .set_cookie(&domain, "hyper_session", &value) + .seed_first_party_credentials(self.auth.realm(), &credentials) .await - .context("failed to set cookie")?; + .context("failed to seed first-party credentials")?; - self.context - .log_message("debug", format!("Set cookie for domain {domain}")); + self.context.log_message("debug", "Seeded first-party credentials"); Ok(()) } @@ -82,7 +74,7 @@ impl ParticipantInner { } async fn join_session(&mut self) -> Result<()> { - self.set_cookie().await?; + self.seed_credentials().await?; self.context .driver diff --git a/browser/src/participant/frontend/driver.rs b/browser/src/participant/frontend/driver.rs index d4a97a0..6cdb680 100644 --- a/browser/src/participant/frontend/driver.rs +++ b/browser/src/participant/frontend/driver.rs @@ -6,7 +6,10 @@ use super::super::shared::{ ParticipantLaunchSpec, ParticipantState, }; -use eyre::Result; +use eyre::{ + Context as _, + Result, +}; use futures::future::BoxFuture; use std::time::Duration; @@ -30,9 +33,18 @@ pub(in crate::participant) trait BrowserDriver: Send + Sync { /// `Ok(None)` if the element exists but the attribute is absent. fn attribute(&self, selector: &str, name: &str) -> BoxFuture<'_, Result>>; fn eval(&self, js_body: &str, arg: Option) -> BoxFuture<'_, Result>; - /// Set a cookie for `domain`. Drivers that require being on-origin first - /// (WebDriver) must navigate to the origin before setting it. - fn set_cookie(&self, domain: &str, name: &str, value: &str) -> BoxFuture<'_, Result<()>>; + /// Seed Hyper Core's credential envelope before the first page is created. + fn seed_first_party_credentials(&self, realm: &str, credentials: &str) -> BoxFuture<'_, Result<()>>; +} + +pub(in crate::participant) fn first_party_credentials_init_script(realm: &str, credentials: &str) -> Result { + const STORAGE_KEY: &str = "hyper_video_first_party_credentials"; + let realm = serde_json::to_string(realm).context("failed to encode credential realm")?; + let key = serde_json::to_string(STORAGE_KEY).context("failed to encode credential storage key")?; + let credentials = serde_json::to_string(credentials).context("failed to encode first-party credentials")?; + Ok(format!( + "if (globalThis.location.origin === {realm}) {{ globalThis.localStorage.setItem({key}, {credentials}); }}" + )) } /// Context shared by every frontend automation, parameterised over the driver. @@ -78,6 +90,14 @@ mod tests { super::commands::tests::RecordingDriver, *, }; + + #[test] + fn credential_init_script_escapes_its_value() { + assert_eq!( + first_party_credentials_init_script("https://example.com", r#"{"token":"a'b"}"#).unwrap(), + r#"if (globalThis.location.origin === "https://example.com") { globalThis.localStorage.setItem("hyper_video_first_party_credentials", "{\"token\":\"a'b\"}"); }"# + ); + } use client_simulator_config::{ Config, ParticipantConfig, diff --git a/browser/src/participant/frontend/mod.rs b/browser/src/participant/frontend/mod.rs index daca152..979bba7 100644 --- a/browser/src/participant/frontend/mod.rs +++ b/browser/src/participant/frontend/mod.rs @@ -13,6 +13,7 @@ pub(in crate::participant) use builder::{ FrontendKindBuilder, }; pub(in crate::participant) use driver::{ + first_party_credentials_init_script, BrowserDriver, FrontendAutomation, FrontendContext, diff --git a/browser/src/participant/local/chromium_driver.rs b/browser/src/participant/local/chromium_driver.rs index 02f9f2d..4c56b77 100644 --- a/browser/src/participant/local/chromium_driver.rs +++ b/browser/src/participant/local/chromium_driver.rs @@ -133,24 +133,13 @@ impl BrowserDriver for ChromiumDriver { .boxed() } - fn set_cookie(&self, domain: &str, name: &str, value: &str) -> BoxFuture<'_, Result<()>> { - use chromiumoxide::cdp::browser_protocol::network::CookieParam; - - let domain = domain.to_owned(); - let name = name.to_owned(); - let value = value.to_owned(); + fn seed_first_party_credentials(&self, realm: &str, credentials: &str) -> BoxFuture<'_, Result<()>> { + let script = super::super::frontend::first_party_credentials_init_script(realm, credentials); async move { - let cookie = CookieParam::builder() - .name(name) - .value(value) - .domain(domain) - .path("/") - .build() - .map_err(|e| eyre::eyre!("failed to build cookie: {e}"))?; self.page - .set_cookies(vec![cookie]) + .add_init_script(script?) .await - .context("failed to set cookie")?; + .context("failed to install first-party credential init script")?; Ok(()) } .boxed() diff --git a/browser/src/participant/local/session.rs b/browser/src/participant/local/session.rs index 2ce6d56..0b3d425 100644 --- a/browser/src/participant/local/session.rs +++ b/browser/src/participant/local/session.rs @@ -1,8 +1,8 @@ use super::chromium_driver::ChromiumDriver; use crate::{ auth::{ - BorrowedCookie, - HyperSessionCookieManger, + BorrowedCredentials, + FirstPartyCredentialsManager, }, participant::{ frontend::{ @@ -106,10 +106,10 @@ impl LocalChromiumSession { pub(crate) fn new( launch_spec: ParticipantLaunchSpec, browser_config: BrowserConfig, - auth: Option, - cookie_manager: HyperSessionCookieManger, + auth: Option, + credentials_manager: FirstPartyCredentialsManager, ) -> Self { - let frontend_builder = FrontendAuth::for_kind(launch_spec.frontend_kind, auth, cookie_manager); + let frontend_builder = FrontendAuth::for_kind(launch_spec.frontend_kind, auth, credentials_manager); let (termination_tx, termination_rx) = watch::channel(None); let closing = Arc::new(AtomicBool::new(false)); diff --git a/browser/src/participant/mod.rs b/browser/src/participant/mod.rs index ff24f35..8ace4ef 100644 --- a/browser/src/participant/mod.rs +++ b/browser/src/participant/mod.rs @@ -1,6 +1,6 @@ use super::auth::{ - BorrowedCookie, - HyperSessionCookieManger, + BorrowedCredentials, + FirstPartyCredentialsManager, }; use crate::participant::{ local::session::LocalChromiumSession, @@ -133,29 +133,28 @@ impl ParticipantTaskControl { } impl Participant { - pub fn spawn_with_app_config(config: &Config, cookie_manager: HyperSessionCookieManger) -> Result { + pub fn spawn_with_app_config(config: &Config, credentials_manager: FirstPartyCredentialsManager) -> Result { let session_url = config.url.clone().ok_or_eyre("No session URL provided in the config")?; - let base_url = session_url.origin().unicode_serialization(); - let cookie = cookie_manager.give_cookie(&base_url); - let name = cookie.as_ref().map(BorrowedCookie::username); + let credentials = credentials_manager.give_credentials(&session_url); + let name = credentials.as_ref().map(BorrowedCredentials::username); let participant_config = ParticipantConfig::new(config, name)?; debug!("Participant config: {:#?}", participant_config); - Self::with_participant_config(participant_config, cookie, cookie_manager) + Self::with_participant_config(participant_config, credentials, credentials_manager) } - pub fn spawn(config: &Config, cookie_manager: HyperSessionCookieManger) -> Result { + pub fn spawn(config: &Config, credentials_manager: FirstPartyCredentialsManager) -> Result { match config.backend { - ParticipantBackendKind::Local => Self::spawn_with_app_config(config, cookie_manager), - ParticipantBackendKind::Cloudflare => Self::spawn_cloudflare(config, cookie_manager), - ParticipantBackendKind::RemoteStub => Self::spawn_remote_stub(config, cookie_manager), - ParticipantBackendKind::AwsDeviceFarm => Self::spawn_device_farm(config, cookie_manager), + ParticipantBackendKind::Local => Self::spawn_with_app_config(config, credentials_manager), + ParticipantBackendKind::Cloudflare => Self::spawn_cloudflare(config, credentials_manager), + ParticipantBackendKind::RemoteStub => Self::spawn_remote_stub(config, credentials_manager), + ParticipantBackendKind::AwsDeviceFarm => Self::spawn_device_farm(config, credentials_manager), } } pub fn with_participant_config( participant_config: ParticipantConfig, - cookie: Option, - cookie_manager: HyperSessionCookieManger, + credentials: Option, + credentials_manager: FirstPartyCredentialsManager, ) -> Result { let launch_spec = ParticipantLaunchSpec::from(participant_config.clone()); let browser_config = client_simulator_config::BrowserConfig::from(&participant_config); @@ -166,7 +165,7 @@ impl Participant { let (state_receiver, task_guard) = spawn_session( name.clone(), receiver_tx, - LocalChromiumSession::new(launch_spec, browser_config, cookie, cookie_manager), + LocalChromiumSession::new(launch_spec, browser_config, credentials, credentials_manager), ); Ok(Self { @@ -179,11 +178,10 @@ impl Participant { }) } - pub fn spawn_remote_stub(config: &Config, cookie_manager: HyperSessionCookieManger) -> Result { + pub fn spawn_remote_stub(config: &Config, credentials_manager: FirstPartyCredentialsManager) -> Result { let session_url = config.url.clone().ok_or_eyre("No session URL provided in the config")?; - let base_url = session_url.origin().unicode_serialization(); - let cookie = cookie_manager.give_cookie(&base_url); - let name = cookie.as_ref().map(BorrowedCookie::username); + let credentials = credentials_manager.give_credentials(&session_url); + let name = credentials.as_ref().map(BorrowedCredentials::username); let participant_config = ParticipantConfig::new(config, name)?; let launch_spec = ParticipantLaunchSpec::from(participant_config); let name = launch_spec.username.clone(); @@ -201,15 +199,8 @@ impl Participant { }) } - pub fn spawn_cloudflare(config: &Config, cookie_manager: HyperSessionCookieManger) -> Result { - let session_url = config.url.clone().ok_or_eyre("No session URL provided in the config")?; - let frontend_kind = ResolvedFrontendKind::from_session_url(&session_url); - let base_url = session_url.origin().unicode_serialization(); - let cookie = matches!(frontend_kind, ResolvedFrontendKind::HyperCore) - .then(|| cookie_manager.give_cookie(&base_url)) - .flatten(); - let name = cookie.as_ref().map(BorrowedCookie::username); - let participant_config = ParticipantConfig::new(config, name)?; + pub fn spawn_cloudflare(config: &Config, _credentials_manager: FirstPartyCredentialsManager) -> Result { + let participant_config = ParticipantConfig::new(config, None::)?; let launch_spec = ParticipantLaunchSpec::from(participant_config); let name = launch_spec.username.clone(); @@ -221,8 +212,6 @@ impl Participant { launch_spec, cloudflare::CloudflareLaunchOptions::from(config), config.cloudflare.clone(), - cookie, - cookie_manager, ), ); @@ -236,18 +225,18 @@ impl Participant { }) } - pub fn spawn_device_farm(config: &Config, cookie_manager: HyperSessionCookieManger) -> Result { + pub fn spawn_device_farm(config: &Config, credentials_manager: FirstPartyCredentialsManager) -> Result { let device_farm_config = config.device_farm.clone(); let api = Arc::new(crate::participant::device_farm::AwsTestGrid::new( &device_farm_config.region, )); - Self::spawn_device_farm_with_api(config, cookie_manager, api) + Self::spawn_device_farm_with_api(config, credentials_manager, api) } #[doc(hidden)] pub fn spawn_device_farm_with_api( config: &Config, - cookie_manager: HyperSessionCookieManger, + credentials_manager: FirstPartyCredentialsManager, api: Arc, ) -> Result { use crate::participant::device_farm::{ @@ -257,11 +246,10 @@ impl Participant { let session_url = config.url.clone().ok_or_eyre("No session URL provided in the config")?; let frontend_kind = ResolvedFrontendKind::from_session_url(&session_url); - let base_url = session_url.origin().unicode_serialization(); - let cookie = matches!(frontend_kind, ResolvedFrontendKind::HyperCore) - .then(|| cookie_manager.give_cookie(&base_url)) + let credentials = matches!(frontend_kind, ResolvedFrontendKind::HyperCore) + .then(|| credentials_manager.give_credentials(&session_url)) .flatten(); - let name = cookie.as_ref().map(BorrowedCookie::username); + let name = credentials.as_ref().map(BorrowedCredentials::username); let participant_config = ParticipantConfig::new(config, name)?; let launch_spec = ParticipantLaunchSpec::from(participant_config); let name = launch_spec.username.clone(); @@ -278,8 +266,8 @@ impl Participant { launch_spec, launch_options, device_farm_config, - cookie, - cookie_manager, + credentials, + credentials_manager, api, ), ); diff --git a/browser/src/participant/shared/store.rs b/browser/src/participant/shared/store.rs index 06ef197..cae000a 100644 --- a/browser/src/participant/shared/store.rs +++ b/browser/src/participant/shared/store.rs @@ -1,7 +1,7 @@ use crate::{ auth::{ - HyperSessionCookieManger, - HyperSessionCookieStash, + FirstPartyCredentialsManager, + FirstPartyCredentialsStash, }, participant::{ Participant, @@ -27,24 +27,24 @@ use std::{ /// Store of active participants exposed to the TUI for display and control. #[derive(Debug, Clone)] pub struct ParticipantStore { - cookies: HyperSessionCookieManger, + credentials: FirstPartyCredentialsManager, inner: Arc>>, } impl ParticipantStore { pub fn new(data_dir: impl AsRef) -> Self { Self { - cookies: HyperSessionCookieStash::load_from_data_dir(data_dir).into(), + credentials: FirstPartyCredentialsStash::load_from_data_dir(data_dir).into(), inner: Default::default(), } } - pub fn cookies(&self) -> &HyperSessionCookieManger { - &self.cookies + pub fn credentials(&self) -> &FirstPartyCredentialsManager { + &self.credentials } pub fn spawn(&self, config: &Config) -> Result<()> { - let participant = Participant::spawn(config, self.cookies.clone())?; + let participant = Participant::spawn(config, self.credentials.clone())?; self.add(participant); Ok(()) } diff --git a/browser/tests/cloudflare_driver.rs b/browser/tests/cloudflare_driver.rs index 3e588dc..69a679c 100644 --- a/browser/tests/cloudflare_driver.rs +++ b/browser/tests/cloudflare_driver.rs @@ -1,5 +1,5 @@ use client_simulator_browser::{ - auth::HyperSessionCookieManger, + auth::FirstPartyCredentialsManager, participant::{ Participant, ParticipantState, @@ -100,10 +100,10 @@ async fn cloudflare_runtime_updates_public_participant_state_from_worker_command ), ]); let (base_url, requests, server) = spawn_http_server(responses).await; - let cookie_manager = HyperSessionCookieManger::new(unique_temp_dir().join("cookies.json")); + let credentials_manager = FirstPartyCredentialsManager::new(unique_temp_dir().join("credentials.json")); let participant = Participant::spawn( &cloudflare_config(&format!("{base_url}/m/demo"), &base_url, 60_000), - cookie_manager, + credentials_manager, ) .expect("cloudflare participant should spawn"); let state = participant.state.clone(); @@ -178,10 +178,10 @@ async fn cloudflare_runtime_survives_command_failures_and_can_still_close() { ), ]); let (base_url, requests, server) = spawn_http_server(responses).await; - let cookie_manager = HyperSessionCookieManger::new(unique_temp_dir().join("cookies.json")); + let credentials_manager = FirstPartyCredentialsManager::new(unique_temp_dir().join("credentials.json")); let participant = Participant::spawn( &cloudflare_config(&format!("{base_url}/m/demo"), &base_url, 60_000), - cookie_manager, + credentials_manager, ) .expect("cloudflare participant should spawn"); let state = participant.state.clone(); @@ -231,10 +231,10 @@ async fn cloudflare_runtime_marks_participant_stopped_when_worker_state_poll_fai ), ]); let (base_url, requests, server) = spawn_http_server(responses).await; - let cookie_manager = HyperSessionCookieManger::new(unique_temp_dir().join("cookies.json")); + let credentials_manager = FirstPartyCredentialsManager::new(unique_temp_dir().join("credentials.json")); let participant = Participant::spawn( &cloudflare_config(&format!("{base_url}/m/demo"), &base_url, 5), - cookie_manager, + credentials_manager, ) .expect("cloudflare participant should spawn"); let state = participant.state.clone(); @@ -255,14 +255,8 @@ async fn cloudflare_runtime_marks_participant_stopped_when_worker_state_poll_fai } #[tokio::test] -async fn cloudflare_runtime_fetches_hyper_core_cookie_before_creating_worker_session() { +async fn cloudflare_runtime_delegates_hyper_core_authentication_to_the_worker() { let responses = VecDeque::from(vec![ - MockResponse::new( - 200, - "Set-Cookie: hyper_session=fetched-cookie; Path=/; HttpOnly\r\n", - "", - ), - MockResponse::json(200, json!({ "ok": true })), MockResponse::json( 200, json!({ @@ -304,10 +298,10 @@ async fn cloudflare_runtime_fetches_hyper_core_cookie_before_creating_worker_ses ), ]); let (base_url, requests, server) = spawn_http_server(responses).await; - let cookie_manager = HyperSessionCookieManger::new(unique_temp_dir().join("cookies.json")); + let credentials_manager = FirstPartyCredentialsManager::new(unique_temp_dir().join("credentials.json")); let participant = Participant::spawn( &cloudflare_config(&format!("{base_url}/room/demo"), &base_url, 60_000), - cookie_manager, + credentials_manager, ) .expect("cloudflare participant should spawn"); let state = participant.state.clone(); @@ -332,27 +326,14 @@ async fn cloudflare_runtime_fetches_hyper_core_cookie_before_creating_worker_ses server.abort(); let requests = requests.lock().unwrap().clone(); - assert_eq!(requests.len(), 5); + assert_eq!(requests.len(), 3); assert_eq!(requests[0].method, "POST"); - assert_eq!(requests[0].path, "/api/v1/auth/guest?username=guest"); - assert_eq!(requests[1].method, "PUT"); - assert_eq!(requests[1].path, "/api/v1/auth/me/name"); - assert_eq!( - header_value(&requests[1], "cookie").as_deref(), - Some("hyper_session=fetched-cookie") - ); - - let set_name_body = request_json(&requests[1]); - let display_name = request_json(&requests[2])["displayName"].clone(); - assert_eq!(requests[2].path, "/sessions"); - assert_eq!( - request_json(&requests[2])["hyperSessionCookie"], - json!("fetched-cookie") - ); - assert_eq!(display_name, set_name_body["name"]); - assert_eq!(requests[3].path, "/sessions/cf-runtime-core/commands"); - assert_eq!(request_json(&requests[3]), json!({ "type": "leave" })); - assert_eq!(requests[4].path, "/sessions/cf-runtime-core/close"); + assert_eq!(requests[0].path, "/sessions"); + assert!(request_json(&requests[0])["displayName"].is_string()); + assert!(request_json(&requests[0]).get("hyperSessionCookie").is_none()); + assert_eq!(requests[1].path, "/sessions/cf-runtime-core/commands"); + assert_eq!(request_json(&requests[1]), json!({ "type": "leave" })); + assert_eq!(requests[2].path, "/sessions/cf-runtime-core/close"); } fn cloudflare_config(session_url: &str, base_url: &str, health_poll_interval_ms: u64) -> Config { @@ -405,7 +386,6 @@ where struct CapturedRequest { method: String, path: String, - headers: Vec<(String, String)>, body: String, } @@ -416,14 +396,6 @@ struct MockResponse { } impl MockResponse { - fn new(status: u16, headers: &str, body: &str) -> Self { - Self { - status, - headers: headers.to_owned(), - body: body.to_owned(), - } - } - fn json(status: u16, body: Value) -> Self { Self { status, @@ -487,7 +459,6 @@ async fn read_request(stream: &mut tokio::net::TcpStream) -> CapturedRequest { let method = request_line.next().unwrap().to_owned(); let path = request_line.next().unwrap().to_owned(); - let mut headers = Vec::new(); let mut content_length = 0_usize; for line in lines.filter(|line| !line.is_empty()) { let (name, value) = line.split_once(':').unwrap(); @@ -495,7 +466,6 @@ async fn read_request(stream: &mut tokio::net::TcpStream) -> CapturedRequest { if name.eq_ignore_ascii_case("content-length") { content_length = value.parse().unwrap(); } - headers.push((name.to_ascii_lowercase(), value)); } let body_start = header_end + 4; @@ -509,7 +479,6 @@ async fn read_request(stream: &mut tokio::net::TcpStream) -> CapturedRequest { CapturedRequest { method, path, - headers, body: String::from_utf8(body).unwrap(), } } @@ -518,14 +487,6 @@ fn find_header_end(buffer: &[u8]) -> Option { buffer.windows(4).position(|window| window == b"\r\n\r\n") } -fn header_value(request: &CapturedRequest, name: &str) -> Option { - request - .headers - .iter() - .find(|(header_name, _)| header_name == &name.to_ascii_lowercase()) - .map(|(_, value)| value.clone()) -} - fn request_json(request: &CapturedRequest) -> Value { serde_json::from_str(&request.body).unwrap() } diff --git a/browser/tests/device_farm_driver.rs b/browser/tests/device_farm_driver.rs index 81cbbe9..9b55d38 100644 --- a/browser/tests/device_farm_driver.rs +++ b/browser/tests/device_farm_driver.rs @@ -5,7 +5,7 @@ //! that points thirtyfour at that server. No real AWS or browser is involved. use client_simulator_browser::{ - auth::HyperSessionCookieManger, + auth::FirstPartyCredentialsManager, participant::{ device_farm::{ close_test_grid_session, @@ -60,10 +60,10 @@ use tokio::{ #[tokio::test] async fn device_farm_session_creates_url_connects_joins_and_closes() { let (base_url, requests, server) = spawn_webdriver_mock().await; - let cookie_manager = HyperSessionCookieManger::new(unique_temp_dir().join("cookies.json")); + let credentials_manager = FirstPartyCredentialsManager::new(unique_temp_dir().join("credentials.json")); let participant = Participant::spawn_device_farm_with_api( &device_farm_config(), - cookie_manager, + credentials_manager, Arc::new(TestGridStub { url: base_url }), ) .expect("device farm participant should spawn"); @@ -97,11 +97,11 @@ async fn device_farm_session_creates_url_connects_joins_and_closes() { async fn device_farm_session_requests_and_emits_browser_logs() { let logs = CapturedLogs::new(); let (base_url, requests, server) = spawn_webdriver_mock().await; - let cookie_manager = HyperSessionCookieManger::new(unique_temp_dir().join("cookies.json")); + let credentials_manager = FirstPartyCredentialsManager::new(unique_temp_dir().join("credentials.json")); let mut config = device_farm_config(); config.browser_logs = true; let participant = - Participant::spawn_device_farm_with_api(&config, cookie_manager, Arc::new(TestGridStub { url: base_url })) + Participant::spawn_device_farm_with_api(&config, credentials_manager, Arc::new(TestGridStub { url: base_url })) .expect("device farm participant should spawn"); let participant_name = participant.name.clone(); let state = participant.state.clone(); @@ -144,11 +144,11 @@ async fn device_farm_session_falls_back_to_legacy_browser_log_endpoint() { ..Default::default() }) .await; - let cookie_manager = HyperSessionCookieManger::new(unique_temp_dir().join("cookies.json")); + let credentials_manager = FirstPartyCredentialsManager::new(unique_temp_dir().join("credentials.json")); let mut config = device_farm_config(); config.browser_logs = true; let participant = - Participant::spawn_device_farm_with_api(&config, cookie_manager, Arc::new(TestGridStub { url: base_url })) + Participant::spawn_device_farm_with_api(&config, credentials_manager, Arc::new(TestGridStub { url: base_url })) .expect("device farm participant should spawn"); let state = participant.state.clone(); @@ -185,12 +185,12 @@ async fn device_farm_session_preserves_signed_test_grid_url_path_and_query() { ..Default::default() }) .await; - let cookie_manager = HyperSessionCookieManger::new(unique_temp_dir().join("cookies.json")); + let credentials_manager = FirstPartyCredentialsManager::new(unique_temp_dir().join("credentials.json")); let mut config = device_farm_config(); config.browser_logs = true; let participant = Participant::spawn_device_farm_with_api( &config, - cookie_manager, + credentials_manager, Arc::new(TestGridStub { url: format!("{base_url}/signed-grid/wd/hub?{signature}"), }), @@ -256,11 +256,11 @@ async fn device_farm_session_polls_and_publishes_frontend_state_changes() { ..Default::default() }) .await; - let cookie_manager = HyperSessionCookieManger::new(unique_temp_dir().join("cookies.json")); + let credentials_manager = FirstPartyCredentialsManager::new(unique_temp_dir().join("credentials.json")); let mut config = device_farm_config(); config.device_farm.health_poll_interval_ms = 100; let participant = - Participant::spawn_device_farm_with_api(&config, cookie_manager, Arc::new(TestGridStub { url: base_url })) + Participant::spawn_device_farm_with_api(&config, credentials_manager, Arc::new(TestGridStub { url: base_url })) .expect("device farm participant should spawn"); let state = participant.state.clone(); @@ -342,11 +342,11 @@ async fn device_farm_session_stops_when_webdriver_health_check_fails() { ..Default::default() }) .await; - let cookie_manager = HyperSessionCookieManger::new(unique_temp_dir().join("cookies.json")); + let credentials_manager = FirstPartyCredentialsManager::new(unique_temp_dir().join("credentials.json")); let mut config = device_farm_config(); config.device_farm.health_poll_interval_ms = 20; let participant = - Participant::spawn_device_farm_with_api(&config, cookie_manager, Arc::new(TestGridStub { url: base_url })) + Participant::spawn_device_farm_with_api(&config, credentials_manager, Arc::new(TestGridStub { url: base_url })) .expect("device farm participant should spawn"); let state = participant.state.clone(); diff --git a/cloudflare-browser-simulator b/cloudflare-browser-simulator index d08a416..f240fe0 160000 --- a/cloudflare-browser-simulator +++ b/cloudflare-browser-simulator @@ -1 +1 @@ -Subproject commit d08a416ea1f1e2245dd86eb8330b2a2004559e91 +Subproject commit f240fe094eaba67adc35c8cdb6316c7233fc578f diff --git a/config/src/args.rs b/config/src/args.rs index b4f9bd5..97fe0bc 100644 --- a/config/src/args.rs +++ b/config/src/args.rs @@ -5,10 +5,6 @@ pub struct TuiArgs { #[clap(long, value_name = "URL")] pub url: Option, - /// Optional authentication cookie to override the stored configuration. - #[clap(long, value_name = "COOKIE")] - pub cookie: Option, - /// Enable or disable fake WebRTC devices/UI. /// - adds `--use-fake-device-for-media-stream` /// - adds `--use-fake-ui-for-media-stream` @@ -44,9 +40,6 @@ mod config_ext { if let Some(url) = &self.url { cache.insert("url".to_string(), url.clone().into()); } - if let Some(cookie) = &self.cookie { - cache.insert("cookie".to_string(), cookie.clone().into()); - } if let Some(fake_media) = &self.fake_media { cache.insert("fake_media".to_string(), (*fake_media).into()); } diff --git a/docs/browser-driver.md b/docs/browser-driver.md index c8ad28d..3a41ee3 100644 --- a/docs/browser-driver.md +++ b/docs/browser-driver.md @@ -116,9 +116,9 @@ If the goal is not just "implement the runtime trait" but "replace the current l ### 5.2 Authentication / identity setup -- Hyper Core currently requires a `hyper_session` cookie to be present in the browser before navigation. The local driver either reuses a stored cookie or fetches a new guest cookie and sets the display name through the HTTP auth API. See [`browser/src/participant/local/session.rs`](../browser/src/participant/local/session.rs) `:263-279`, [`browser/src/auth.rs`](../browser/src/auth.rs) `:61-85`, `:253-349`, and [`browser/src/participant/local/core.rs`](../browser/src/participant/local/core.rs) `:57-80`. +- Hyper Core requires a first-party credential envelope before navigation. The simulator reuses or fetches guest credentials and installs the envelope with a browser init script; Hyper Core owns renewal after startup. The Cloudflare worker fetches its own guest envelope so renewal credentials are not forwarded through the worker API. - Hyper Core then fills the participant name in the join form and clicks join. See [`browser/src/participant/local/core.rs`](../browser/src/participant/local/core.rs) `:90-140`. -- Hyper Lite does not use the cookie path and joins by clicking the join button directly. See [`browser/src/participant/local/lite.rs`](../browser/src/participant/local/lite.rs) `:49-88`. +- Hyper Lite does not use the Hyper Core credential path and joins by clicking the join button directly. See [`browser/src/participant/local/lite.rs`](../browser/src/participant/local/lite.rs) `:49-88`. ### 5.3 Frontend-specific control hooks @@ -171,7 +171,7 @@ If you are implementing a new driver, this is the minimum checklist: If you need drop-in parity with the current local Chromium backend, also implement: -- Hyper Core auth/cookie setup, +- Hyper Core first-party credential setup, - Hyper Core and Hyper Lite frontend control flows, - fake media injection, - headless/headed browser startup, diff --git a/justfile b/justfile index 7e636c1..ad82ea8 100644 --- a/justfile +++ b/justfile @@ -48,8 +48,8 @@ dist-build *args="": # -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- -fetch-cookie username="simulator-user" server-url="http://localhost:8081": - cargo run -q -- cookie --url {{ server-url }} --user {{ username }} +fetch-credentials username="simulator-user" server-url="http://localhost:8081": + cargo run -q -- credentials --url {{ server-url }} --user {{ username }} cachix-push: nix build --no-link --print-out-paths \ diff --git a/src/main.rs b/src/main.rs index e1cd05d..3ac6763 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,10 +9,7 @@ use clap::{ }; use client_simulator_config::TuiArgs; use client_simulator_tui::start_tui; -use eyre::{ - Context as _, - OptionExt as _, -}; +use eyre::Context as _; use tracing_subscriber::{ filter::LevelFilter, fmt, @@ -78,8 +75,8 @@ enum Command { Tui(TuiArgs), /// Start simulator participants without the TUI Headless(headless::HeadlessArgs), - /// Connect to the hyper server to get a hyper session cookie - Cookie(CookieArgs), + /// Connect to the Hyper server to get first-party guest credentials + Credentials(CredentialsArgs), /// Manage AWS Device Farm Test Grid sessions Aws(aws::AwsArgs), /// Manage sessions on the Cloudflare browser simulator worker @@ -372,12 +369,12 @@ mod tests { } #[derive(clap::Args, Debug, Clone)] -pub struct CookieArgs { +pub struct CredentialsArgs { /// Base URL of the hyper server #[clap(long = "url", value_name = "URL", default_value = "http://localhost:8081")] pub base_url: url::Url, - /// Username for the hyper session + /// Username for the guest account #[clap(long, value_name = "USERNAME", default_value = "browser-simulator user")] pub user: String, } @@ -398,13 +395,13 @@ async fn main() -> eyre::Result<()> { let code = headless::run(args, logging_filter_from_env(logging)).await?; std::process::exit(code); } - Some(Command::Cookie(args)) => run_cookie(args, logging_filter_from_env(logging)).await, + Some(Command::Credentials(args)) => run_credentials(args, logging_filter_from_env(logging)).await, Some(Command::Aws(args)) => aws::run(args, logging_filter_from_env(logging)).await, Some(Command::Cf(args)) => cf::run(args, logging_filter_from_env(logging)).await, } } -async fn run_cookie(CookieArgs { base_url, user }: CookieArgs, filter: EnvFilter) -> eyre::Result<()> { +async fn run_credentials(CredentialsArgs { base_url, user }: CredentialsArgs, filter: EnvFilter) -> eyre::Result<()> { registry() .with( fmt::layer() @@ -415,21 +412,14 @@ async fn run_cookie(CookieArgs { base_url, user }: CookieArgs, filter: EnvFilter .with(tracing_error::ErrorLayer::default()) .init(); - let domain = base_url - .host_str() - .ok_or_eyre("Base URL must have a valid host")? - .to_string(); let config = client_simulator_config::Config::new(Default::default()).context("Failed to create config")?; let participants_store = client_simulator_browser::participant::ParticipantStore::new(config.data_dir()); - let cookie = participants_store - .cookies() - .give_or_fetch_cookie(base_url, user) + let credentials = participants_store + .credentials() + .give_or_fetch_credentials(base_url, user) .await - .context("Failed to fetch or give cookie")?; - let cookie = cookie - .as_browser_cookie_for(&domain) - .context("Failed to convert cookie for browser")?; - let json = serde_json::to_string(&cookie).context("Failed to serialize cookie to JSON")?; + .context("Failed to fetch first-party credentials")?; + let json = credentials.envelope_json()?; println!("{json}"); diff --git a/tui/examples/join-hyper-session.rs b/tui/examples/join-hyper-session.rs index 192be36..b600024 100644 --- a/tui/examples/join-hyper-session.rs +++ b/tui/examples/join-hyper-session.rs @@ -1,6 +1,6 @@ use clap::Parser; use client_simulator_browser::{ - auth::HyperSessionCookieManger, + auth::FirstPartyCredentialsManager, participant::Participant, }; use client_simulator_config::{ @@ -36,7 +36,7 @@ async fn run(Args { url }: Args) -> Result<()> { }, }, None, - HyperSessionCookieManger::new("cookies.json"), + FirstPartyCredentialsManager::new("first_party_credentials.json"), ) .expect("Failed to create participant config"); From 87a53936adb52155a5c9f478ac6f60eb643f279c Mon Sep 17 00:00:00 2001 From: Robin Schreiber Date: Thu, 27 Aug 2026 21:05:32 +0200 Subject: [PATCH 2/2] Fix simulator Clippy lint --- tui/src/tui/components/nav_tabs.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tui/src/tui/components/nav_tabs.rs b/tui/src/tui/components/nav_tabs.rs index abf12ba..8f2889b 100644 --- a/tui/src/tui/components/nav_tabs.rs +++ b/tui/src/tui/components/nav_tabs.rs @@ -68,7 +68,7 @@ impl Component for NavTabs { }; // Define tab titles for each Mode - let tab_titles = vec!["Browser [1]".to_string(), format!("Logs [2]")]; + let tab_titles = vec!["Browser [1]".to_string(), "Logs [2]".to_string()]; let selected_tab = match self.screen { Screen::BrowserStart => 0,