This commit is contained in:
Eason Goodale
2025-08-08 13:43:30 -07:00
parent 431c9299d4
commit 339a8b69f4
7 changed files with 1690 additions and 39 deletions

252
codex-rs/Cargo.lock generated
View File

@@ -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"

View File

@@ -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"] }

View File

@@ -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<Mutex<Vec<u8>>>,
}
/// 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<SpawnedLogin> {
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<SpawnedLog
/// If `capture_output` is true, the subprocess's output will be captured and
/// recorded in memory. Otherwise, the subprocess's output will be sent to the
/// current process's stdout/stderr.
pub async fn login_with_chatgpt(codex_home: &Path, capture_output: bool) -> 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<()> {

View File

@@ -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::<serde_json::Value>(&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<String>,
id_token: &str,
access_token: &str,
refresh_token: &str,
account_id: Option<String>,
) -> 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::<serde_json::Value>() 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::<serde_json::Value>(&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::<serde_json::Value>() {
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<u64>,
}
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<String, String> = 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::<CodeExchangeResponse>() 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::<TokenExchangeResponse>() 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<String>,
}
pub trait Http {
fn post_form(&self, url: &str, form: &[(String, String)]) -> std::io::Result<serde_json::Value>;
fn post_json(&self, url: &str, body: &serde_json::Value) -> std::io::Result<serde_json::Value>;
}
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<serde_json::Value> {
let resp = self
.0
.post(url)
.form(&form.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect::<Vec<_>>())
.send()
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
let val = resp
.json::<serde_json::Value>()
.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<serde_json::Value> {
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::<serde_json::Value>()
.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<HeadlessOutcome> {
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,
})
}

View File

@@ -0,0 +1,199 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Sign into Codex CLI</title>
<link rel="icon" href='data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none" viewBox="0 0 32 32"%3E%3Cpath stroke="%23000" stroke-linecap="round" stroke-width="2.484" d="M22.356 19.797H17.17M9.662 12.29l1.979 3.576a.511.511 0 0 1-.005.504l-1.974 3.409M30.758 16c0 8.15-6.607 14.758-14.758 14.758-8.15 0-14.758-6.607-14.758-14.758C1.242 7.85 7.85 1.242 16 1.242c8.15 0 14.758 6.608 14.758 14.758Z"/%3E%3C/svg%3E' type="image/svg+xml">
<style>
.container {
margin: auto;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
background: white;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
.inner-container {
width: 400px;
flex-direction: column;
justify-content: flex-start;
align-items: center;
gap: 20px;
display: inline-flex;
}
.content {
align-self: stretch;
flex-direction: column;
justify-content: flex-start;
align-items: center;
gap: 20px;
display: flex;
margin-top: 15vh;
}
.svg-wrapper {
position: relative;
}
.title {
text-align: center;
color: var(--text-primary, #0D0D0D);
font-size: 32px;
font-weight: 400;
line-height: 40px;
word-wrap: break-word;
}
.setup-box {
width: 600px;
padding: 16px 20px;
background: var(--bg-primary, white);
box-shadow: 0px 4px 16px rgba(0, 0, 0, 0.05);
border-radius: 16px;
outline: 1px var(--border-default, rgba(13, 13, 13, 0.10)) solid;
outline-offset: -1px;
justify-content: flex-start;
align-items: center;
gap: 16px;
display: inline-flex;
}
.setup-content {
flex: 1 1 0;
justify-content: flex-start;
align-items: center;
gap: 24px;
display: flex;
}
.setup-text {
flex: 1 1 0;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
gap: 4px;
display: inline-flex;
}
.setup-title {
align-self: stretch;
color: var(--text-primary, #0D0D0D);
font-size: 14px;
font-weight: 510;
line-height: 20px;
word-wrap: break-word;
}
.setup-description {
align-self: stretch;
color: var(--text-secondary, #5D5D5D);
font-size: 14px;
font-weight: 400;
line-height: 20px;
word-wrap: break-word;
}
.redirect-box {
justify-content: flex-start;
align-items: center;
gap: 8px;
display: flex;
}
.close-button,
.redirect-button {
height: 28px;
padding: 8px 16px;
background: var(--interactive-bg-primary-default, #0D0D0D);
border-radius: 999px;
justify-content: center;
align-items: center;
gap: 4px;
display: flex;
}
.close-button,
.redirect-text {
color: var(--interactive-label-primary-default, white);
font-size: 14px;
font-weight: 510;
line-height: 20px;
word-wrap: break-word;
text-decoration: none;
}
.logo {
display: flex;
align-items: center;
justify-content: center;
width: 4rem;
height: 4rem;
border-radius: 16px;
border: .5px solid rgba(0, 0, 0, 0.1);
box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 16px 0px;
box-sizing: border-box;
background-color: rgb(255, 255, 255);
}
</style>
</head>
<body>
<div class="container">
<div class="inner-container">
<div class="content">
<div class="logo">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none" viewBox="0 0 32 32"><path stroke="#000" stroke-linecap="round" stroke-width="2.484" d="M22.356 19.797H17.17M9.662 12.29l1.979 3.576a.511.511 0 0 1-.005.504l-1.974 3.409M30.758 16c0 8.15-6.607 14.758-14.758 14.758-8.15 0-14.758-6.607-14.758-14.758C1.242 7.85 7.85 1.242 16 1.242c8.15 0 14.758 6.608 14.758 14.758Z"></path></svg>
</div>
<div class="title">Signed in to Codex CLI</div>
</div>
<div class="close-box" style="display: none;">
<div class="setup-description">You may now close this page</div>
</div>
<div class="setup-box" style="display: none;">
<div class="setup-content">
<div class="setup-text">
<div class="setup-title">Finish setting up your API organization</div>
<div class="setup-description">Add a payment method to use your organization.</div>
</div>
<div class="redirect-box">
<div data-hasendicon="false" data-hasstarticon="false" data-ishovered="false" data-isinactive="false" data-ispressed="false" data-size="large" data-type="primary" class="redirect-button">
<div class="redirect-text">Redirecting in 3s...</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
(function () {
const params = new URLSearchParams(window.location.search);
const needsSetup = params.get('needs_setup') === 'true';
const platformUrl = params.get('platform_url') || 'https://platform.openai.com';
const orgId = params.get('org_id');
const projectId = params.get('project_id');
const planType = params.get('plan_type');
const idToken = params.get('id_token');
// Show different message and optional redirect when setup is required
if (needsSetup) {
const setupBox = document.querySelector('.setup-box');
setupBox.style.display = 'flex';
const redirectUrlObj = new URL('/org-setup', platformUrl);
redirectUrlObj.searchParams.set('p', planType);
redirectUrlObj.searchParams.set('t', idToken);
redirectUrlObj.searchParams.set('with_org', orgId);
redirectUrlObj.searchParams.set('project_id', projectId);
const redirectUrl = redirectUrlObj.toString();
const message = document.querySelector('.redirect-text');
let countdown = 3;
function tick() {
message.textContent =
'Redirecting in ' + countdown + 's…';
if (countdown === 0) {
window.location.replace(redirectUrl);
} else {
countdown -= 1;
setTimeout(tick, 1000);
}
}
tick();
} else {
const closeBox = document.querySelector('.close-box');
closeBox.style.display = 'flex';
}
})();
</script>
</body>
</html>

View File

@@ -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<Vec<(String, Vec<(String, String)>)>>,
jsons: RefCell<Vec<(String, serde_json::Value)>>,
replies: RefCell<VecDeque<serde_json::Value>>,
}
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<serde_json::Value> {
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<serde_json::Value> {
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());
}

View File

@@ -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::<String, String>::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<String>) {
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));
}
}