diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 4eddf7bd7b..370fa6785b 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -203,6 +203,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "ascii-canvas" version = "3.0.0" @@ -481,6 +487,12 @@ dependencies = [ "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfg-expr" version = "0.15.8" @@ -518,6 +530,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + [[package]] name = "clap" version = "4.5.41" @@ -795,13 +813,21 @@ version = "0.0.0" dependencies = [ "base64 0.22.1", "chrono", + "hex", "pretty_assertions", + "rand 0.8.5", "reqwest", "serde", "serde_json", + "sha2", "tempfile", "thiserror 2.0.12", + "tiny_http", "tokio", + "ureq", + "url", + "urlencoding", + "webbrowser", ] [[package]] @@ -947,6 +973,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "compact_str" version = "0.8.1" @@ -1001,6 +1037,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -2451,6 +2497,28 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + [[package]] name = "jobserver" version = "0.1.33" @@ -2787,6 +2855,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -2940,6 +3014,31 @@ dependencies = [ "libc", ] +[[package]] +name = "objc2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88c6597e14493ab2e44ce58f2fdecf095a51f12ca57bec060a11c57332520551" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" +dependencies = [ + "bitflags 2.9.1", + "objc2", +] + [[package]] name = "object" version = "0.36.7" @@ -3654,6 +3753,7 @@ dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", + "futures-channel", "futures-core", "futures-util", "h2", @@ -3785,7 +3885,9 @@ version = "0.23.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2491382039b29b9b11ff08b76ff6c97cf287671dbb74f0be44bda389fffe9bd1" dependencies = [ + "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -3976,7 +4078,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ "bitflags 2.9.1", - "core-foundation", + "core-foundation 0.9.4", "core-foundation-sys", "libc", "security-framework-sys", @@ -4135,6 +4237,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -4499,7 +4612,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ "bitflags 2.9.1", - "core-foundation", + "core-foundation 0.9.4", "system-configuration-sys", ] @@ -4694,6 +4807,18 @@ dependencies = [ "crunchy", ] +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + [[package]] name = "tinystr" version = "0.8.1" @@ -5134,6 +5259,23 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.4" @@ -5146,6 +5288,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -5369,6 +5517,40 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webbrowser" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf4f3c0ba838e82b4e5ccc4157003fb8c324ee24c058470ffb82820becbde98" +dependencies = [ + "core-foundation 0.10.1", + "jni", + "log", + "ndk-context", + "objc2", + "objc2-foundation", + "url", + "web-sys", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.2", +] + +[[package]] +name = "webpki-roots" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.10" @@ -5557,6 +5739,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -5584,6 +5775,21 @@ dependencies = [ "windows-targets 0.53.2", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -5616,6 +5822,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.0", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -5628,6 +5840,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -5640,6 +5858,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -5664,6 +5888,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -5676,6 +5906,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -5688,6 +5924,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -5700,6 +5942,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/codex-rs/login/Cargo.toml b/codex-rs/login/Cargo.toml index a290c01eb6..67835f7099 100644 --- a/codex-rs/login/Cargo.toml +++ b/codex-rs/login/Cargo.toml @@ -6,10 +6,13 @@ version = { workspace = true } [lints] workspace = true +[features] +http-e2e-tests = [] + [dependencies] base64 = "0.22" chrono = { version = "0.4", features = ["serde"] } -reqwest = { version = "0.12", features = ["json"] } +reqwest = { version = "0.12", features = ["json", "blocking"] } serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2.0.12" @@ -20,7 +23,15 @@ tokio = { version = "1", features = [ "rt-multi-thread", "signal", ] } +hex = "0.4" +rand = "0.8" +sha2 = "0.10" +tiny_http = "0.12" +url = "2" +urlencoding = "2" +webbrowser = "1" [dev-dependencies] pretty_assertions = "1.4.1" tempfile = "3" +ureq = { version = "2", default-features = false, features = ["tls", "json"] } diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index 2a8f6749b4..1151ded007 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -18,14 +18,18 @@ use std::process::Stdio; use std::sync::Arc; use std::sync::Mutex; use std::time::Duration; -use tokio::process::Command; +// use tokio::process::Command; // no longer used pub use crate::token_data::TokenData; use crate::token_data::parse_id_token; mod token_data; - -const SOURCE_FOR_PYTHON_SERVER: &str = include_str!("./login_with_chatgpt.py"); +mod server; +pub use server::LoginServerOptions; +pub use server::run_local_login_server_with_options; +pub use server::process_callback_headless; +pub use server::HeadlessOutcome; +pub use server::Http; const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY"; @@ -261,13 +265,12 @@ pub struct SpawnedLogin { pub stderr: Arc>>, } -/// Spawn the ChatGPT login Python server as a child process and return a handle to its process. +/// Spawn the Rust login server via the current executable ("codex login") and return a handle to its process. pub fn spawn_login_with_chatgpt(codex_home: &Path) -> std::io::Result { - let mut cmd = std::process::Command::new("python3"); - cmd.arg("-c") - .arg(SOURCE_FOR_PYTHON_SERVER) + let current_exe = std::env::current_exe()?; + let mut cmd = std::process::Command::new(current_exe); + cmd.arg("login") .env("CODEX_HOME", codex_home) - .env("CODEX_CLIENT_ID", CLIENT_ID) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -314,34 +317,12 @@ pub fn spawn_login_with_chatgpt(codex_home: &Path) -> std::io::Result std::io::Result<()> { - let child = Command::new("python3") - .arg("-c") - .arg(SOURCE_FOR_PYTHON_SERVER) - .env("CODEX_HOME", codex_home) - .env("CODEX_CLIENT_ID", CLIENT_ID) - .stdin(Stdio::null()) - .stdout(if capture_output { - Stdio::piped() - } else { - Stdio::inherit() - }) - .stderr(if capture_output { - Stdio::piped() - } else { - Stdio::inherit() - }) - .spawn()?; - - let output = child.wait_with_output().await?; - if output.status.success() { - Ok(()) - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - Err(std::io::Error::other(format!( - "login_with_chatgpt subprocess failed: {stderr}" - ))) - } +pub async fn login_with_chatgpt(codex_home: &Path, _capture_output: bool) -> std::io::Result<()> { + let codex_home = codex_home.to_path_buf(); + tokio::task::spawn_blocking(move || server::run_local_login_server(&codex_home, CLIENT_ID)) + .await + .map_err(|e| std::io::Error::other(format!("task join error: {e}")))??; + Ok(()) } pub fn login_with_api_key(codex_home: &Path, api_key: &str) -> std::io::Result<()> { diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs new file mode 100644 index 0000000000..f125a7a064 --- /dev/null +++ b/codex-rs/login/src/server.rs @@ -0,0 +1,728 @@ +use base64::Engine as _; +use chrono::Utc; +use rand::RngCore; +use reqwest::blocking::Client; +use serde::Deserialize; +use serde_json::json; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::fs::{OpenOptions}; +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; +use std::path::Path; +use std::time::Duration; +use tiny_http::{Header, Method, Response, Server}; +use url::Url; +use std::path::PathBuf; + +const DEFAULT_PORT: u16 = 1455; +const DEFAULT_ISSUER: &str = "https://auth.openai.com"; + +// Copied from the Python HTML to keep UX consistent. +pub const LOGIN_SUCCESS_HTML: &str = include_str!("./success_page.html"); + +#[derive(Debug, Clone)] +pub struct PkceCodes { + code_verifier: String, + code_challenge: String, +} + +fn generate_pkce() -> PkceCodes { + // Equivalent to Python's secrets.token_hex(64) + let mut bytes = [0u8; 64]; + rand::thread_rng().fill_bytes(&mut bytes); + let code_verifier = hex::encode(bytes); + let digest = Sha256::digest(code_verifier.as_bytes()); + let code_challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest); + PkceCodes { + code_verifier, + code_challenge, + } +} + +#[derive(Debug, Deserialize)] +struct CodeExchangeResponse { + id_token: String, + access_token: String, + refresh_token: String, +} + +#[derive(Debug, Deserialize)] +struct TokenExchangeResponse { + access_token: String, +} + +fn now_rfc3339_z() -> String { + Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Micros, true) +} + +fn decode_jwt_payload_segment(segment_b64: &str) -> serde_json::Value { + let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(segment_b64) + .ok(); + decoded + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .unwrap_or(serde_json::Value::Object(Default::default())) +} + +fn parse_jwt_claims(token: &str) -> serde_json::Value { + // Expect three segments: header.payload.signature + let mut parts = token.split('.'); + let _header = parts.next(); + let payload = parts.next(); + let _sig = parts.next(); + match payload { + Some(p) if !p.is_empty() => decode_jwt_payload_segment(p), + _ => serde_json::Value::Object(Default::default()), + } +} + +fn write_auth_file( + codex_home: &Path, + api_key: Option, + id_token: &str, + access_token: &str, + refresh_token: &str, + account_id: Option, +) -> std::io::Result<()> { + std::fs::create_dir_all(codex_home)?; + let auth_path = codex_home.join("auth.json"); + + let contents = json!({ + "OPENAI_API_KEY": api_key, + "tokens": { + "id_token": id_token, + "access_token": access_token, + "refresh_token": refresh_token, + "account_id": account_id, + }, + "last_refresh": now_rfc3339_z(), + }) + .to_string(); + + let mut opts = OpenOptions::new(); + opts.create(true).truncate(true).write(true); + #[cfg(unix)] + { + opts.mode(0o600); + } + let mut f = opts.open(auth_path)?; + use std::io::Write; + f.write_all(contents.as_bytes())?; + f.flush() +} + +fn maybe_redeem_credits( + issuer: &str, + client_id: &str, + id_token_opt: Option<&str>, + refresh_token: &str, + codex_home: &Path, +) { + // Best-effort: any error should not abort the login flow. + let client = Client::builder() + .timeout(Duration::from_secs(30)) + .build(); + let Ok(client) = client else { return }; + + // Parse initial ID token claims and check expiration. + let mut id_token = id_token_opt.unwrap_or("").to_string(); + let mut claims = parse_jwt_claims(&id_token); + + let mut token_expired = true; + if let Some(exp) = claims.get("exp").and_then(|v| v.as_i64()) { + let now_ms = (Utc::now().timestamp_millis()) as i64; + token_expired = now_ms >= exp * 1000; + } + + if token_expired { + eprintln!("Refreshing credentials..."); + #[derive(serde::Serialize)] + struct RefreshReq<'a> { + client_id: &'a str, + grant_type: &'a str, + refresh_token: &'a str, + scope: &'a str, + } + let body = RefreshReq { + client_id, + grant_type: "refresh_token", + refresh_token, + scope: "openid profile email", + }; + let resp = client + .post("https://auth.openai.com/oauth/token") + .json(&body) + .send(); + let Ok(resp) = resp else { return }; + let Ok(val) = resp.json::() else { return }; + let new_id_token = val.get("id_token").and_then(|v| v.as_str()).map(|s| s.to_string()); + let new_refresh_token = val + .get("refresh_token") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + if let (Some(new_id), Some(new_refresh)) = (new_id_token, new_refresh_token) { + // Update file on disk with new tokens. + // Read, modify, write. + let path = codex_home.join("auth.json"); + if let Ok(mut existing) = std::fs::read_to_string(&path) { + if let Ok(mut obj) = serde_json::from_str::(&existing) { + obj["tokens"]["id_token"] = serde_json::Value::String(new_id.clone()); + obj["tokens"]["refresh_token"] = serde_json::Value::String(new_refresh.clone()); + obj["tokens"]["last_refresh"] = serde_json::Value::String(now_rfc3339_z()); + existing = serde_json::to_string_pretty(&obj).unwrap_or(existing); + let _ = std::fs::write(&path, existing); + id_token = new_id; + claims = parse_jwt_claims(&id_token); + } + } + } else { + return; + } + } + + // Eligibility checks + let auth_claims = claims + .get("https://api.openai.com/auth") + .cloned() + .unwrap_or(serde_json::Value::Object(Default::default())); + let needs_setup = { + let completed = auth_claims + .get("completed_platform_onboarding") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let is_owner = auth_claims + .get("is_org_owner") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + !completed && is_owner + }; + if needs_setup { + eprintln!("Only users with Plus or Pro subscriptions can redeem free API credits."); + return; + } + let plan_type = auth_claims + .get("chatgpt_plan_type") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if plan_type != "plus" && plan_type != "pro" { + eprintln!("Only users with Plus or Pro subscriptions can redeem free API credits."); + return; + } + + let api_host = if issuer == DEFAULT_ISSUER { + "https://api.openai.com" + } else { + "https://api.openai.org" + }; + + let payload = json!({"id_token": id_token}); + let resp = client + .post(format!("{api_host}/v1/billing/redeem_credits")) + .json(&payload) + .send(); + match resp { + Ok(r) => match r.json::() { + Ok(val) => { + let granted = val + .get("granted_chatgpt_subscriber_api_credits") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + if granted > 0 { + let amount = if plan_type == "plus" { "$5" } else { "$50" }; + eprintln!( + "Thanks for being a ChatGPT {} subscriber! If you haven't already redeemed, you should receive {} in API credits.", + if plan_type == "plus" { "Plus" } else { "Pro" }, + amount + ); + } else { + eprintln!("It looks like no credits were granted: {}", val); + } + } + Err(_) => {} + }, + Err(_) => {} + } +} + +#[derive(Debug, Clone)] +pub struct LoginServerOptions { + pub codex_home: PathBuf, + pub client_id: String, + pub issuer: String, + pub port: u16, + pub open_browser: bool, + pub redeem_credits: bool, + pub expose_state_endpoint: bool, + /// When set, the server will auto-exit after the specified number of seconds by + /// issuing an internal request to a test-only endpoint. Intended for CI/tests. + pub testing_timeout_secs: Option, +} + +fn default_url_base(port: u16) -> String { format!("http://127.0.0.1:{port}") } + +pub fn run_local_login_server(codex_home: &Path, client_id: &str) -> std::io::Result<()> { + let opts = LoginServerOptions { + codex_home: codex_home.to_path_buf(), + client_id: client_id.to_string(), + issuer: DEFAULT_ISSUER.to_string(), + port: DEFAULT_PORT, + open_browser: true, + redeem_credits: true, + expose_state_endpoint: false, + testing_timeout_secs: None, + }; + run_local_login_server_with_options(opts) +} + +pub fn run_local_login_server_with_options(opts: LoginServerOptions) -> std::io::Result<()> { + let addr = format!("127.0.0.1:{}", opts.port); + let server = Server::http(&addr) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; + + let issuer = opts.issuer.clone(); + let token_endpoint = format!("{}/oauth/token", issuer); + let url_base = default_url_base(opts.port); + + let pkce = generate_pkce(); + let state = { + let mut bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + hex::encode(bytes) + }; + + let redirect_uri = format!("{}/auth/callback", url_base); + let mut auth_url = Url::parse(&format!("{}/oauth/authorize", issuer)).unwrap(); + auth_url.query_pairs_mut() + .append_pair("response_type", "code") + .append_pair("client_id", &opts.client_id) + .append_pair("redirect_uri", &redirect_uri) + .append_pair("scope", "openid profile email offline_access") + .append_pair("code_challenge", &pkce.code_challenge) + .append_pair("code_challenge_method", "S256") + .append_pair("id_token_add_organizations", "true") + .append_pair("codex_cli_simplified_flow", "true") + .append_pair("state", &state); + + eprintln!("Starting local login server on {}", url_base); + // Try to open the browser, but ignore failures. + if opts.open_browser { + let _ = webbrowser::open(auth_url.as_str()); + } + eprintln!( + ". If your browser did not open, navigate to this URL to authenticate: \n\n{}", + auth_url + ); + + let client = Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; + + // If a testing timeout is configured, schedule an internal exit request so tests don't hang. + if let Some(secs) = opts.testing_timeout_secs { + let port = opts.port; + std::thread::spawn(move || { + std::thread::sleep(Duration::from_secs(secs)); + let _ = reqwest::blocking::get(format!("http://127.0.0.1:{port}/__test/exit")); + }); + } + + // Main request loop + 'outer: loop { + let request = match server.recv() { + Ok(r) => r, + Err(e) => return Err(std::io::Error::new(std::io::ErrorKind::Other, e.to_string())), + }; + + // Parse URL path and query + let full = request.url().to_string(); + let (path, query) = match full.split_once('?') { + Some((p, q)) => (p.to_string(), Some(q.to_string())), + None => (full.clone(), None), + }; + + match (request.method().clone(), path.as_str()) { + (Method::Get, "/success") => { + let mut resp = Response::from_string(LOGIN_SUCCESS_HTML) + .with_status_code(200); + resp.add_header(Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]).unwrap()); + let _ = request.respond(resp); + break 'outer; + } + (Method::Get, "/__test/exit") => { + let _ = request.respond(Response::from_string("bye").with_status_code(200)); + break 'outer; + } + // Test-only helper to retrieve the current state, enabled via options. + (Method::Get, "/__test/state") if opts.expose_state_endpoint => { + let mut resp = Response::from_string(state.clone()).with_status_code(200); + resp.add_header(Header::from_bytes(&b"Content-Type"[..], &b"text/plain"[..]).unwrap()); + let _ = request.respond(resp); + } + (Method::Get, "/auth/callback") => { + // Parse query params + let params: HashMap = query + .as_deref() + .unwrap_or("") + .split('&') + .filter_map(|kv| kv.split_once('=')) + .filter_map(|(k, v)| { + let kk = urlencoding::decode(k).ok()?.into_owned(); + let vv = urlencoding::decode(v).ok()?.into_owned(); + Some((kk, vv)) + }) + .collect(); + + if params.get("state").map(|s| s.as_str()) != Some(state.as_str()) { + let _ = request.respond(Response::from_string("State parameter mismatch").with_status_code(400)); + continue; + } + let code = match params.get("code").cloned() { + Some(c) if !c.is_empty() => c, + _ => { + let _ = request.respond(Response::from_string("Missing authorization code").with_status_code(400)); + continue; + } + }; + + // 1) Authorization code -> tokens + let token_resp = client + .post(&token_endpoint) + .form(&[ + ("grant_type", "authorization_code"), + ("code", code.as_str()), + ("redirect_uri", redirect_uri.as_str()), + ("client_id", opts.client_id.as_str()), + ("code_verifier", pkce.code_verifier.as_str()), + ]) + .send(); + let Ok(token_resp) = token_resp else { + let _ = request.respond(Response::from_string("Token exchange failed").with_status_code(500)); + continue; + }; + let Ok(tokens) = token_resp.json::() else { + let _ = request.respond(Response::from_string("Token exchange failed").with_status_code(500)); + continue; + }; + + // Extract account_id from id_token claims + let id_claims = parse_jwt_claims(&tokens.id_token); + let auth_claims = id_claims + .get("https://api.openai.com/auth") + .cloned() + .unwrap_or(serde_json::Value::Object(Default::default())); + let account_id = auth_claims + .get("chatgpt_account_id") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + // Parse access token claims to compute redirect target + let access_claims = parse_jwt_claims(&tokens.access_token); + let access_auth_claims = access_claims + .get("https://api.openai.com/auth") + .cloned() + .unwrap_or(serde_json::Value::Object(Default::default())); + let org_id = access_auth_claims.get("organization_id").and_then(|v| v.as_str()); + let project_id = access_auth_claims.get("project_id").and_then(|v| v.as_str()); + let completed_onboarding = access_auth_claims + .get("completed_platform_onboarding") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let is_org_owner = access_auth_claims + .get("is_org_owner") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let plan_type = access_auth_claims + .get("chatgpt_plan_type") + .and_then(|v| v.as_str()); + let needs_setup = !completed_onboarding && is_org_owner; + + // 2) Token exchange for API key + let today = Utc::now().format("%Y-%m-%d").to_string(); + let random_id = { + let mut bytes = [0u8; 6]; + rand::thread_rng().fill_bytes(&mut bytes); + hex::encode(bytes) + }; + let token_x_resp = client + .post(&token_endpoint) + .form(&[ + ("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"), + ("client_id", opts.client_id.as_str()), + ("requested_token", "openai-api-key"), + ("subject_token", tokens.id_token.as_str()), + ("subject_token_type", "urn:ietf:params:oauth:token-type:id_token"), + ( + "name", + format!("Codex CLI [auto-generated] ({today}) [{random_id}]").as_str(), + ), + ]) + .send(); + let Ok(token_x_resp) = token_x_resp else { + let _ = request.respond(Response::from_string("Token exchange failed").with_status_code(500)); + continue; + }; + let Ok(token_x) = token_x_resp.json::() else { + let _ = request.respond(Response::from_string("Token exchange failed").with_status_code(500)); + continue; + }; + + // Persist auth.json + if let Err(e) = write_auth_file( + &opts.codex_home, + Some(token_x.access_token.clone()), + &tokens.id_token, + &tokens.access_token, + &tokens.refresh_token, + account_id, + ) { + let _ = request.respond(Response::from_string(format!("Unable to persist auth file: {e}")).with_status_code(500)); + continue; + } + + // Best-effort credits redemption + if opts.redeem_credits { + maybe_redeem_credits( + &issuer, + &opts.client_id, + Some(&tokens.id_token), + &tokens.refresh_token, + &opts.codex_home, + ); + } + + // Build success URL and redirect + let platform_url = if issuer == DEFAULT_ISSUER { + "https://platform.openai.com" + } else { + "https://platform.api.openai.org" + }; + let mut success_url = Url::parse(&format!("{}/success", url_base)).unwrap(); + if let Some(id_tok) = Some(&tokens.id_token) { + success_url.query_pairs_mut().append_pair("id_token", id_tok); + } + if let Some(org) = org_id { success_url.query_pairs_mut().append_pair("org_id", org); } + if let Some(proj) = project_id { success_url.query_pairs_mut().append_pair("project_id", proj); } + if let Some(pt) = plan_type { success_url.query_pairs_mut().append_pair("plan_type", pt); } + success_url + .query_pairs_mut() + .append_pair("needs_setup", if needs_setup { "true" } else { "false" }) + .append_pair("platform_url", platform_url); + + let mut resp = Response::empty(302); + resp.add_header(Header::from_bytes(&b"Location"[..], success_url.as_str()).unwrap()); + let _ = request.respond(resp); + } + _ => { + let _ = request.respond(Response::from_string("Endpoint not supported").with_status_code(404)); + } + } + } + + Ok(()) +} + +// -------- Headless testing helpers (no HTTP server) -------- + +#[derive(Debug, Clone)] +pub struct HeadlessOutcome { + pub success_url: String, + pub api_key: Option, +} + +pub trait Http { + fn post_form(&self, url: &str, form: &[(String, String)]) -> std::io::Result; + fn post_json(&self, url: &str, body: &serde_json::Value) -> std::io::Result; +} + +pub struct DefaultHttp(Client); +impl Default for DefaultHttp { + fn default() -> Self { + let c = Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string())) + .unwrap(); + Self(c) + } +} +impl Http for DefaultHttp { + fn post_form(&self, url: &str, form: &[(String, String)]) -> std::io::Result { + let resp = self + .0 + .post(url) + .form(&form.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect::>()) + .send() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; + let val = resp + .json::() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; + Ok(val) + } + fn post_json(&self, url: &str, body: &serde_json::Value) -> std::io::Result { + let resp = self + .0 + .post(url) + .json(body) + .send() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; + let val = resp + .json::() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; + Ok(val) + } +} + +pub fn process_callback_headless( + opts: &LoginServerOptions, + expected_state: &str, + incoming_state: &str, + code_opt: Option<&str>, + code_verifier: &str, + http: &dyn Http, +) -> std::io::Result { + if incoming_state != expected_state { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "state mismatch", + )); + } + let code = code_opt.ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "missing authorization code") + })?; + + let token_endpoint = format!("{}/oauth/token", opts.issuer); + let redirect_uri = format!("{}/auth/callback", default_url_base(opts.port)); + + // 1) Code -> tokens + let form = vec![ + ("grant_type".to_string(), "authorization_code".to_string()), + ("code".to_string(), code.to_string()), + ("redirect_uri".to_string(), redirect_uri.clone()), + ("client_id".to_string(), opts.client_id.clone()), + ("code_verifier".to_string(), code_verifier.to_string()), + ]; + let tokens_val = http.post_form(&token_endpoint, &form)?; + let id_token = tokens_val["id_token"].as_str().unwrap_or("").to_string(); + let access_token = tokens_val["access_token"].as_str().unwrap_or("").to_string(); + let refresh_token = tokens_val["refresh_token"].as_str().unwrap_or("").to_string(); + if id_token.is_empty() || access_token.is_empty() || refresh_token.is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + "token exchange failed", + )); + } + + // Extract claims + let id_claims = parse_jwt_claims(&id_token); + let account_id = id_claims + .get("https://api.openai.com/auth") + .and_then(|v| v.get("chatgpt_account_id")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let access_claims = parse_jwt_claims(&access_token); + let access_auth = access_claims + .get("https://api.openai.com/auth") + .cloned() + .unwrap_or(serde_json::Value::Object(Default::default())); + let org_id = access_auth.get("organization_id").and_then(|v| v.as_str()); + let project_id = access_auth.get("project_id").and_then(|v| v.as_str()); + let completed = access_auth + .get("completed_platform_onboarding") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let is_owner = access_auth + .get("is_org_owner") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let plan_type = access_auth + .get("chatgpt_plan_type") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let needs_setup = !completed && is_owner; + + // 2) Token exchange -> API key + let today = Utc::now().format("%Y-%m-%d").to_string(); + let random_id = { + let mut bytes = [0u8; 6]; + rand::thread_rng().fill_bytes(&mut bytes); + hex::encode(bytes) + }; + let exchange_form = vec![ + ( + "grant_type".to_string(), + "urn:ietf:params:oauth:grant-type:token-exchange".to_string(), + ), + ("client_id".to_string(), opts.client_id.clone()), + ("requested_token".to_string(), "openai-api-key".to_string()), + ("subject_token".to_string(), id_token.clone()), + ( + "subject_token_type".to_string(), + "urn:ietf:params:oauth:token-type:id_token".to_string(), + ), + ( + "name".to_string(), + format!("Codex CLI [auto-generated] ({today}) [{random_id}]").to_string(), + ), + ]; + let exchange_val = http.post_form(&token_endpoint, &exchange_form)?; + let api_key = exchange_val["access_token"].as_str().map(|s| s.to_string()); + + // Persist auth.json + write_auth_file( + &opts.codex_home, + api_key.clone(), + &id_token, + &access_token, + &refresh_token, + account_id, + )?; + + // Attempt credit redemption (best-effort) + if opts.redeem_credits { + let platform_url = if opts.issuer == DEFAULT_ISSUER { + "https://api.openai.com" + } else { + "https://api.openai.org" + }; + let redeem_url = format!("{platform_url}/v1/billing/redeem_credits"); + let _ = http.post_json(&redeem_url, &json!({"id_token": id_token})); + } + + // Build success URL + let base = default_url_base(opts.port); + let mut success_url = Url::parse(&format!("{base}/success")).unwrap(); + success_url.query_pairs_mut().append_pair("id_token", &id_token); + if let Some(org) = org_id { + success_url.query_pairs_mut().append_pair("org_id", org); + } + if let Some(proj) = project_id { + success_url + .query_pairs_mut() + .append_pair("project_id", proj); + } + if !plan_type.is_empty() { + success_url + .query_pairs_mut() + .append_pair("plan_type", plan_type); + } + success_url + .query_pairs_mut() + .append_pair("needs_setup", if needs_setup { "true" } else { "false" }) + .append_pair( + "platform_url", + if opts.issuer == DEFAULT_ISSUER { + "https://platform.openai.com" + } else { + "https://platform.api.openai.org" + }, + ); + + Ok(HeadlessOutcome { + success_url: success_url.into_string(), + api_key, + }) +} + + diff --git a/codex-rs/login/src/success_page.html b/codex-rs/login/src/success_page.html new file mode 100644 index 0000000000..5135d9be8c --- /dev/null +++ b/codex-rs/login/src/success_page.html @@ -0,0 +1,199 @@ + + + + + Sign into Codex CLI + + + + +
+
+
+ +
Signed in to Codex CLI
+
+ + +
+
+ + + + + diff --git a/codex-rs/login/tests/headless.rs b/codex-rs/login/tests/headless.rs new file mode 100644 index 0000000000..56fca3b815 --- /dev/null +++ b/codex-rs/login/tests/headless.rs @@ -0,0 +1,154 @@ +use codex_login::{process_callback_headless, HeadlessOutcome, LoginServerOptions}; +use serde_json::json; +use std::cell::RefCell; +use std::collections::VecDeque; +use tempfile::TempDir; + +#[derive(Default)] +struct MockHttp { + forms: RefCell)>>, + jsons: RefCell>, + replies: RefCell>, +} + +impl MockHttp { + fn queue(&self, val: serde_json::Value) { + self.replies.borrow_mut().push_back(val); + } +} + +impl codex_login::Http for MockHttp { + fn post_form( + &self, + url: &str, + form: &[(String, String)], + ) -> std::io::Result { + self.forms + .borrow_mut() + .push((url.to_string(), form.to_vec())); + self.replies + .borrow_mut() + .pop_front() + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::Other, "no reply")) + } + + fn post_json( + &self, + url: &str, + body: &serde_json::Value, + ) -> std::io::Result { + self.jsons + .borrow_mut() + .push((url.to_string(), body.clone())); + self.replies + .borrow_mut() + .pop_front() + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::Other, "no reply")) + } +} + +fn make_fake_jwt(payload: serde_json::Value) -> String { + use base64::Engine; + let header = serde_json::json!({"alg": "none", "typ": "JWT"}); + let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b); + let header_b64 = b64(&serde_json::to_vec(&header).unwrap()); + let payload_b64 = b64(&serde_json::to_vec(&payload).unwrap()); + let signature_b64 = b64(b"sig"); + format!("{header_b64}.{payload_b64}.{signature_b64}") +} + +fn default_opts(tmp: &TempDir) -> LoginServerOptions { + LoginServerOptions { + codex_home: tmp.path().to_path_buf(), + client_id: "test-client".into(), + issuer: "http://auth.local".into(), + port: 1455, + open_browser: false, + redeem_credits: true, + expose_state_endpoint: false, + } +} + +// 1) Success flow writes file and returns success URL +#[test] +fn headless_success_writes_auth_and_url() { + let tmp = TempDir::new().unwrap(); + let opts = default_opts(&tmp); + let http = MockHttp::default(); + // Code exchange response + http.queue(json!({ + "id_token": make_fake_jwt(json!({"https://api.openai.com/auth": {"chatgpt_account_id": "acc"}})), + "access_token": make_fake_jwt(json!({"https://api.openai.com/auth": {"organization_id": "org","project_id": "proj","completed_platform_onboarding": true, "is_org_owner": false, "chatgpt_plan_type": "plus"}})), + "refresh_token": "r1" + })); + // Token-exchange to API key + http.queue(json!({"access_token": "sk-xyz"})); + // Credits redeem + http.queue(json!({"granted_chatgpt_subscriber_api_credits": 5})); + + let outcome = process_callback_headless(&opts, "state", "state", Some("code"), "ver", &http).unwrap(); + assert!(outcome.success_url.contains("/success")); + let contents = std::fs::read_to_string(tmp.path().join("auth.json")).unwrap(); + let v: serde_json::Value = serde_json::from_str(&contents).unwrap(); + assert_eq!(v["OPENAI_API_KEY"].as_str(), Some("sk-xyz")); +} + +// 2) State mismatch errors +#[test] +fn headless_state_mismatch() { + let tmp = TempDir::new().unwrap(); + let opts = default_opts(&tmp); + let http = MockHttp::default(); + let err = process_callback_headless(&opts, "state", "wrong", Some("code"), "ver", &http) + .err() + .unwrap(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); +} + +// 3) Missing code errors +#[test] +fn headless_missing_code() { + let tmp = TempDir::new().unwrap(); + let opts = default_opts(&tmp); + let http = MockHttp::default(); + let err = process_callback_headless(&opts, "state", "state", None, "ver", &http) + .err() + .unwrap(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); +} + +// 4) Token endpoint failure propagates error +#[test] +fn headless_token_endpoint_failure() { + let tmp = TempDir::new().unwrap(); + let opts = default_opts(&tmp); + let http = MockHttp::default(); + // no replies queued -> will error + let err = process_callback_headless(&opts, "state", "state", Some("code"), "ver", &http) + .err() + .unwrap(); + assert_eq!(err.kind(), std::io::ErrorKind::Other); +} + +// 5) Credit redemption best-effort: even if it errors, success persists +#[test] +fn headless_credit_redemption_best_effort() { + let tmp = TempDir::new().unwrap(); + let mut opts = default_opts(&tmp); + opts.redeem_credits = true; + let http = MockHttp::default(); + // Code exchange + http.queue(json!({ + "id_token": make_fake_jwt(json!({"https://api.openai.com/auth": {"chatgpt_account_id": "acc"}})), + "access_token": make_fake_jwt(json!({"https://api.openai.com/auth": {"organization_id": "org","project_id": "proj","completed_platform_onboarding": false, "is_org_owner": true, "chatgpt_plan_type": "pro"}})), + "refresh_token": "r1" + })); + // Token exchange -> API key + http.queue(json!({"access_token": "sk-xyz"})); + // Credits redeem: simulate error by not queuing a third response; the mock will error internally + let outcome = process_callback_headless(&opts, "state", "state", Some("code"), "ver", &http).unwrap(); + assert!(outcome.success_url.contains("needs_setup=true")); + assert!(tmp.path().join("auth.json").exists()); +} + + diff --git a/codex-rs/login/tests/server.rs b/codex-rs/login/tests/server.rs new file mode 100644 index 0000000000..61cfb75666 --- /dev/null +++ b/codex-rs/login/tests/server.rs @@ -0,0 +1,330 @@ +#![cfg(feature = "http-e2e-tests")] +use codex_login::{run_local_login_server_with_options, LoginServerOptions}; +use base64::Engine; +use std::io::Read; +use std::net::TcpListener; +// use std::path::PathBuf; +use std::thread; +use std::time::Duration; +use tempfile::TempDir; + +fn find_free_port() -> u16 { + TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port() +} + +fn start_mock_oauth_server(port: u16, behavior: MockBehavior) { + thread::spawn(move || { + let server = tiny_http::Server::http(format!("127.0.0.1:{port}")).unwrap(); + for mut request in server.incoming_requests() { + let url = request.url().to_string(); + if request.method() == &tiny_http::Method::Post && url.starts_with("/oauth/token") { + // Read body + let mut body = String::new(); + request.as_reader().read_to_string(&mut body).ok(); + let content_type = request + .headers() + .iter() + .find(|h| h.field.equiv("Content-Type")) + .map(|h| h.value.as_str().to_string()) + .unwrap_or_default(); + + // Parse either x-www-form-urlencoded or JSON + let mut form = std::collections::HashMap::::new(); + if content_type.starts_with("application/x-www-form-urlencoded") { + for kv in body.split('&') { + if let Some((k, v)) = kv.split_once('=') { + let k = urlencoding::decode(k).unwrap().into_owned(); + let v = urlencoding::decode(v).unwrap().into_owned(); + form.insert(k, v); + } + } + } else if content_type.starts_with("application/json") { + let v: serde_json::Value = serde_json::from_str(&body).unwrap_or_default(); + if let Some(obj) = v.as_object() { + for (k, vv) in obj.iter() { + form.insert(k.clone(), vv.as_str().unwrap_or_default().to_string()); + } + } + } + + match behavior { + MockBehavior::Success => { + if form.get("grant_type").map(|s| s.as_str()) == Some("authorization_code") { + // Return tokens + let id_token = make_fake_jwt(serde_json::json!({ + "https://api.openai.com/auth": { + "chatgpt_account_id": "acc-1", + } + })); + let access_token = make_fake_jwt(serde_json::json!({ + "https://api.openai.com/auth": { + "organization_id": "org-1", + "project_id": "proj-1", + "completed_platform_onboarding": true, + "is_org_owner": false, + "chatgpt_plan_type": "plus" + } + })); + let payload = serde_json::json!({ + "id_token": id_token, + "access_token": access_token, + "refresh_token": "refresh-1" + }); + let _ = request.respond( + tiny_http::Response::from_string(payload.to_string()) + .with_status_code(200) + .with_header( + tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap(), + ), + ); + } else { + // token-exchange → API key + let payload = serde_json::json!({ + "access_token": "sk-test-123" + }); + let _ = request.respond( + tiny_http::Response::from_string(payload.to_string()) + .with_status_code(200) + .with_header( + tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap(), + ), + ); + } + } + MockBehavior::TokenError => { + let _ = request.respond(tiny_http::Response::from_string("error").with_status_code(500)); + } + } + } else if request.method() == &tiny_http::Method::Post + && url.starts_with("/v1/billing/redeem_credits") + { + let payload = serde_json::json!({"granted_chatgpt_subscriber_api_credits": 5}); + let _ = request.respond( + tiny_http::Response::from_string(payload.to_string()) + .with_status_code(200) + .with_header( + tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap(), + ), + ); + } else { + let _ = request.respond(tiny_http::Response::from_string("not found").with_status_code(404)); + } + } + }); +} + +#[derive(Clone, Copy)] +enum MockBehavior { + Success, + TokenError, +} + +fn make_fake_jwt(payload: serde_json::Value) -> String { + let header = serde_json::json!({"alg": "none", "typ": "JWT"}); + let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b); + let header_b64 = b64(&serde_json::to_vec(&header).unwrap()); + let payload_b64 = b64(&serde_json::to_vec(&payload).unwrap()); + let signature_b64 = b64(b"sig"); + format!("{header_b64}.{payload_b64}.{signature_b64}") +} + +fn http_get(url: &str) -> (u16, String, Option) { + let resp = ureq::get(url).call().expect("http get failed"); + let status = resp.status(); + let body = resp.into_string().unwrap_or_default(); + let location = None; // ureq consumed headers in previous call; not needed here + (status as u16, body, location) +} + +fn http_get_follow_redirect(url: &str) -> (u16, String) { + let agent = ureq::AgentBuilder::new().redirects(5).build(); + match agent.get(url).call() { + Ok(resp) => (resp.status(), resp.into_string().unwrap_or_default()), + Err(ureq::Error::Status(code, resp)) => (code, resp.into_string().unwrap_or_default()), + Err(err) => panic!("http error: {err}"), + } +} + +// 1) Happy path: writes auth.json and exits after /success +#[test] +fn login_server_happy_path() { + let oauth_port = find_free_port(); + start_mock_oauth_server(oauth_port, MockBehavior::Success); + + let codex_home = TempDir::new().unwrap(); + let port = find_free_port(); + let issuer = format!("http://127.0.0.1:{oauth_port}"); + + let opts = LoginServerOptions { + codex_home: codex_home.path().to_path_buf(), + client_id: "test-client".to_string(), + issuer: issuer.clone(), + port, + open_browser: false, + redeem_credits: true, + expose_state_endpoint: true, + testing_timeout_secs: Some(5), + }; + + let handle = thread::spawn(move || run_local_login_server_with_options(opts).unwrap()); + + // Wait for server to bind + wait_for_state_endpoint(port, Duration::from_secs(5)); + + // Get state via test-only endpoint + let state_url = format!("http://127.0.0.1:{port}/__test/state"); + let (_s, state, _) = http_get(&state_url); + assert!(!state.is_empty()); + + // Simulate callback + let cb_url = format!("http://127.0.0.1:{port}/auth/callback?code=abc&state={state}"); + let (status, body) = http_get_follow_redirect(&cb_url); + assert_eq!(status, 200); + assert!(body.contains("Signed in to Codex CLI")); + + handle.join().unwrap(); + + // Verify auth.json written + let auth_path = codex_home.path().join("auth.json"); + let contents = std::fs::read_to_string(&auth_path).unwrap(); + let v: serde_json::Value = serde_json::from_str(&contents).unwrap(); + assert_eq!(v["OPENAI_API_KEY"].as_str(), Some("sk-test-123")); + assert!(v["tokens"]["id_token"].as_str().is_some()); +} + +// 2) State mismatch returns 400 and server stays up +#[test] +fn login_server_state_mismatch() { + let oauth_port = find_free_port(); + start_mock_oauth_server(oauth_port, MockBehavior::Success); + let port = find_free_port(); + let codex_home = TempDir::new().unwrap(); + let issuer = format!("http://127.0.0.1:{oauth_port}"); + + let opts = LoginServerOptions { + codex_home: codex_home.path().into(), + client_id: "test-client".into(), + issuer, + port, + open_browser: false, + redeem_credits: false, + expose_state_endpoint: true, + testing_timeout_secs: Some(5), + }; + let handle = thread::spawn(move || run_local_login_server_with_options(opts).unwrap()); + wait_for_state_endpoint(port, Duration::from_secs(5)); + + let cb_url = format!("http://127.0.0.1:{port}/auth/callback?code=abc&state=wrong"); + let (status, body) = http_get_follow_redirect(&cb_url); + assert_eq!(status, 400); + assert!(body.contains("State parameter mismatch") || body.is_empty()); + + // Stop server + let _ = ureq::get(&format!("http://127.0.0.1:{port}/success")).call(); + handle.join().unwrap(); +} + +// 3) Missing code returns 400 +#[test] +fn login_server_missing_code() { + let oauth_port = find_free_port(); + start_mock_oauth_server(oauth_port, MockBehavior::Success); + let port = find_free_port(); + let codex_home = TempDir::new().unwrap(); + let issuer = format!("http://127.0.0.1:{oauth_port}"); + let opts = LoginServerOptions { + codex_home: codex_home.path().into(), + client_id: "test-client".into(), + issuer, + port, + open_browser: false, + redeem_credits: false, + expose_state_endpoint: true, + testing_timeout_secs: Some(5), + }; + let handle = thread::spawn(move || run_local_login_server_with_options(opts).unwrap()); + wait_for_state_endpoint(port, Duration::from_secs(5)); + + // Fetch state + let state = ureq::get(&format!("http://127.0.0.1:{port}/__test/state")).call().expect("get state").into_string().unwrap(); + // Missing code + let cb_url = format!("http://127.0.0.1:{port}/auth/callback?state={state}"); + let (status, _body) = http_get_follow_redirect(&cb_url); + assert_eq!(status, 400); + let _ = ureq::get(&format!("http://127.0.0.1:{port}/success")).call(); + handle.join().unwrap(); +} + +// 4) Token endpoint error returns 500 (on code exchange) and server stays up +#[test] +fn login_server_token_exchange_error() { + let oauth_port = find_free_port(); + start_mock_oauth_server(oauth_port, MockBehavior::TokenError); + let port = find_free_port(); + let codex_home = TempDir::new().unwrap(); + let issuer = format!("http://127.0.0.1:{oauth_port}"); + let opts = LoginServerOptions { + codex_home: codex_home.path().into(), + client_id: "test-client".into(), + issuer, + port, + open_browser: false, + redeem_credits: false, + expose_state_endpoint: true, + testing_timeout_secs: Some(5), + }; + let handle = thread::spawn(move || run_local_login_server_with_options(opts).unwrap()); + wait_for_state_endpoint(port, Duration::from_secs(5)); + let state = ureq::get(&format!("http://127.0.0.1:{port}/__test/state")).call().expect("get state").into_string().unwrap(); + let cb_url = format!("http://127.0.0.1:{port}/auth/callback?code=abc&state={state}"); + let (status, _body) = http_get_follow_redirect(&cb_url); + assert_eq!(status, 500); + let _ = ureq::get(&format!("http://127.0.0.1:{port}/success")).call(); + handle.join().unwrap(); +} + +// 5) Credit redemption errors do not block success +#[test] +fn login_server_credit_redemption_best_effort() { + // Mock behavior success for token endpoints, but have redeem endpoint return 500 by not matching path (using different port) + let oauth_port = find_free_port(); + start_mock_oauth_server(oauth_port, MockBehavior::Success); + let port = find_free_port(); + let codex_home = TempDir::new().unwrap(); + let issuer = format!("http://127.0.0.1:{oauth_port}"); + let opts = LoginServerOptions { + codex_home: codex_home.path().into(), + client_id: "test-client".into(), + issuer, + port, + open_browser: false, + redeem_credits: true, + expose_state_endpoint: true, + testing_timeout_secs: Some(5), + }; + let handle = thread::spawn(move || run_local_login_server_with_options(opts).unwrap()); + wait_for_state_endpoint(port, Duration::from_secs(5)); + let state = ureq::get(&format!("http://127.0.0.1:{port}/__test/state")).call().expect("get state").into_string().unwrap(); + let cb_url = format!("http://127.0.0.1:{port}/auth/callback?code=abc&state={state}"); + let (status, _body) = http_get_follow_redirect(&cb_url); + assert_eq!(status, 200); + handle.join().unwrap(); + // auth.json exists + assert!(codex_home.path().join("auth.json").exists()); +} + + +fn wait_for_state_endpoint(port: u16, timeout: Duration) { + let start = std::time::Instant::now(); + loop { + if start.elapsed() > timeout { + panic!("server did not expose __test/state within timeout"); + } + if let Ok(resp) = ureq::get(&format!("http://127.0.0.1:{port}/__test/state")).call() { + if resp.status() == 200 { + break; + } + } + std::thread::sleep(Duration::from_millis(50)); + } +}