mirror of
https://github.com/openai/codex.git
synced 2026-04-28 08:34:54 +00:00
176 KiB
176 KiB
PR #2047: Port login server to rust
- URL: https://github.com/openai/codex/pull/2047
- Author: easong-openai
- Created: 2025-08-08 22:34:56 UTC
- Updated: 2025-08-15 00:52:48 UTC
- Changes: +2865/-1784, Files changed: 21, Commits: 24
Description
Works with my manual testing, but could use some review. Auto-ported by codex.
Full Diff
diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
index 41392633be..7c185b2cc0 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.43"
@@ -797,13 +815,24 @@ version = "0.0.0"
dependencies = [
"base64 0.22.1",
"chrono",
+ "hex",
+ "html-escape",
"pretty_assertions",
+ "rand 0.8.5",
"reqwest",
"serde",
"serde_json",
+ "sha2",
"tempfile",
"thiserror 2.0.12",
+ "tiny_http",
"tokio",
+ "tracing",
+ "ureq",
+ "url",
+ "urlencoding",
+ "webbrowser",
+ "wiremock",
]
[[package]]
@@ -951,6 +980,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"
@@ -1005,6 +1044,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"
@@ -1938,6 +1987,15 @@ dependencies = [
"windows-sys 0.59.0",
]
+[[package]]
+name = "html-escape"
+version = "0.2.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476"
+dependencies = [
+ "utf8-width",
+]
+
[[package]]
name = "http"
version = "1.3.1"
@@ -2455,6 +2513,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"
@@ -2791,6 +2871,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"
@@ -2944,6 +3030,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"
@@ -3670,6 +3781,7 @@ dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
+ "futures-channel",
"futures-core",
"futures-util",
"h2",
@@ -3801,7 +3913,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",
@@ -3992,7 +4106,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",
@@ -4151,6 +4265,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"
@@ -4515,7 +4640,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",
]
@@ -4710,6 +4835,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"
@@ -5150,6 +5287,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"
@@ -5162,6 +5316,18 @@ dependencies = [
"serde",
]
+[[package]]
+name = "urlencoding"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
+
+[[package]]
+name = "utf8-width"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3"
+
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -5385,6 +5551,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"
@@ -5573,6 +5773,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"
@@ -5600,6 +5809,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"
@@ -5632,6 +5856,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"
@@ -5644,6 +5874,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"
@@ -5656,6 +5892,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"
@@ -5680,6 +5922,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"
@@ -5692,6 +5940,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"
@@ -5704,6 +5958,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"
@@ -5716,6 +5976,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/cli/src/login.rs b/codex-rs/cli/src/login.rs
index 1a70bd27b6..f8610f4981 100644
--- a/codex-rs/cli/src/login.rs
+++ b/codex-rs/cli/src/login.rs
@@ -5,6 +5,7 @@ use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_login::AuthMode;
use codex_login::CodexAuth;
+use codex_login::EXIT_CODE_WHEN_ADDRESS_ALREADY_IN_USE;
use codex_login::OPENAI_API_KEY_ENV_VAR;
use codex_login::login_with_api_key;
use codex_login::login_with_chatgpt;
@@ -13,15 +14,19 @@ use codex_login::logout;
pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> ! {
let config = load_config_or_exit(cli_config_overrides);
- let capture_output = false;
- match login_with_chatgpt(&config.codex_home, capture_output).await {
+ match login_with_chatgpt(&config.codex_home).await {
Ok(_) => {
eprintln!("Successfully logged in");
std::process::exit(0);
}
Err(e) => {
- eprintln!("Error logging in: {e}");
- std::process::exit(1);
+ if e.kind() == std::io::ErrorKind::AddrInUse {
+ eprintln!("Error logging in: address already in use");
+ std::process::exit(EXIT_CODE_WHEN_ADDRESS_ALREADY_IN_USE);
+ } else {
+ eprintln!("Error logging in: {e}");
+ std::process::exit(1);
+ }
}
}
}
diff --git a/codex-rs/login/Cargo.toml b/codex-rs/login/Cargo.toml
index 85c11505ec..58c035465e 100644
--- a/codex-rs/login/Cargo.toml
+++ b/codex-rs/login/Cargo.toml
@@ -3,17 +3,22 @@ edition = "2024"
name = "codex-login"
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"] }
+hex = "0.4"
+html-escape = "0.2"
+rand = "0.8"
+reqwest = { version = "0.12", features = ["json", "blocking"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+sha2 = "0.10"
tempfile = "3"
thiserror = "2.0.12"
+tiny_http = "0.12"
tokio = { version = "1", features = [
"io-std",
"macros",
@@ -21,7 +26,16 @@ tokio = { version = "1", features = [
"rt-multi-thread",
"signal",
] }
+tracing = "0.1"
+url = "2"
+urlencoding = "2"
+webbrowser = "1"
[dev-dependencies]
pretty_assertions = "1.4.1"
tempfile = "3"
+ureq = { version = "2", default-features = false, features = ["tls", "json"] }
+wiremock = "0.6"
+
+[lints]
+workspace = true
diff --git a/codex-rs/login/src/auth.rs b/codex-rs/login/src/auth.rs
new file mode 100644
index 0000000000..4cd4f16894
--- /dev/null
+++ b/codex-rs/login/src/auth.rs
@@ -0,0 +1,209 @@
+use chrono::Utc;
+use std::path::Path;
+use std::path::PathBuf;
+use std::sync::Arc;
+use std::sync::Mutex;
+use std::time::Duration;
+
+use crate::auth_store::AuthDotJson;
+use crate::auth_store::get_auth_file;
+use crate::auth_store::try_read_auth_json;
+use crate::auth_store::update_tokens;
+use crate::refresh::try_refresh_token;
+use crate::token_data::TokenData;
+
+#[derive(Clone, Debug, PartialEq, Copy)]
+pub enum AuthMode {
+ ApiKey,
+ ChatGPT,
+}
+
+#[derive(Debug, Clone)]
+pub struct CodexAuth {
+ pub mode: AuthMode,
+
+ pub(crate) api_key: Option<String>,
+ pub(crate) auth_dot_json: Arc<Mutex<Option<AuthDotJson>>>,
+ pub(crate) auth_file: PathBuf,
+}
+
+impl PartialEq for CodexAuth {
+ fn eq(&self, other: &Self) -> bool {
+ self.mode == other.mode
+ }
+}
+
+impl CodexAuth {
+ pub fn from_api_key(api_key: &str) -> Self {
+ Self {
+ api_key: Some(api_key.to_owned()),
+ mode: AuthMode::ApiKey,
+ auth_file: PathBuf::new(),
+ auth_dot_json: Arc::new(Mutex::new(None)),
+ }
+ }
+
+ /// Loads from auth.json or OPENAI_API_KEY
+ pub fn from_codex_home(codex_home: &Path) -> std::io::Result<Option<CodexAuth>> {
+ load_auth(codex_home, true)
+ }
+
+ pub async fn get_token_data(&self) -> Result<TokenData, std::io::Error> {
+ let auth_dot_json: Option<AuthDotJson> = self.get_current_auth_json();
+ match auth_dot_json {
+ Some(AuthDotJson {
+ tokens: Some(mut tokens),
+ last_refresh: Some(last_refresh),
+ ..
+ }) => {
+ if last_refresh < Utc::now() - chrono::Duration::days(28) {
+ let refresh_response = tokio::time::timeout(
+ Duration::from_secs(60),
+ try_refresh_token(tokens.refresh_token.clone()),
+ )
+ .await
+ .map_err(|_| {
+ std::io::Error::other("timed out while refreshing OpenAI API key")
+ })?
+ .map_err(std::io::Error::other)?;
+
+ let updated_auth_dot_json = update_tokens(
+ &self.auth_file,
+ refresh_response.id_token,
+ refresh_response.access_token,
+ refresh_response.refresh_token,
+ )?;
+
+ tokens = updated_auth_dot_json
+ .tokens
+ .clone()
+ .ok_or(std::io::Error::other(
+ "Token data is not available after refresh.",
+ ))?;
+
+ #[expect(clippy::unwrap_used)]
+ let mut auth_lock = self.auth_dot_json.lock().unwrap();
+ *auth_lock = Some(updated_auth_dot_json);
+ }
+
+ Ok(tokens)
+ }
+ _ => Err(std::io::Error::other("Token data is not available.")),
+ }
+ }
+
+ pub async fn get_token(&self) -> Result<String, std::io::Error> {
+ match self.mode {
+ AuthMode::ApiKey => Ok(self.api_key.clone().unwrap_or_default()),
+ AuthMode::ChatGPT => {
+ let id_token = self.get_token_data().await?.access_token;
+
+ Ok(id_token)
+ }
+ }
+ }
+
+ pub fn get_account_id(&self) -> Option<String> {
+ self.get_current_token_data()
+ .and_then(|t| t.account_id.clone())
+ }
+
+ pub fn get_plan_type(&self) -> Option<String> {
+ self.get_current_token_data()
+ .and_then(|t| t.id_token.chatgpt_plan_type.as_ref().map(|p| p.as_string()))
+ }
+
+ fn get_current_auth_json(&self) -> Option<AuthDotJson> {
+ #[expect(clippy::unwrap_used)]
+ self.auth_dot_json.lock().unwrap().clone()
+ }
+
+ fn get_current_token_data(&self) -> Option<TokenData> {
+ self.get_current_auth_json().and_then(|t| t.tokens.clone())
+ }
+
+ /// Consider this private to integration tests.
+ pub fn create_dummy_chatgpt_auth_for_testing() -> Self {
+ let auth_dot_json = AuthDotJson {
+ openai_api_key: None,
+ tokens: Some(TokenData {
+ id_token: Default::default(),
+ id_token_raw: String::new(),
+ access_token: "Access Token".to_string(),
+ refresh_token: "test".to_string(),
+ account_id: Some("account_id".to_string()),
+ }),
+ last_refresh: Some(Utc::now()),
+ };
+
+ let auth_dot_json = Arc::new(Mutex::new(Some(auth_dot_json)));
+ Self {
+ api_key: None,
+ mode: AuthMode::ChatGPT,
+ auth_file: PathBuf::new(),
+ auth_dot_json,
+ }
+ }
+}
+
+pub(crate) fn load_auth(
+ codex_home: &Path,
+ include_env_var: bool,
+) -> std::io::Result<Option<CodexAuth>> {
+ // First, check to see if there is a valid auth.json file. If not, we fall
+ // back to AuthMode::ApiKey using the OPENAI_API_KEY environment variable
+ let auth_file = get_auth_file(codex_home);
+ let auth_dot_json = match try_read_auth_json(&auth_file) {
+ Ok(auth) => auth,
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound && include_env_var => {
+ return match read_openai_api_key_from_env() {
+ Some(api_key) => Ok(Some(CodexAuth::from_api_key(&api_key))),
+ None => Ok(None),
+ };
+ }
+ // Though if auth.json exists but is malformed, do not fall back to the
+ // env var because the user may be expecting to use AuthMode::ChatGPT.
+ Err(e) => {
+ return Err(e);
+ }
+ };
+
+ let AuthDotJson {
+ openai_api_key: auth_json_api_key,
+ tokens,
+ last_refresh,
+ } = auth_dot_json;
+
+ // If the auth.json has an API key AND does not appear to be on a plan that
+ // should prefer AuthMode::ChatGPT, use AuthMode::ApiKey.
+ if let Some(api_key) = &auth_json_api_key {
+ match &tokens {
+ Some(tokens) => {
+ if tokens.is_plan_that_should_use_api_key() {
+ return Ok(Some(CodexAuth::from_api_key(api_key)));
+ }
+ }
+ None => {
+ // Let's assume they are trying to use their API key.
+ return Ok(Some(CodexAuth::from_api_key(api_key)));
+ }
+ }
+ }
+
+ Ok(Some(CodexAuth {
+ api_key: None,
+ mode: AuthMode::ChatGPT,
+ auth_file,
+ auth_dot_json: Arc::new(Mutex::new(Some(AuthDotJson {
+ openai_api_key: None,
+ tokens,
+ last_refresh,
+ }))),
+ }))
+}
+
+fn read_openai_api_key_from_env() -> Option<String> {
+ std::env::var(crate::OPENAI_API_KEY_ENV_VAR)
+ .ok()
+ .filter(|s| !s.is_empty())
+}
diff --git a/codex-rs/login/src/auth_store.rs b/codex-rs/login/src/auth_store.rs
new file mode 100644
index 0000000000..fdab2bf90f
--- /dev/null
+++ b/codex-rs/login/src/auth_store.rs
@@ -0,0 +1,149 @@
+use chrono::DateTime;
+use chrono::Utc;
+use serde::Deserialize;
+use serde::Serialize;
+use std::fs::File;
+use std::fs::OpenOptions;
+#[cfg(unix)]
+use std::os::unix::fs::OpenOptionsExt;
+use std::path::Path;
+use std::path::PathBuf;
+
+use crate::token_data::TokenData;
+
+#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
+pub struct AuthDotJson {
+ #[serde(rename = "OPENAI_API_KEY")]
+ pub openai_api_key: Option<String>,
+
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub tokens: Option<TokenData>,
+
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub last_refresh: Option<DateTime<Utc>>,
+}
+
+pub fn get_auth_file(codex_home: &Path) -> PathBuf {
+ codex_home.join("auth.json")
+}
+
+/// Delete the auth.json file inside `codex_home` if it exists. Returns `Ok(true)`
+/// if a file was removed, `Ok(false)` if no auth file was present.
+pub fn logout(codex_home: &Path) -> std::io::Result<bool> {
+ let auth_file = get_auth_file(codex_home);
+ match std::fs::remove_file(&auth_file) {
+ Ok(_) => Ok(true),
+ Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
+ Err(err) => Err(err),
+ }
+}
+
+/// Attempt to read and deserialize the `auth.json` file at the given path.
+/// Returns the full AuthDotJson structure.
+pub fn try_read_auth_json(auth_file: &Path) -> std::io::Result<AuthDotJson> {
+ let mut file = File::open(auth_file)?;
+ let mut contents = String::new();
+ use std::io::Read as _;
+ file.read_to_string(&mut contents)?;
+ let auth_dot_json: AuthDotJson = serde_json::from_str(&contents)?;
+ Ok(auth_dot_json)
+}
+
+pub(crate) fn write_auth_json(
+ auth_file: &Path,
+ auth_dot_json: &AuthDotJson,
+) -> std::io::Result<()> {
+ let json_data = serde_json::to_string_pretty(auth_dot_json)?;
+ let mut options = OpenOptions::new();
+ options.truncate(true).write(true).create(true);
+ #[cfg(unix)]
+ {
+ options.mode(0o600);
+ }
+ let mut file = options.open(auth_file)?;
+ use std::io::Write as _;
+ file.write_all(json_data.as_bytes())?;
+ file.flush()?;
+ Ok(())
+}
+
+pub fn login_with_api_key(codex_home: &Path, api_key: &str) -> std::io::Result<()> {
+ let auth_dot_json = AuthDotJson {
+ openai_api_key: Some(api_key.to_string()),
+ tokens: None,
+ last_refresh: None,
+ };
+ write_auth_json(&get_auth_file(codex_home), &auth_dot_json)
+}
+
+pub(crate) fn update_tokens(
+ auth_file: &Path,
+ id_token: String,
+ access_token: Option<String>,
+ refresh_token: Option<String>,
+) -> std::io::Result<AuthDotJson> {
+ let mut prior_access: Option<String> = None;
+ let mut prior_refresh: Option<String> = None;
+ let mut auth = match try_read_auth_json(auth_file) {
+ Ok(a) => a,
+ Err(_) => {
+ // Try to salvage existing access/refresh from raw JSON on disk
+ if let Ok(mut f) = File::open(auth_file) {
+ let mut contents = String::new();
+ use std::io::Read as _;
+ if f.read_to_string(&mut contents).is_ok() {
+ if let Ok(val) = serde_json::from_str::<serde_json::Value>(&contents) {
+ prior_access = val
+ .get("tokens")
+ .and_then(|t| t.get("access_token"))
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string());
+ prior_refresh = val
+ .get("tokens")
+ .and_then(|t| t.get("refresh_token"))
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string());
+ }
+ }
+ }
+ AuthDotJson {
+ openai_api_key: None,
+ tokens: None,
+ last_refresh: None,
+ }
+ }
+ };
+ let now = Utc::now();
+ auth.last_refresh = Some(now);
+
+ let new_tokens = match auth.tokens.take() {
+ Some(mut tokens) => {
+ tokens.id_token_raw = id_token;
+ if let Some(a) = access_token.clone() {
+ tokens.access_token = a;
+ }
+ if let Some(r) = refresh_token.clone() {
+ tokens.refresh_token = r;
+ }
+ // Re-parse id_token_raw into parsed fields
+ tokens.id_token = crate::token_data::parse_id_token(&tokens.id_token_raw)
+ .map_err(std::io::Error::other)?;
+ tokens
+ }
+ None => {
+ // Construct fresh TokenData from provided values
+ let a = access_token
+ .or_else(|| prior_access.clone())
+ .ok_or_else(|| std::io::Error::other("missing access_token"))?;
+ let r = refresh_token
+ .or_else(|| prior_refresh.clone())
+ .ok_or_else(|| std::io::Error::other("missing refresh_token"))?;
+ crate::token_data::TokenData::from_raw(id_token, a, r, None)
+ .map_err(std::io::Error::other)?
+ }
+ };
+
+ auth.tokens = Some(new_tokens);
+ write_auth_json(auth_file, &auth)?;
+ try_read_auth_json(auth_file)
+}
diff --git a/codex-rs/login/src/entrypoints.rs b/codex-rs/login/src/entrypoints.rs
new file mode 100644
index 0000000000..7e1f1624df
--- /dev/null
+++ b/codex-rs/login/src/entrypoints.rs
@@ -0,0 +1,124 @@
+use std::path::Path;
+use std::process::Child;
+use std::process::Stdio;
+use std::sync::Arc;
+use std::sync::Mutex;
+
+use crate::server;
+
+/// Represents a running login subprocess. The child can be killed by holding
+/// the mutex and calling `kill()`.
+#[derive(Debug, Clone)]
+pub struct SpawnedLogin {
+ pub child: Arc<Mutex<Child>>,
+ pub stdout: Arc<Mutex<Vec<u8>>>,
+ pub stderr: Arc<Mutex<Vec<u8>>>,
+}
+
+impl SpawnedLogin {
+ /// Attempts to extract the login URL printed by the spawned login server.
+ ///
+ /// The login server prints the authorization URL to stderr on its own line
+ /// in case the browser does not open automatically. We scan the captured
+ /// stderr buffer from the end and return the last line that looks like an
+ /// http(s) URL.
+ pub fn get_login_url(&self) -> Option<String> {
+ let buf = self.stderr.lock().ok()?;
+ let text = String::from_utf8_lossy(&buf);
+ for line in text.lines().rev() {
+ let s = line.trim();
+ if s.starts_with("http://") || s.starts_with("https://") {
+ return Some(s.to_string());
+ }
+ }
+ None
+ }
+}
+
+/// 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 current_exe = std::env::current_exe()?;
+ let mut cmd = std::process::Command::new(current_exe);
+ cmd.arg("login")
+ .env("CODEX_HOME", codex_home)
+ .stdin(Stdio::null())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped());
+
+ let mut child = cmd.spawn()?;
+
+ let stdout_buf = Arc::new(Mutex::new(Vec::new()));
+ let stderr_buf = Arc::new(Mutex::new(Vec::new()));
+
+ if let Some(mut out) = child.stdout.take() {
+ let buf = stdout_buf.clone();
+ std::thread::spawn(move || {
+ let mut tmp = Vec::new();
+ let _ = std::io::copy(&mut out, &mut tmp);
+ if let Ok(mut b) = buf.lock() {
+ b.extend_from_slice(&tmp);
+ }
+ });
+ }
+ if let Some(mut err) = child.stderr.take() {
+ let buf = stderr_buf.clone();
+ std::thread::spawn(move || {
+ let mut tmp = Vec::new();
+ let _ = std::io::copy(&mut err, &mut tmp);
+ if let Ok(mut b) = buf.lock() {
+ b.extend_from_slice(&tmp);
+ }
+ });
+ }
+
+ Ok(SpawnedLogin {
+ child: Arc::new(Mutex::new(child)),
+ stdout: stdout_buf,
+ stderr: stderr_buf,
+ })
+}
+
+/// Entrypoint used by the CLI to run the local login server.
+pub async fn login_with_chatgpt(codex_home: &Path) -> std::io::Result<()> {
+ let client_id = std::env::var("CODEX_CLIENT_ID")
+ .ok()
+ .filter(|s| !s.is_empty())
+ .unwrap_or_else(|| crate::CLIENT_ID.to_string());
+
+ let codex_home = codex_home.to_path_buf();
+ let client_id_cloned = client_id.clone();
+ tokio::task::spawn_blocking(move || {
+ let opts = server::LoginServerOptions::for_cli(&codex_home, &client_id_cloned);
+ server::run_local_login_server_with_options(opts)
+ })
+ .await
+ .map_err(|e| std::io::Error::other(format!("task join error: {e}")))??;
+ Ok(())
+}
+
+pub fn spawn_login_in_process(
+ codex_home: &Path,
+) -> (
+ std::thread::JoinHandle<std::io::Result<()>>,
+ std::sync::mpsc::Receiver<crate::server::LoginServerStatus>,
+ std::sync::mpsc::Sender<()>,
+) {
+ let (status_tx, status_rx) = std::sync::mpsc::channel();
+ let (cancel_tx, cancel_rx) = std::sync::mpsc::channel();
+
+ let client_id = std::env::var("CODEX_CLIENT_ID")
+ .ok()
+ .filter(|s| !s.is_empty())
+ .unwrap_or_else(|| crate::CLIENT_ID.to_string());
+
+ let opts = server::LoginServerOptions::for_ui(
+ codex_home,
+ &client_id,
+ status_tx,
+ cancel_rx,
+ server::DEFAULT_PORT,
+ );
+
+ let handle = std::thread::spawn(move || server::run_local_login_server_with_options(opts));
+ (handle, status_rx, cancel_tx)
+}
diff --git a/codex-rs/login/src/error_page.html b/codex-rs/login/src/error_page.html
new file mode 100644
index 0000000000..3d0b78267c
--- /dev/null
+++ b/codex-rs/login/src/error_page.html
@@ -0,0 +1,100 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8" />
+ <title>Sign-in error · 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: 520px;
+ 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;
+ }
+ .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);
+ }
+ .title {
+ text-align: center;
+ color: var(--text-primary, #0D0D0D);
+ font-size: 28px;
+ font-weight: 500;
+ line-height: 36px;
+ word-wrap: break-word;
+ }
+ .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;
+ }
+ .message {
+ align-self: stretch;
+ color: var(--text-secondary, #5D5D5D);
+ font-size: 14px;
+ line-height: 20px;
+ }
+ .help {
+ align-self: stretch;
+ color: var(--text-secondary, #5D5D5D);
+ font-size: 13px;
+ line-height: 20px;
+ }
+ </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">We couldn't complete sign-in</div>
+ </div>
+ <div class="box">
+ <div class="message" id="message">%%MESSAGE%%</div>
+ </div>
+ <div class="help">Try restarting the sign-in from the Codex CLI. You can also copy the URL from the terminal and open it manually.</div>
+ </div>
+ </div>
+ </body>
+ </html>
+
+
diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs
index a1dad79e9d..0298720856 100644
--- a/codex-rs/login/src/lib.rs
+++ b/codex-rs/login/src/lib.rs
@@ -1,756 +1,35 @@
-use chrono::DateTime;
-
-use chrono::Utc;
-use serde::Deserialize;
-use serde::Serialize;
-use std::env;
-use std::fs::File;
-use std::fs::OpenOptions;
-use std::fs::remove_file;
-use std::io::Read;
-use std::io::Write;
-use std::io::{self};
-#[cfg(unix)]
-use std::os::unix::fs::OpenOptionsExt;
-use std::path::Path;
-use std::path::PathBuf;
-use std::process::Child;
-use std::process::Stdio;
-use std::sync::Arc;
-use std::sync::Mutex;
-use std::time::Duration;
-use tempfile::NamedTempFile;
-use tokio::process::Command;
-
pub use crate::token_data::TokenData;
-use crate::token_data::parse_id_token;
+mod auth;
+mod auth_store;
+mod entrypoints;
+mod pkce;
+mod refresh;
+mod server;
+mod success_url;
mod token_data;
-const SOURCE_FOR_PYTHON_SERVER: &str = include_str!("./login_with_chatgpt.py");
-
-const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
+pub use auth::AuthMode;
+pub use auth::CodexAuth;
+pub use auth_store::AuthDotJson;
+pub use auth_store::get_auth_file;
+pub use auth_store::login_with_api_key;
+pub use auth_store::logout;
+pub use auth_store::try_read_auth_json;
+pub use entrypoints::SpawnedLogin;
+pub use entrypoints::login_with_chatgpt;
+pub use entrypoints::spawn_login_in_process;
+pub use entrypoints::spawn_login_with_chatgpt;
+pub use server::HeadlessOutcome;
+pub use server::Http;
+pub use server::LoginServerOptions;
+pub use server::LoginServerStatus;
+pub use server::process_callback_headless;
+pub use server::run_local_login_server_with_options;
+
+pub(crate) const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY";
-
-#[derive(Clone, Debug, PartialEq, Copy)]
-pub enum AuthMode {
- ApiKey,
- ChatGPT,
-}
-
-#[derive(Debug, Clone)]
-pub struct CodexAuth {
- pub mode: AuthMode,
-
- api_key: Option<String>,
- auth_dot_json: Arc<Mutex<Option<AuthDotJson>>>,
- auth_file: PathBuf,
-}
-
-impl PartialEq for CodexAuth {
- fn eq(&self, other: &Self) -> bool {
- self.mode == other.mode
- }
-}
-
-impl CodexAuth {
- pub fn from_api_key(api_key: &str) -> Self {
- Self {
- api_key: Some(api_key.to_owned()),
- mode: AuthMode::ApiKey,
- auth_file: PathBuf::new(),
- auth_dot_json: Arc::new(Mutex::new(None)),
- }
- }
-
- /// Loads the available auth information from the auth.json or
- /// OPENAI_API_KEY environment variable.
- pub fn from_codex_home(codex_home: &Path) -> std::io::Result<Option<CodexAuth>> {
- load_auth(codex_home, true)
- }
-
- pub async fn get_token_data(&self) -> Result<TokenData, std::io::Error> {
- let auth_dot_json: Option<AuthDotJson> = self.get_current_auth_json();
- match auth_dot_json {
- Some(AuthDotJson {
- tokens: Some(mut tokens),
- last_refresh: Some(last_refresh),
- ..
- }) => {
- if last_refresh < Utc::now() - chrono::Duration::days(28) {
- let refresh_response = tokio::time::timeout(
- Duration::from_secs(60),
- try_refresh_token(tokens.refresh_token.clone()),
- )
- .await
- .map_err(|_| {
- std::io::Error::other("timed out while refreshing OpenAI API key")
- })?
- .map_err(std::io::Error::other)?;
-
- let updated_auth_dot_json = update_tokens(
- &self.auth_file,
- refresh_response.id_token,
- refresh_response.access_token,
- refresh_response.refresh_token,
- )
- .await?;
-
- tokens = updated_auth_dot_json
- .tokens
- .clone()
- .ok_or(std::io::Error::other(
- "Token data is not available after refresh.",
- ))?;
-
- #[expect(clippy::unwrap_used)]
- let mut auth_lock = self.auth_dot_json.lock().unwrap();
- *auth_lock = Some(updated_auth_dot_json);
- }
-
- Ok(tokens)
- }
- _ => Err(std::io::Error::other("Token data is not available.")),
- }
- }
-
- pub async fn get_token(&self) -> Result<String, std::io::Error> {
- match self.mode {
- AuthMode::ApiKey => Ok(self.api_key.clone().unwrap_or_default()),
- AuthMode::ChatGPT => {
- let id_token = self.get_token_data().await?.access_token;
-
- Ok(id_token)
- }
- }
- }
-
- pub fn get_account_id(&self) -> Option<String> {
- self.get_current_token_data()
- .and_then(|t| t.account_id.clone())
- }
-
- pub fn get_plan_type(&self) -> Option<String> {
- self.get_current_token_data()
- .and_then(|t| t.id_token.chatgpt_plan_type.as_ref().map(|p| p.as_string()))
- }
-
- fn get_current_auth_json(&self) -> Option<AuthDotJson> {
- #[expect(clippy::unwrap_used)]
- self.auth_dot_json.lock().unwrap().clone()
- }
-
- fn get_current_token_data(&self) -> Option<TokenData> {
- self.get_current_auth_json().and_then(|t| t.tokens.clone())
- }
-
- /// Consider this private to integration tests.
- pub fn create_dummy_chatgpt_auth_for_testing() -> Self {
- let auth_dot_json = AuthDotJson {
- openai_api_key: None,
- tokens: Some(TokenData {
- id_token: Default::default(),
- access_token: "Access Token".to_string(),
- refresh_token: "test".to_string(),
- account_id: Some("account_id".to_string()),
- }),
- last_refresh: Some(Utc::now()),
- };
-
- let auth_dot_json = Arc::new(Mutex::new(Some(auth_dot_json)));
- Self {
- api_key: None,
- mode: AuthMode::ChatGPT,
- auth_file: PathBuf::new(),
- auth_dot_json,
- }
- }
-}
-
-fn load_auth(codex_home: &Path, include_env_var: bool) -> std::io::Result<Option<CodexAuth>> {
- // First, check to see if there is a valid auth.json file. If not, we fall
- // back to AuthMode::ApiKey using the OPENAI_API_KEY environment variable
- // (if it is set).
- let auth_file = get_auth_file(codex_home);
- let auth_dot_json = match try_read_auth_json(&auth_file) {
- Ok(auth) => auth,
- // If auth.json does not exist, try to read the OPENAI_API_KEY from the
- // environment variable.
- Err(e) if e.kind() == std::io::ErrorKind::NotFound && include_env_var => {
- return match read_openai_api_key_from_env() {
- Some(api_key) => Ok(Some(CodexAuth::from_api_key(&api_key))),
- None => Ok(None),
- };
- }
- // Though if auth.json exists but is malformed, do not fall back to the
- // env var because the user may be expecting to use AuthMode::ChatGPT.
- Err(e) => {
- return Err(e);
- }
- };
-
- let AuthDotJson {
- openai_api_key: auth_json_api_key,
- tokens,
- last_refresh,
- } = auth_dot_json;
-
- // If the auth.json has an API key AND does not appear to be on a plan that
- // should prefer AuthMode::ChatGPT, use AuthMode::ApiKey.
- if let Some(api_key) = &auth_json_api_key {
- // Should any of these be AuthMode::ChatGPT with the api_key set?
- // Does AuthMode::ChatGPT indicate that there is an auth.json that is
- // "refreshable" even if we are using the API key for auth?
- match &tokens {
- Some(tokens) => {
- if tokens.is_plan_that_should_use_api_key() {
- return Ok(Some(CodexAuth::from_api_key(api_key)));
- } else {
- // Ignore the API key and fall through to ChatGPT auth.
- }
- }
- None => {
- // We have an API key but no tokens in the auth.json file.
- // Perhaps the user ran `codex login --api-key <KEY>` or updated
- // auth.json by hand. Either way, let's assume they are trying
- // to use their API key.
- return Ok(Some(CodexAuth::from_api_key(api_key)));
- }
- }
- }
-
- // For the AuthMode::ChatGPT variant, perhaps neither api_key nor
- // openai_api_key should exist?
- Ok(Some(CodexAuth {
- api_key: None,
- mode: AuthMode::ChatGPT,
- auth_file,
- auth_dot_json: Arc::new(Mutex::new(Some(AuthDotJson {
- openai_api_key: None,
- tokens,
- last_refresh,
- }))),
- }))
-}
-
-fn read_openai_api_key_from_env() -> Option<String> {
- env::var(OPENAI_API_KEY_ENV_VAR)
- .ok()
- .filter(|s| !s.is_empty())
-}
-
-pub fn get_auth_file(codex_home: &Path) -> PathBuf {
- codex_home.join("auth.json")
-}
-
-/// Delete the auth.json file inside `codex_home` if it exists. Returns `Ok(true)`
-/// if a file was removed, `Ok(false)` if no auth file was present.
-pub fn logout(codex_home: &Path) -> std::io::Result<bool> {
- let auth_file = get_auth_file(codex_home);
- match remove_file(&auth_file) {
- Ok(_) => Ok(true),
- Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
- Err(err) => Err(err),
- }
-}
-
-/// Represents a running login subprocess. The child can be killed by holding
-/// the mutex and calling `kill()`.
-#[derive(Debug, Clone)]
-pub struct SpawnedLogin {
- pub child: Arc<Mutex<Child>>,
- pub stdout: Arc<Mutex<Vec<u8>>>,
- pub stderr: Arc<Mutex<Vec<u8>>>,
-}
-
-impl SpawnedLogin {
- /// Returns the login URL, if one has been emitted by the login subprocess.
- ///
- /// The Python helper prints the URL to stderr; we capture it and extract
- /// the last whitespace-separated token that starts with "http".
- pub fn get_login_url(&self) -> Option<String> {
- self.stderr
- .lock()
- .ok()
- .and_then(|buffer| String::from_utf8(buffer.clone()).ok())
- .and_then(|output| {
- output
- .split_whitespace()
- .filter(|part| part.starts_with("http"))
- .next_back()
- .map(|s| s.to_string())
- })
- }
-}
-
-// Helpers for streaming child output into shared buffers
-struct AppendWriter {
- buf: Arc<Mutex<Vec<u8>>>,
-}
-
-impl Write for AppendWriter {
- fn write(&mut self, data: &[u8]) -> io::Result<usize> {
- if let Ok(mut b) = self.buf.lock() {
- b.extend_from_slice(data);
- }
- Ok(data.len())
- }
-
- fn flush(&mut self) -> io::Result<()> {
- Ok(())
- }
-}
-
-fn spawn_pipe_reader<R: Read + Send + 'static>(mut reader: R, buf: Arc<Mutex<Vec<u8>>>) {
- std::thread::spawn(move || {
- let _ = io::copy(&mut reader, &mut AppendWriter { buf });
- });
-}
-
-/// Spawn the ChatGPT login Python server as a child process and return a handle to its process.
-pub fn spawn_login_with_chatgpt(codex_home: &Path) -> std::io::Result<SpawnedLogin> {
- let script_path = write_login_script_to_disk()?;
- let mut cmd = std::process::Command::new("python3");
- cmd.arg(&script_path)
- .env("CODEX_HOME", codex_home)
- .env("CODEX_CLIENT_ID", CLIENT_ID)
- .stdin(Stdio::null())
- .stdout(Stdio::piped())
- .stderr(Stdio::piped());
-
- let mut child = cmd.spawn()?;
-
- let stdout_buf = Arc::new(Mutex::new(Vec::new()));
- let stderr_buf = Arc::new(Mutex::new(Vec::new()));
-
- if let Some(out) = child.stdout.take() {
- spawn_pipe_reader(out, stdout_buf.clone());
- }
- if let Some(err) = child.stderr.take() {
- spawn_pipe_reader(err, stderr_buf.clone());
- }
-
- Ok(SpawnedLogin {
- child: Arc::new(Mutex::new(child)),
- stdout: stdout_buf,
- stderr: stderr_buf,
- })
-}
-
-/// Run `python3 -c {{SOURCE_FOR_PYTHON_SERVER}}` with the CODEX_HOME
-/// environment variable set to the provided `codex_home` path. If the
-/// subprocess exits 0, read the OPENAI_API_KEY property out of
-/// CODEX_HOME/auth.json and return Ok(OPENAI_API_KEY). Otherwise, return Err
-/// with any information from the subprocess.
-///
-/// 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 script_path = write_login_script_to_disk()?;
- let child = Command::new("python3")
- .arg(&script_path)
- .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}"
- )))
- }
-}
-
-fn write_login_script_to_disk() -> std::io::Result<PathBuf> {
- // Write the embedded Python script to a file to avoid very long
- // command-line arguments (Windows error 206).
- let mut tmp = NamedTempFile::new()?;
- tmp.write_all(SOURCE_FOR_PYTHON_SERVER.as_bytes())?;
- tmp.flush()?;
-
- let (_file, path) = tmp.keep()?;
- Ok(path)
-}
-
-pub fn login_with_api_key(codex_home: &Path, api_key: &str) -> std::io::Result<()> {
- let auth_dot_json = AuthDotJson {
- openai_api_key: Some(api_key.to_string()),
- tokens: None,
- last_refresh: None,
- };
- write_auth_json(&get_auth_file(codex_home), &auth_dot_json)
-}
-
-/// Attempt to read and refresh the `auth.json` file in the given `CODEX_HOME` directory.
-/// Returns the full AuthDotJson structure after refreshing if necessary.
-pub fn try_read_auth_json(auth_file: &Path) -> std::io::Result<AuthDotJson> {
- let mut file = File::open(auth_file)?;
- let mut contents = String::new();
- file.read_to_string(&mut contents)?;
- let auth_dot_json: AuthDotJson = serde_json::from_str(&contents)?;
-
- Ok(auth_dot_json)
-}
-
-fn write_auth_json(auth_file: &Path, auth_dot_json: &AuthDotJson) -> std::io::Result<()> {
- let json_data = serde_json::to_string_pretty(auth_dot_json)?;
- let mut options = OpenOptions::new();
- options.truncate(true).write(true).create(true);
- #[cfg(unix)]
- {
- options.mode(0o600);
- }
- let mut file = options.open(auth_file)?;
- file.write_all(json_data.as_bytes())?;
- file.flush()?;
- Ok(())
-}
-
-async fn update_tokens(
- auth_file: &Path,
- id_token: String,
- access_token: Option<String>,
- refresh_token: Option<String>,
-) -> std::io::Result<AuthDotJson> {
- let mut auth_dot_json = try_read_auth_json(auth_file)?;
-
- let tokens = auth_dot_json.tokens.get_or_insert_with(TokenData::default);
- tokens.id_token = parse_id_token(&id_token).map_err(std::io::Error::other)?;
- if let Some(access_token) = access_token {
- tokens.access_token = access_token.to_string();
- }
- if let Some(refresh_token) = refresh_token {
- tokens.refresh_token = refresh_token.to_string();
- }
- auth_dot_json.last_refresh = Some(Utc::now());
- write_auth_json(auth_file, &auth_dot_json)?;
- Ok(auth_dot_json)
-}
-
-async fn try_refresh_token(refresh_token: String) -> std::io::Result<RefreshResponse> {
- let refresh_request = RefreshRequest {
- client_id: CLIENT_ID,
- grant_type: "refresh_token",
- refresh_token,
- scope: "openid profile email",
- };
-
- let client = reqwest::Client::new();
- let response = client
- .post("https://auth.openai.com/oauth/token")
- .header("Content-Type", "application/json")
- .json(&refresh_request)
- .send()
- .await
- .map_err(std::io::Error::other)?;
-
- if response.status().is_success() {
- let refresh_response = response
- .json::<RefreshResponse>()
- .await
- .map_err(std::io::Error::other)?;
- Ok(refresh_response)
- } else {
- Err(std::io::Error::other(format!(
- "Failed to refresh token: {}",
- response.status()
- )))
- }
-}
-
-#[derive(Serialize)]
-struct RefreshRequest {
- client_id: &'static str,
- grant_type: &'static str,
- refresh_token: String,
- scope: &'static str,
-}
-
-#[derive(Deserialize, Clone)]
-struct RefreshResponse {
- id_token: String,
- access_token: Option<String>,
- refresh_token: Option<String>,
-}
-
-/// Expected structure for $CODEX_HOME/auth.json.
-#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
-pub struct AuthDotJson {
- #[serde(rename = "OPENAI_API_KEY")]
- pub openai_api_key: Option<String>,
-
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub tokens: Option<TokenData>,
-
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub last_refresh: Option<DateTime<Utc>>,
-}
+pub const EXIT_CODE_WHEN_ADDRESS_ALREADY_IN_USE: i32 = 13;
#[cfg(test)]
-mod tests {
- #![expect(clippy::expect_used, clippy::unwrap_used)]
- use super::*;
- use crate::token_data::IdTokenInfo;
- use crate::token_data::KnownPlan;
- use crate::token_data::PlanType;
- use base64::Engine;
- use pretty_assertions::assert_eq;
- use serde_json::json;
- use tempfile::tempdir;
-
- const LAST_REFRESH: &str = "2025-08-06T20:41:36.232376Z";
-
- #[test]
- fn writes_api_key_and_loads_auth() {
- let dir = tempdir().unwrap();
- login_with_api_key(dir.path(), "sk-test-key").unwrap();
- let auth = load_auth(dir.path(), false).unwrap().unwrap();
- assert_eq!(auth.mode, AuthMode::ApiKey);
- assert_eq!(auth.api_key.as_deref(), Some("sk-test-key"));
- }
-
- #[test]
- fn loads_from_env_var_if_env_var_exists() {
- let dir = tempdir().unwrap();
-
- let env_var = std::env::var(OPENAI_API_KEY_ENV_VAR);
-
- if let Ok(env_var) = env_var {
- let auth = load_auth(dir.path(), true).unwrap().unwrap();
- assert_eq!(auth.mode, AuthMode::ApiKey);
- assert_eq!(auth.api_key, Some(env_var));
- }
- }
-
- #[tokio::test]
- async fn pro_account_with_no_api_key_uses_chatgpt_auth() {
- let codex_home = tempdir().unwrap();
- write_auth_file(
- AuthFileParams {
- openai_api_key: None,
- chatgpt_plan_type: "pro".to_string(),
- },
- codex_home.path(),
- )
- .expect("failed to write auth file");
-
- let CodexAuth {
- api_key,
- mode,
- auth_dot_json,
- auth_file: _,
- } = load_auth(codex_home.path(), false).unwrap().unwrap();
- assert_eq!(None, api_key);
- assert_eq!(AuthMode::ChatGPT, mode);
-
- let guard = auth_dot_json.lock().unwrap();
- let auth_dot_json = guard.as_ref().expect("AuthDotJson should exist");
- assert_eq!(
- &AuthDotJson {
- openai_api_key: None,
- tokens: Some(TokenData {
- id_token: IdTokenInfo {
- email: Some("user@example.com".to_string()),
- chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)),
- },
- access_token: "test-access-token".to_string(),
- refresh_token: "test-refresh-token".to_string(),
- account_id: None,
- }),
- last_refresh: Some(
- DateTime::parse_from_rfc3339(LAST_REFRESH)
- .unwrap()
- .with_timezone(&Utc)
- ),
- },
- auth_dot_json
- )
- }
-
- /// Even if the OPENAI_API_KEY is set in auth.json, if the plan is not in
- /// [`TokenData::is_plan_that_should_use_api_key`], it should use
- /// [`AuthMode::ChatGPT`].
- #[tokio::test]
- async fn pro_account_with_api_key_still_uses_chatgpt_auth() {
- let codex_home = tempdir().unwrap();
- write_auth_file(
- AuthFileParams {
- openai_api_key: Some("sk-test-key".to_string()),
- chatgpt_plan_type: "pro".to_string(),
- },
- codex_home.path(),
- )
- .expect("failed to write auth file");
-
- let CodexAuth {
- api_key,
- mode,
- auth_dot_json,
- auth_file: _,
- } = load_auth(codex_home.path(), false).unwrap().unwrap();
- assert_eq!(None, api_key);
- assert_eq!(AuthMode::ChatGPT, mode);
-
- let guard = auth_dot_json.lock().unwrap();
- let auth_dot_json = guard.as_ref().expect("AuthDotJson should exist");
- assert_eq!(
- &AuthDotJson {
- openai_api_key: None,
- tokens: Some(TokenData {
- id_token: IdTokenInfo {
- email: Some("user@example.com".to_string()),
- chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)),
- },
- access_token: "test-access-token".to_string(),
- refresh_token: "test-refresh-token".to_string(),
- account_id: None,
- }),
- last_refresh: Some(
- DateTime::parse_from_rfc3339(LAST_REFRESH)
- .unwrap()
- .with_timezone(&Utc)
- ),
- },
- auth_dot_json
- )
- }
-
- /// If the OPENAI_API_KEY is set in auth.json and it is an enterprise
- /// account, then it should use [`AuthMode::ApiKey`].
- #[tokio::test]
- async fn enterprise_account_with_api_key_uses_chatgpt_auth() {
- let codex_home = tempdir().unwrap();
- write_auth_file(
- AuthFileParams {
- openai_api_key: Some("sk-test-key".to_string()),
- chatgpt_plan_type: "enterprise".to_string(),
- },
- codex_home.path(),
- )
- .expect("failed to write auth file");
-
- let CodexAuth {
- api_key,
- mode,
- auth_dot_json,
- auth_file: _,
- } = load_auth(codex_home.path(), false).unwrap().unwrap();
- assert_eq!(Some("sk-test-key".to_string()), api_key);
- assert_eq!(AuthMode::ApiKey, mode);
-
- let guard = auth_dot_json.lock().expect("should unwrap");
- assert!(guard.is_none(), "auth_dot_json should be None");
- }
-
- struct AuthFileParams {
- openai_api_key: Option<String>,
- chatgpt_plan_type: String,
- }
-
- fn write_auth_file(params: AuthFileParams, codex_home: &Path) -> std::io::Result<()> {
- let auth_file = get_auth_file(codex_home);
- // Create a minimal valid JWT for the id_token field.
- #[derive(Serialize)]
- struct Header {
- alg: &'static str,
- typ: &'static str,
- }
- let header = Header {
- alg: "none",
- typ: "JWT",
- };
- let payload = serde_json::json!({
- "email": "user@example.com",
- "email_verified": true,
- "https://api.openai.com/auth": {
- "chatgpt_account_id": "bc3618e3-489d-4d49-9362-1561dc53ba53",
- "chatgpt_plan_type": params.chatgpt_plan_type,
- "chatgpt_user_id": "user-12345",
- "user_id": "user-12345",
- }
- });
- let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b);
- let header_b64 = b64(&serde_json::to_vec(&header)?);
- let payload_b64 = b64(&serde_json::to_vec(&payload)?);
- let signature_b64 = b64(b"sig");
- let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}");
-
- let auth_json_data = json!({
- "OPENAI_API_KEY": params.openai_api_key,
- "tokens": {
- "id_token": fake_jwt,
- "access_token": "test-access-token",
- "refresh_token": "test-refresh-token"
- },
- "last_refresh": LAST_REFRESH,
- });
- let auth_json = serde_json::to_string_pretty(&auth_json_data)?;
- std::fs::write(auth_file, auth_json)
- }
-
- #[test]
- fn id_token_info_handles_missing_fields() {
- // Payload without email or plan should yield None values.
- let header = serde_json::json!({"alg": "none", "typ": "JWT"});
- let payload = serde_json::json!({"sub": "123"});
- let header_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD
- .encode(serde_json::to_vec(&header).unwrap());
- let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD
- .encode(serde_json::to_vec(&payload).unwrap());
- let signature_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"sig");
- let jwt = format!("{header_b64}.{payload_b64}.{signature_b64}");
-
- let info = parse_id_token(&jwt).expect("should parse");
- assert!(info.email.is_none());
- assert!(info.chatgpt_plan_type.is_none());
- }
-
- #[tokio::test]
- async fn loads_api_key_from_auth_json() {
- let dir = tempdir().unwrap();
- let auth_file = dir.path().join("auth.json");
- std::fs::write(
- auth_file,
- r#"
- {
- "OPENAI_API_KEY": "sk-test-key",
- "tokens": null,
- "last_refresh": null
- }
- "#,
- )
- .unwrap();
-
- let auth = load_auth(dir.path(), false).unwrap().unwrap();
- assert_eq!(auth.mode, AuthMode::ApiKey);
- assert_eq!(auth.api_key, Some("sk-test-key".to_string()));
-
- assert!(auth.get_token_data().await.is_err());
- }
-
- #[test]
- fn logout_removes_auth_file() -> Result<(), std::io::Error> {
- let dir = tempdir()?;
- login_with_api_key(dir.path(), "sk-test-key")?;
- assert!(dir.path().join("auth.json").exists());
- let removed = logout(dir.path())?;
- assert!(removed);
- assert!(!dir.path().join("auth.json").exists());
- Ok(())
- }
-}
+mod lib_tests;
diff --git a/codex-rs/login/src/lib_tests.rs b/codex-rs/login/src/lib_tests.rs
new file mode 100644
index 0000000000..a7b7a2e3ca
--- /dev/null
+++ b/codex-rs/login/src/lib_tests.rs
@@ -0,0 +1,337 @@
+#![expect(clippy::expect_used, clippy::unwrap_used)]
+use crate::auth::AuthMode;
+use crate::auth::CodexAuth;
+use crate::auth::load_auth;
+use crate::auth_store::get_auth_file;
+use crate::auth_store::logout;
+use crate::token_data::IdTokenInfo;
+use crate::token_data::KnownPlan;
+use crate::token_data::PlanType;
+use crate::token_data::parse_id_token;
+use base64::Engine;
+use pretty_assertions::assert_eq;
+use serde::Serialize;
+use serde_json::json;
+use std::path::Path;
+use tempfile::tempdir;
+
+const LAST_REFRESH: &str = "2025-08-06T20:41:36.232376Z";
+
+// moved to integration tests in tests/api_key_login.rs
+
+#[test]
+fn loads_from_env_var_if_env_var_exists() {
+ let dir = tempdir().unwrap();
+ let env_var = std::env::var(crate::OPENAI_API_KEY_ENV_VAR);
+ if let Ok(env_var) = env_var {
+ let auth = load_auth(dir.path(), true).unwrap().unwrap();
+ assert_eq!(auth.mode, AuthMode::ApiKey);
+ assert_eq!(auth.api_key, Some(env_var));
+ }
+}
+
+#[tokio::test]
+async fn pro_account_with_no_api_key_uses_chatgpt_auth() {
+ let codex_home = tempdir().unwrap();
+ write_auth_file(
+ AuthFileParams {
+ openai_api_key: None,
+ chatgpt_plan_type: "pro".to_string(),
+ },
+ codex_home.path(),
+ )
+ .expect("failed to write auth file");
+
+ let CodexAuth {
+ api_key,
+ mode,
+ auth_dot_json,
+ auth_file: _,
+ } = load_auth(codex_home.path(), false).unwrap().unwrap();
+ assert_eq!(None, api_key);
+ assert_eq!(AuthMode::ChatGPT, mode);
+
+ let guard = auth_dot_json.lock().unwrap();
+ let actual = guard.as_ref().expect("AuthDotJson should exist");
+ assert_eq!(actual.openai_api_key, None);
+ let tokens = actual.tokens.as_ref().expect("tokens should exist");
+ assert_eq!(
+ tokens.id_token,
+ IdTokenInfo {
+ email: Some("user@example.com".to_string()),
+ chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)),
+ }
+ );
+ assert_eq!(tokens.access_token, "test-access-token".to_string());
+ assert_eq!(tokens.refresh_token, "test-refresh-token".to_string());
+ assert_eq!(tokens.account_id, None);
+ assert_eq!(
+ actual.last_refresh,
+ Some(
+ chrono::DateTime::parse_from_rfc3339(LAST_REFRESH)
+ .unwrap()
+ .with_timezone(&chrono::Utc)
+ )
+ );
+}
+
+/// Even if the OPENAI_API_KEY is set in auth.json, if the plan is not in
+/// [`TokenData::is_plan_that_should_use_api_key`], it should use
+/// [`AuthMode::ChatGPT`].
+#[tokio::test]
+async fn pro_account_with_api_key_still_uses_chatgpt_auth() {
+ let codex_home = tempdir().unwrap();
+ write_auth_file(
+ AuthFileParams {
+ openai_api_key: Some("sk-test-key".to_string()),
+ chatgpt_plan_type: "pro".to_string(),
+ },
+ codex_home.path(),
+ )
+ .expect("failed to write auth file");
+
+ let CodexAuth {
+ api_key,
+ mode,
+ auth_dot_json,
+ auth_file: _,
+ } = load_auth(codex_home.path(), false).unwrap().unwrap();
+ assert_eq!(None, api_key);
+ assert_eq!(AuthMode::ChatGPT, mode);
+
+ let guard = auth_dot_json.lock().unwrap();
+ let actual = guard.as_ref().expect("AuthDotJson should exist");
+ assert_eq!(actual.openai_api_key, None);
+ let tokens = actual.tokens.as_ref().expect("tokens should exist");
+ assert_eq!(
+ tokens.id_token,
+ IdTokenInfo {
+ email: Some("user@example.com".to_string()),
+ chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)),
+ }
+ );
+ assert_eq!(tokens.access_token, "test-access-token".to_string());
+ assert_eq!(tokens.refresh_token, "test-refresh-token".to_string());
+ assert_eq!(tokens.account_id, None);
+ assert_eq!(
+ actual.last_refresh,
+ Some(
+ chrono::DateTime::parse_from_rfc3339(LAST_REFRESH)
+ .unwrap()
+ .with_timezone(&chrono::Utc)
+ )
+ );
+}
+
+/// If the OPENAI_API_KEY is set in auth.json and it is an enterprise
+/// account, then it should use [`AuthMode::ApiKey`].
+#[tokio::test]
+async fn enterprise_account_with_api_key_uses_chatgpt_auth() {
+ let codex_home = tempdir().unwrap();
+ write_auth_file(
+ AuthFileParams {
+ openai_api_key: Some("sk-test-key".to_string()),
+ chatgpt_plan_type: "enterprise".to_string(),
+ },
+ codex_home.path(),
+ )
+ .expect("failed to write auth file");
+
+ let CodexAuth {
+ api_key,
+ mode,
+ auth_dot_json,
+ auth_file: _,
+ } = load_auth(codex_home.path(), false).unwrap().unwrap();
+ assert_eq!(Some("sk-test-key".to_string()), api_key);
+ assert_eq!(AuthMode::ApiKey, mode);
+
+ let guard = auth_dot_json.lock().expect("should unwrap");
+ assert!(guard.is_none(), "auth_dot_json should be None");
+}
+
+struct AuthFileParams {
+ openai_api_key: Option<String>,
+ chatgpt_plan_type: String,
+}
+
+fn write_auth_file(params: AuthFileParams, codex_home: &Path) -> std::io::Result<()> {
+ let auth_file = get_auth_file(codex_home);
+ #[derive(Serialize)]
+ struct Header {
+ alg: &'static str,
+ typ: &'static str,
+ }
+ let header = Header {
+ alg: "none",
+ typ: "JWT",
+ };
+ let payload = serde_json::json!({
+ "email": "user@example.com",
+ "email_verified": true,
+ "https://api.openai.com/auth": {
+ "chatgpt_account_id": "bc3618e3-489d-4d49-9362-1561dc53ba53",
+ "chatgpt_plan_type": params.chatgpt_plan_type,
+ "chatgpt_user_id": "user-12345",
+ "user_id": "user-12345",
+ }
+ });
+ let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b);
+ let header_b64 = b64(&serde_json::to_vec(&header)?);
+ let payload_b64 = b64(&serde_json::to_vec(&payload)?);
+ let signature_b64 = b64(b"sig");
+ let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}");
+
+ let auth_json_data = json!({
+ "OPENAI_API_KEY": params.openai_api_key,
+ "tokens": {
+ "id_token": fake_jwt,
+ "access_token": "test-access-token",
+ "refresh_token": "test-refresh-token"
+ },
+ "last_refresh": LAST_REFRESH,
+ });
+ let auth_json = serde_json::to_string_pretty(&auth_json_data)?;
+ std::fs::write(auth_file, auth_json)
+}
+
+#[test]
+fn id_token_info_handles_missing_fields() {
+ // Payload without email or plan should yield None values.
+ let header = serde_json::json!({"alg": "none", "typ": "JWT"});
+ let payload = serde_json::json!({"sub": "123"});
+ let header_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD
+ .encode(serde_json::to_vec(&header).unwrap());
+ let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD
+ .encode(serde_json::to_vec(&payload).unwrap());
+ let signature_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"sig");
+ let jwt = format!("{header_b64}.{payload_b64}.{signature_b64}");
+
+ let info = parse_id_token(&jwt).expect("should parse");
+ assert!(info.email.is_none());
+ assert!(info.chatgpt_plan_type.is_none());
+}
+
+#[tokio::test]
+async fn loads_api_key_from_auth_json() {
+ let dir = tempdir().unwrap();
+ let auth_file = dir.path().join("auth.json");
+ std::fs::write(
+ auth_file,
+ r#"
+ {
+ "OPENAI_API_KEY": "sk-test-key",
+ "tokens": null,
+ "last_refresh": null
+ }
+ "#,
+ )
+ .unwrap();
+
+ let auth = load_auth(dir.path(), false).unwrap().unwrap();
+ assert_eq!(auth.mode, AuthMode::ApiKey);
+ assert_eq!(auth.api_key, Some("sk-test-key".to_string()));
+
+ assert!(auth.get_token_data().await.is_err());
+}
+
+#[test]
+fn logout_removes_auth_file() -> Result<(), std::io::Error> {
+ let dir = tempdir()?;
+ crate::auth_store::login_with_api_key(dir.path(), "sk-test-key")?;
+ assert!(dir.path().join("auth.json").exists());
+ let removed = logout(dir.path())?;
+ assert!(removed);
+ assert!(!dir.path().join("auth.json").exists());
+ Ok(())
+}
+
+#[test]
+fn update_tokens_preserves_id_token_as_string() {
+ let dir = tempdir().unwrap();
+ let auth_file = crate::auth_store::get_auth_file(dir.path());
+
+ // Write an initial auth.json with a tokens object
+ let initial = serde_json::json!({
+ "OPENAI_API_KEY": null,
+ "tokens": {
+ "id_token": "old-id-token",
+ "access_token": "a1",
+ "refresh_token": "r1"
+ },
+ "last_refresh": LAST_REFRESH
+ });
+ std::fs::write(&auth_file, serde_json::to_string_pretty(&initial).unwrap()).unwrap();
+
+ // Build a valid-looking JWT (URL-safe base64 header.payload.signature)
+ #[derive(Serialize)]
+ struct Header {
+ alg: &'static str,
+ typ: &'static str,
+ }
+ let header = Header {
+ alg: "none",
+ typ: "JWT",
+ };
+ let payload = serde_json::json!({});
+ 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");
+ let new_id = format!("{header_b64}.{payload_b64}.{signature_b64}");
+ // Call update_tokens with a new id_token
+ let _ = crate::auth_store::update_tokens(&auth_file, new_id.clone(), None, None).unwrap();
+
+ // Read raw file and ensure id_token is still a string, equal to what we wrote
+ let raw = std::fs::read_to_string(&auth_file).unwrap();
+ let val: serde_json::Value = serde_json::from_str(&raw).unwrap();
+ assert_eq!(val["tokens"]["id_token"].as_str(), Some(new_id.as_str()));
+}
+
+#[test]
+fn write_auth_json_is_python_compatible_shape() {
+ let dir = tempdir().unwrap();
+ let id_token = {
+ #[derive(Serialize)]
+ struct Header {
+ alg: &'static str,
+ typ: &'static str,
+ }
+ let header = Header {
+ alg: "none",
+ typ: "JWT",
+ };
+ let payload = serde_json::json!({"sub": "123"});
+ 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}")
+ };
+
+ let tokens = crate::token_data::TokenData::from_raw(
+ id_token.clone(),
+ "a1".to_string(),
+ "r1".to_string(),
+ Some("acc".to_string()),
+ )
+ .unwrap();
+ let auth = crate::auth_store::AuthDotJson {
+ openai_api_key: Some("sk-test".to_string()),
+ tokens: Some(tokens),
+ last_refresh: Some(chrono::Utc::now()),
+ };
+ crate::auth_store::write_auth_json(&crate::auth_store::get_auth_file(dir.path()), &auth)
+ .unwrap();
+
+ let raw = std::fs::read_to_string(dir.path().join("auth.json")).unwrap();
+ let val: serde_json::Value = serde_json::from_str(&raw).unwrap();
+
+ assert_eq!(val["OPENAI_API_KEY"].as_str(), Some("sk-test"));
+ assert!(val["last_refresh"].as_str().is_some());
+ assert!(val["tokens"].is_object());
+ assert_eq!(val["tokens"]["id_token"].as_str(), Some(id_token.as_str()));
+ assert_eq!(val["tokens"]["access_token"].as_str(), Some("a1"));
+ assert_eq!(val["tokens"]["refresh_token"].as_str(), Some("r1"));
+ assert_eq!(val["tokens"]["account_id"].as_str(), Some("acc"));
+}
diff --git a/codex-rs/login/src/login_with_chatgpt.py b/codex-rs/login/src/login_with_chatgpt.py
deleted file mode 100644
index 252c4e06ae..0000000000
--- a/codex-rs/login/src/login_with_chatgpt.py
+++ /dev/null
@@ -1,933 +0,0 @@
-"""Script that spawns a local webserver for retrieving an OpenAI API key.
-
-- Listens on 127.0.0.1:1455
-- Opens http://localhost:1455/auth/callback in the browser
-- If the user successfully navigates the auth flow,
- $CODEX_HOME/auth.json will be written with the API key.
-- User will be redirected to http://localhost:1455/success upon success.
-
-The script should exit with a non-zero code if the user fails to navigate the
-auth flow.
-
-To test this script locally without overwriting your existing auth.json file:
-
-```
-rm -rf /tmp/codex_home && mkdir /tmp/codex_home
-CODEX_HOME=/tmp/codex_home python3 codex-rs/login/src/login_with_chatgpt.py
-```
-"""
-
-from __future__ import annotations
-
-import argparse
-import base64
-import datetime
-import errno
-import hashlib
-import http.server
-import json
-import os
-import secrets
-import sys
-import threading
-import time
-import urllib.parse
-import urllib.request
-import webbrowser
-from dataclasses import dataclass
-from typing import Any, Dict # for type hints
-
-# Required port for OAuth client.
-REQUIRED_PORT = 1455
-URL_BASE = f"http://localhost:{REQUIRED_PORT}"
-DEFAULT_ISSUER = "https://auth.openai.com"
-
-EXIT_CODE_WHEN_ADDRESS_ALREADY_IN_USE = 13
-
-CA_CONTEXT = None
-CODEX_LOGIN_TRACE = os.environ.get("CODEX_LOGIN_TRACE", "false") in ["true", "1"]
-
-try:
-
- def trace(msg: str) -> None:
- if CODEX_LOGIN_TRACE:
- print(msg)
-
- def attempt_request(method: str) -> bool:
- try:
- with urllib.request.urlopen(
- urllib.request.Request(
- f"{DEFAULT_ISSUER}/.well-known/openid-configuration",
- method="GET",
- ),
- context=CA_CONTEXT,
- ) as resp:
- if resp.status != 200:
- trace(f"Request using {method} failed: {resp.status}")
- return False
-
- trace(f"Request using {method} succeeded")
- return True
- except Exception as e:
- trace(f"Request using {method} failed: {e}")
- return False
-
- status = attempt_request("default settings")
- if not status:
- try:
- import truststore
-
- truststore.inject_into_ssl()
- status = attempt_request("truststore")
- except Exception as e:
- trace(f"Failed to use truststore: {e}")
-
- if not status:
- try:
- import ssl
- import certifi as _certifi
-
- CA_CONTEXT = ssl.create_default_context(cafile=_certifi.where())
- status = attempt_request("certify")
- except Exception as e:
- trace(f"Failed to use certify: {e}")
-
-
-except Exception:
- pass
-
-
-@dataclass
-class TokenData:
- id_token: str
- access_token: str
- refresh_token: str
- account_id: str
-
-
-@dataclass
-class AuthBundle:
- """Aggregates authentication data produced after successful OAuth flow."""
-
- api_key: str | None
- token_data: TokenData
- last_refresh: str
-
-
-def main() -> None:
- parser = argparse.ArgumentParser(description="Retrieve API key via local HTTP flow")
- parser.add_argument(
- "--no-browser",
- action="store_true",
- help="Do not automatically open the browser",
- )
- parser.add_argument("--verbose", action="store_true", help="Enable request logging")
- args = parser.parse_args()
-
- codex_home = os.environ.get("CODEX_HOME")
- if not codex_home:
- eprint("ERROR: CODEX_HOME environment variable is not set")
- sys.exit(1)
-
- client_id = os.getenv("CODEX_CLIENT_ID")
- if not client_id:
- eprint("ERROR: CODEX_CLIENT_ID environment variable is not set")
- sys.exit(1)
-
- # Spawn server.
- try:
- httpd = _ApiKeyHTTPServer(
- ("127.0.0.1", REQUIRED_PORT),
- _ApiKeyHTTPHandler,
- codex_home=codex_home,
- client_id=client_id,
- verbose=args.verbose,
- )
- except OSError as e:
- eprint(f"ERROR: {e}")
- if e.errno == errno.EADDRINUSE:
- # Caller might want to handle this case specially.
- sys.exit(EXIT_CODE_WHEN_ADDRESS_ALREADY_IN_USE)
- else:
- sys.exit(1)
-
- auth_url = httpd.auth_url()
-
- with httpd:
- eprint(f"Starting local login server on {URL_BASE}")
- if not args.no_browser:
- try:
- webbrowser.open(auth_url, new=1, autoraise=True)
- except Exception as e:
- eprint(f"Failed to open browser: {e}")
-
- eprint(
- f". If your browser did not open, navigate to this URL to authenticate: \n\n{auth_url}"
- )
-
- # Run the server in the main thread until `shutdown()` is called by the
- # request handler.
- try:
- httpd.serve_forever()
- except KeyboardInterrupt:
- eprint("\nKeyboard interrupt received, exiting.")
-
- # Server has been shut down by the request handler. Exit with the code
- # it set (0 on success, non-zero on failure).
- sys.exit(httpd.exit_code)
-
-
-class _ApiKeyHTTPHandler(http.server.BaseHTTPRequestHandler):
- """A minimal request handler that captures an *api key* from query/post."""
-
- # We store the result in the server instance itself.
- server: "_ApiKeyHTTPServer" # type: ignore[override] - helpful annotation
-
- def do_GET(self) -> None: # noqa: N802 – required by BaseHTTPRequestHandler
- path = urllib.parse.urlparse(self.path).path
-
- if path == "/success":
- # Serve confirmation page then gracefully shut down the server so
- # the main thread can exit with the previously captured exit code.
- self._send_html(LOGIN_SUCCESS_HTML)
-
- # Ensure the data is flushed to the client before we stop.
- try:
- self.wfile.flush()
- except Exception as e:
- eprint(f"Failed to flush response: {e}")
-
- self.request_shutdown()
- elif path == "/auth/callback":
- query = urllib.parse.urlparse(self.path).query
- params = urllib.parse.parse_qs(query)
-
- # Validate state -------------------------------------------------
- if params.get("state", [None])[0] != self.server.state:
- self.send_error(400, "State parameter mismatch")
- return
-
- # Standard OAuth flow -----------------------------------------
- code = params.get("code", [None])[0]
- if not code:
- self.send_error(400, "Missing authorization code")
- return
-
- try:
- auth_bundle, success_url = self._exchange_code(code)
- except Exception as exc: # noqa: BLE001 – propagate to client
- self.send_error(500, f"Token exchange failed: {exc}")
- return
-
- # Persist API key along with additional token metadata.
- if _write_auth_file(
- auth=auth_bundle,
- codex_home=self.server.codex_home,
- ):
- self.server.exit_code = 0
- self._send_redirect(success_url)
- else:
- self.send_error(500, "Unable to persist auth file")
- else:
- self.send_error(404, "Endpoint not supported")
-
- def do_POST(self) -> None: # noqa: N802 – required by BaseHTTPRequestHandler
- self.send_error(404, "Endpoint not supported")
-
- def send_error(self, code, message=None, explain=None) -> None:
- """Send an error response and stop the server.
-
- We avoid calling `sys.exit()` directly from the request-handling thread
- so that the response has a chance to be written to the socket. Instead
- we shut the server down; the main thread will then exit with the
- appropriate status code.
- """
- super().send_error(code, message, explain)
- try:
- self.wfile.flush()
- except Exception as e:
- eprint(f"Failed to flush response: {e}")
-
- self.request_shutdown()
-
- def _send_redirect(self, url: str) -> None:
- self.send_response(302)
- self.send_header("Location", url)
- self.end_headers()
-
- def _send_html(self, body: str) -> None:
- encoded = body.encode()
- self.send_response(200)
- self.send_header("Content-Type", "text/html; charset=utf-8")
- self.send_header("Content-Length", str(len(encoded)))
- self.end_headers()
- self.wfile.write(encoded)
-
- # Silence logging for cleanliness unless --verbose flag is used.
- def log_message(self, fmt: str, *args): # type: ignore[override]
- if getattr(self.server, "verbose", False): # type: ignore[attr-defined]
- super().log_message(fmt, *args)
-
- def _obtain_api_key(
- self,
- token_claims: Dict[str, Any],
- access_claims: Dict[str, Any],
- token_data: TokenData,
- ) -> tuple[str | None, str | None]:
- """Obtain an API key from the auth service.
-
- Returns (api_key, success_url) if successful, None otherwise.
- """
-
- org_id = token_claims.get("organization_id")
- project_id = token_claims.get("project_id")
-
- if not org_id or not project_id:
- return (None, None)
-
- random_id = secrets.token_hex(6)
-
- # 2. Token exchange to obtain API key
- today = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d")
- exchange_data = urllib.parse.urlencode(
- {
- "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
- "client_id": self.server.client_id,
- "requested_token": "openai-api-key",
- "subject_token": token_data.id_token,
- "subject_token_type": "urn:ietf:params:oauth:token-type:id_token",
- "name": f"Codex CLI [auto-generated] ({today}) [{random_id}]",
- }
- ).encode()
-
- exchanged_access_token: str
- with urllib.request.urlopen(
- urllib.request.Request(
- self.server.token_endpoint,
- data=exchange_data,
- method="POST",
- headers={"Content-Type": "application/x-www-form-urlencoded"},
- ),
- context=CA_CONTEXT,
- ) as resp:
- exchange_payload = json.loads(resp.read().decode())
- exchanged_access_token = exchange_payload["access_token"]
-
- # Determine whether the organization still requires additional
- # setup (e.g., adding a payment method) based on the ID-token
- # claim provided by the auth service.
- completed_onboarding = token_claims.get("completed_platform_onboarding") == True
- chatgpt_plan_type = access_claims.get("chatgpt_plan_type")
- is_org_owner = token_claims.get("is_org_owner") == True
- needs_setup = not completed_onboarding and is_org_owner
-
- # Build the success URL on the same host/port as the callback and
- # include the required query parameters for the front-end page.
- success_url_query = {
- "id_token": token_data.id_token,
- "needs_setup": "true" if needs_setup else "false",
- "org_id": org_id,
- "project_id": project_id,
- "plan_type": chatgpt_plan_type,
- "platform_url": (
- "https://platform.openai.com"
- if self.server.issuer == "https://auth.openai.com"
- else "https://platform.api.openai.org"
- ),
- }
- success_url = f"{URL_BASE}/success?{urllib.parse.urlencode(success_url_query)}"
-
- # Attempt to redeem complimentary API credits for eligible ChatGPT
- # Plus / Pro subscribers. Any errors are logged but do not interrupt
- # the login flow.
-
- try:
- maybe_redeem_credits(
- issuer=self.server.issuer,
- client_id=self.server.client_id,
- id_token=token_data.id_token,
- refresh_token=token_data.refresh_token,
- codex_home=self.server.codex_home,
- )
- except Exception as exc: # pragma: no cover – best-effort only
- eprint(f"Unable to redeem ChatGPT subscriber API credits: {exc}")
-
- return (exchanged_access_token, success_url)
-
- def _exchange_code(self, code: str) -> tuple[AuthBundle, str]:
- """Perform token + token-exchange to obtain an OpenAI API key.
-
- Returns (AuthBundle, success_url).
- """
-
- # 1. Authorization-code -> (id_token, access_token, refresh_token)
- data = urllib.parse.urlencode(
- {
- "grant_type": "authorization_code",
- "code": code,
- "redirect_uri": self.server.redirect_uri,
- "client_id": self.server.client_id,
- "code_verifier": self.server.pkce.code_verifier,
- }
- ).encode()
-
- token_data: TokenData
-
- with urllib.request.urlopen(
- urllib.request.Request(
- self.server.token_endpoint,
- data=data,
- method="POST",
- headers={"Content-Type": "application/x-www-form-urlencoded"},
- ),
- context=CA_CONTEXT,
- ) as resp:
- payload = json.loads(resp.read().decode())
-
- # Extract chatgpt_account_id from id_token
- id_token_parts = payload["id_token"].split(".")
- if len(id_token_parts) != 3:
- raise ValueError("Invalid ID token")
- id_token_claims = _decode_jwt_segment(id_token_parts[1])
- auth_claims = id_token_claims.get("https://api.openai.com/auth", {})
- chatgpt_account_id = auth_claims.get("chatgpt_account_id", "")
-
- token_data = TokenData(
- id_token=payload["id_token"],
- access_token=payload["access_token"],
- refresh_token=payload["refresh_token"],
- account_id=chatgpt_account_id,
- )
-
- access_token_parts = token_data.access_token.split(".")
- if len(access_token_parts) != 3:
- raise ValueError("Invalid access token")
-
- access_token_claims = _decode_jwt_segment(access_token_parts[1])
-
- token_claims = id_token_claims.get("https://api.openai.com/auth", {})
- access_claims = access_token_claims.get("https://api.openai.com/auth", {})
-
- exchanged_access_token, success_url = self._obtain_api_key(
- token_claims, access_claims, token_data
- )
-
- # Persist refresh_token/id_token for future use (redeem credits etc.)
- last_refresh_str = (
- datetime.datetime.now(datetime.timezone.utc)
- .isoformat()
- .replace("+00:00", "Z")
- )
-
- auth_bundle = AuthBundle(
- api_key=exchanged_access_token,
- token_data=token_data,
- last_refresh=last_refresh_str,
- )
-
- return (auth_bundle, success_url or f"{URL_BASE}/success")
-
- def request_shutdown(self) -> None:
- # shutdown() must be invoked from another thread to avoid
- # deadlocking the serve_forever() loop, which is running in this
- # same thread. A short-lived helper thread does the trick.
- threading.Thread(target=self.server.shutdown, daemon=True).start()
-
-
-def _write_auth_file(*, auth: AuthBundle, codex_home: str) -> bool:
- """Persist *api_key* to $CODEX_HOME/auth.json.
-
- Returns True on success, False otherwise. Any error is printed to
- *stderr* so that the Rust layer can surface the problem.
- """
- if not os.path.isdir(codex_home):
- try:
- os.makedirs(codex_home, exist_ok=True)
- except Exception as exc: # pragma: no cover – unlikely
- eprint(f"ERROR: unable to create CODEX_HOME directory: {exc}")
- return False
-
- auth_path = os.path.join(codex_home, "auth.json")
- auth_json_contents = {
- "OPENAI_API_KEY": auth.api_key,
- "tokens": {
- "id_token": auth.token_data.id_token,
- "access_token": auth.token_data.access_token,
- "refresh_token": auth.token_data.refresh_token,
- "account_id": auth.token_data.account_id,
- },
- "last_refresh": auth.last_refresh,
- }
- try:
- with open(auth_path, "w", encoding="utf-8") as fp:
- if hasattr(os, "fchmod"): # POSIX-safe
- os.fchmod(fp.fileno(), 0o600)
- json.dump(auth_json_contents, fp, indent=2)
- except Exception as exc: # pragma: no cover – permissions/filesystem
- eprint(f"ERROR: unable to write auth file: {exc}")
- return False
-
- return True
-
-
-@dataclass
-class PkceCodes:
- code_verifier: str
- code_challenge: str
-
-
-class _ApiKeyHTTPServer(http.server.HTTPServer):
- """HTTPServer with shutdown helper & self-contained OAuth configuration."""
-
- def __init__(
- self,
- server_address: tuple[str, int],
- request_handler_class: type[http.server.BaseHTTPRequestHandler],
- *,
- codex_home: str,
- client_id: str,
- verbose: bool = False,
- ) -> None:
- super().__init__(server_address, request_handler_class, bind_and_activate=True)
-
- self.exit_code = 1
- self.codex_home = codex_home
- self.verbose: bool = verbose
-
- self.issuer: str = DEFAULT_ISSUER
- self.token_endpoint: str = f"{self.issuer}/oauth/token"
- self.client_id: str = client_id
- port = server_address[1]
- self.redirect_uri: str = f"http://localhost:{port}/auth/callback"
- self.pkce: PkceCodes = _generate_pkce()
- self.state: str = secrets.token_hex(32)
-
- def auth_url(self) -> str:
- """Return fully-formed OpenID authorization URL."""
- params = {
- "response_type": "code",
- "client_id": self.client_id,
- "redirect_uri": self.redirect_uri,
- "scope": "openid profile email offline_access",
- "code_challenge": self.pkce.code_challenge,
- "code_challenge_method": "S256",
- "id_token_add_organizations": "true",
- "codex_cli_simplified_flow": "true",
- "state": self.state,
- }
- return f"{self.issuer}/oauth/authorize?" + urllib.parse.urlencode(params)
-
-
-def maybe_redeem_credits(
- *,
- issuer: str,
- client_id: str,
- id_token: str | None,
- refresh_token: str,
- codex_home: str,
-) -> None:
- """Attempt to redeem complimentary API credits for ChatGPT subscribers.
-
- The operation is best-effort: any error results in a warning being printed
- and the function returning early without raising.
- """
- id_claims: Dict[str, Any] | None = parse_id_token_claims(id_token or "")
-
- # Refresh expired ID token, if possible
- token_expired = True
- if id_claims and isinstance(id_claims.get("exp"), int):
- token_expired = _current_timestamp_ms() >= int(id_claims["exp"]) * 1000
-
- if token_expired:
- eprint("Refreshing credentials...")
- new_refresh_token: str | None = None
- new_id_token: str | None = None
-
- try:
- payload = json.dumps(
- {
- "client_id": client_id,
- "grant_type": "refresh_token",
- "refresh_token": refresh_token,
- "scope": "openid profile email",
- }
- ).encode()
-
- req = urllib.request.Request(
- url="https://auth.openai.com/oauth/token",
- data=payload,
- method="POST",
- headers={"Content-Type": "application/json"},
- )
-
- with urllib.request.urlopen(req, context=CA_CONTEXT) as resp:
- refresh_data = json.loads(resp.read().decode())
- new_id_token = refresh_data.get("id_token")
- new_id_claims = parse_id_token_claims(new_id_token or "")
- new_refresh_token = refresh_data.get("refresh_token")
- except Exception as err:
- eprint("Unable to refresh ID token via token-exchange:", err)
- return
-
- if not new_id_token or not new_refresh_token:
- return
-
- # Update auth.json with new tokens.
- try:
- auth_dir = codex_home
- auth_path = os.path.join(auth_dir, "auth.json")
- with open(auth_path, "r", encoding="utf-8") as fp:
- existing = json.load(fp)
-
- tokens = existing.setdefault("tokens", {})
- tokens["id_token"] = new_id_token
- # Note this does not touch the access_token?
- tokens["refresh_token"] = new_refresh_token
- tokens["last_refresh"] = (
- datetime.datetime.now(datetime.timezone.utc)
- .isoformat()
- .replace("+00:00", "Z")
- )
-
- with open(auth_path, "w", encoding="utf-8") as fp:
- if hasattr(os, "fchmod"):
- os.fchmod(fp.fileno(), 0o600)
- json.dump(existing, fp, indent=2)
- except Exception as err:
- eprint("Unable to update refresh token in auth file:", err)
-
- if not new_id_claims:
- # Still couldn't parse claims.
- return
-
- id_token = new_id_token
- id_claims = new_id_claims
-
- # Done refreshing credentials: now try to redeem credits.
- if not id_token:
- eprint("No ID token available, cannot redeem credits.")
- return
-
- auth_claims = id_claims.get("https://api.openai.com/auth", {})
-
- # Subscription eligibility check (Plus or Pro, >7 days active)
- sub_start_str = auth_claims.get("chatgpt_subscription_active_start")
- if isinstance(sub_start_str, str):
- try:
- sub_start_ts = datetime.datetime.fromisoformat(sub_start_str.rstrip("Z"))
- if datetime.datetime.now(
- datetime.timezone.utc
- ) - sub_start_ts < datetime.timedelta(days=7):
- eprint(
- "Sorry, your subscription must be active for more than 7 days to redeem credits."
- )
- return
- except ValueError:
- # Malformed; ignore
- pass
-
- completed_onboarding = bool(auth_claims.get("completed_platform_onboarding"))
- is_org_owner = bool(auth_claims.get("is_org_owner"))
- needs_setup = not completed_onboarding and is_org_owner
- plan_type = auth_claims.get("chatgpt_plan_type")
-
- if needs_setup or plan_type not in {"plus", "pro"}:
- eprint("Only users with Plus or Pro subscriptions can redeem free API credits.")
- return
-
- api_host = (
- "https://api.openai.com"
- if issuer == "https://auth.openai.com"
- else "https://api.openai.org"
- )
-
- try:
- redeem_payload = json.dumps({"id_token": id_token}).encode()
- req = urllib.request.Request(
- url=f"{api_host}/v1/billing/redeem_credits",
- data=redeem_payload,
- method="POST",
- headers={"Content-Type": "application/json"},
- )
-
- with urllib.request.urlopen(req, context=CA_CONTEXT) as resp:
- redeem_data = json.loads(resp.read().decode())
-
- granted = redeem_data.get("granted_chatgpt_subscriber_api_credits", 0)
- if granted and granted > 0:
- eprint(
- f"""Thanks for being a ChatGPT {"Plus" if plan_type == "plus" else "Pro"} subscriber!
-If you haven't already redeemed, you should receive {"$5" if plan_type == "plus" else "$50"} in API credits.
-
-Credits: https://platform.openai.com/settings/organization/billing/credit-grants
-More info: https://help.openai.com/en/articles/11381614""",
- )
- else:
- eprint(
- f"""It looks like no credits were granted:
-
-{json.dumps(redeem_data, indent=2)}
-
-Credits: https://platform.openai.com/settings/organization/billing/credit-grants
-More info: https://help.openai.com/en/articles/11381614"""
- )
- except Exception as err:
- eprint("Credit redemption request failed:", err)
-
-
-def _generate_pkce() -> PkceCodes:
- """Generate PKCE *code_verifier* and *code_challenge* (S256)."""
- code_verifier = secrets.token_hex(64)
- digest = hashlib.sha256(code_verifier.encode()).digest()
- code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
- return PkceCodes(code_verifier, code_challenge)
-
-
-def eprint(*args, **kwargs) -> None:
- print(*args, file=sys.stderr, **kwargs)
-
-
-# Parse ID-token claims (if provided)
-#
-# interface IDTokenClaims {
-# "exp": number; // specifically, an int
-# "https://api.openai.com/auth": {
-# organization_id: string;
-# project_id: string;
-# completed_platform_onboarding: boolean;
-# is_org_owner: boolean;
-# chatgpt_subscription_active_start: string;
-# chatgpt_subscription_active_until: string;
-# chatgpt_plan_type: string;
-# };
-# }
-def parse_id_token_claims(id_token: str) -> Dict[str, Any] | None:
- if id_token:
- parts = id_token.split(".")
- if len(parts) == 3:
- return _decode_jwt_segment(parts[1])
- return None
-
-
-def _decode_jwt_segment(segment: str) -> Dict[str, Any]:
- """Return the decoded JSON payload from a JWT segment.
-
- Adds required padding for urlsafe_b64decode.
- """
- padded = segment + "=" * (-len(segment) % 4)
- try:
- data = base64.urlsafe_b64decode(padded.encode())
- return json.loads(data.decode())
- except Exception:
- return {}
-
-
-def _current_timestamp_ms() -> int:
- return int(time.time() * 1000)
-
-
-LOGIN_SUCCESS_HTML = """<!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>"""
-
-# Unconditionally call `main()` instead of gating it behind
-# `if __name__ == "__main__"` because this script is either:
-#
-# - invoked as a string passed to `python3 -c`
-# - run via `python3 login_with_chatgpt.py` for testing as part of local
-# development
-main()
diff --git a/codex-rs/login/src/pkce.rs b/codex-rs/login/src/pkce.rs
new file mode 100644
index 0000000000..28936ff248
--- /dev/null
+++ b/codex-rs/login/src/pkce.rs
@@ -0,0 +1,22 @@
+use base64::Engine as _;
+use rand::RngCore;
+use sha2::Digest;
+use sha2::Sha256;
+
+#[derive(Debug, Clone)]
+pub(crate) struct PkceCodes {
+ pub(crate) code_verifier: String,
+ pub(crate) code_challenge: String,
+}
+
+pub(crate) fn generate_pkce() -> PkceCodes {
+ 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,
+ }
+}
diff --git a/codex-rs/login/src/refresh.rs b/codex-rs/login/src/refresh.rs
new file mode 100644
index 0000000000..d5d1f5cf62
--- /dev/null
+++ b/codex-rs/login/src/refresh.rs
@@ -0,0 +1,48 @@
+use serde::Deserialize;
+use serde::Serialize;
+
+#[derive(Serialize)]
+struct RefreshRequest {
+ client_id: &'static str,
+ grant_type: &'static str,
+ refresh_token: String,
+ scope: &'static str,
+}
+
+#[derive(Deserialize, Clone)]
+pub(crate) struct RefreshResponse {
+ pub(crate) id_token: String,
+ pub(crate) access_token: Option<String>,
+ pub(crate) refresh_token: Option<String>,
+}
+
+pub(crate) async fn try_refresh_token(refresh_token: String) -> std::io::Result<RefreshResponse> {
+ let refresh_request = RefreshRequest {
+ client_id: crate::CLIENT_ID,
+ grant_type: "refresh_token",
+ refresh_token,
+ scope: "openid profile email",
+ };
+
+ let client = reqwest::Client::new();
+ let response = client
+ .post("https://auth.openai.com/oauth/token")
+ .header("Content-Type", "application/json")
+ .json(&refresh_request)
+ .send()
+ .await
+ .map_err(std::io::Error::other)?;
+
+ if response.status().is_success() {
+ let refresh_response = response
+ .json::<RefreshResponse>()
+ .await
+ .map_err(std::io::Error::other)?;
+ Ok(refresh_response)
+ } else {
+ Err(std::io::Error::other(format!(
+ "Failed to refresh token: {}",
+ response.status()
+ )))
+ }
+}
diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs
new file mode 100644
index 0000000000..667aa58858
--- /dev/null
+++ b/codex-rs/login/src/server.rs
@@ -0,0 +1,466 @@
+use rand::RngCore;
+use reqwest::blocking::Client;
+use std::collections::HashMap;
+use std::net::TcpListener;
+use std::path::Path;
+use std::path::PathBuf;
+#[cfg(feature = "http-e2e-tests")]
+use std::time::Duration;
+use tiny_http::Header;
+use tiny_http::Method;
+use tiny_http::Response;
+use tiny_http::Server;
+use url::Url;
+use url::form_urlencoded;
+
+use crate::auth_store::AuthDotJson;
+use crate::auth_store::get_auth_file;
+use crate::auth_store::write_auth_json;
+use crate::pkce::generate_pkce;
+use crate::success_url::build_success_url;
+use crate::token_data::TokenData;
+use crate::token_data::extract_login_context_from_tokens;
+use tracing::error;
+use tracing::trace;
+
+pub const DEFAULT_PORT: u16 = 1455;
+pub const DEFAULT_ISSUER: &str = "https://auth.openai.com";
+
+pub const LOGIN_SUCCESS_HTML: &str = include_str!("./success_page.html");
+pub const LOGIN_ERROR_HTML: &str = include_str!("./error_page.html");
+
+fn render_error_html(message: &str) -> String {
+ LOGIN_ERROR_HTML.replace("%%MESSAGE%%", html_escape::encode_text(message).as_ref())
+}
+
+#[derive(Debug, Clone)]
+pub enum LoginServerStatus {
+ Url(String),
+ Completed,
+}
+
+#[derive(Debug)]
+pub struct LoginServerOptions {
+ pub codex_home: PathBuf,
+ pub client_id: String,
+ pub issuer: String,
+ pub port: u16,
+ pub open_browser: bool,
+ pub expose_state_endpoint: bool,
+ pub testing_timeout_secs: Option<u64>,
+ pub port_sender: Option<std::sync::mpsc::Sender<u16>>,
+ pub status_sender: Option<std::sync::mpsc::Sender<LoginServerStatus>>,
+ pub cancel_receiver: Option<std::sync::mpsc::Receiver<()>>,
+}
+
+impl LoginServerOptions {
+ pub fn for_cli(codex_home: &Path, client_id: &str) -> Self {
+ Self {
+ codex_home: codex_home.to_path_buf(),
+ client_id: client_id.to_string(),
+ issuer: DEFAULT_ISSUER.to_string(),
+ port: DEFAULT_PORT,
+ open_browser: true,
+ expose_state_endpoint: false,
+ testing_timeout_secs: None,
+ port_sender: None,
+ status_sender: None,
+ cancel_receiver: None,
+ }
+ }
+
+ pub fn for_ui(
+ codex_home: &Path,
+ client_id: &str,
+ status_sender: std::sync::mpsc::Sender<LoginServerStatus>,
+ cancel_receiver: std::sync::mpsc::Receiver<()>,
+ port: u16,
+ ) -> Self {
+ Self {
+ codex_home: codex_home.to_path_buf(),
+ client_id: client_id.to_string(),
+ issuer: DEFAULT_ISSUER.to_string(),
+ port,
+ open_browser: true,
+ expose_state_endpoint: false,
+ testing_timeout_secs: None,
+ port_sender: None,
+ status_sender: Some(status_sender),
+ cancel_receiver: Some(cancel_receiver),
+ }
+ }
+}
+
+const PLATFORM_BASE: &str = "https://platform.openai.com";
+
+fn default_url_base(port: u16) -> String {
+ format!("http://localhost:{port}")
+}
+
+#[allow(dead_code)]
+pub fn run_local_login_server(codex_home: &Path, client_id: &str) -> std::io::Result<()> {
+ let opts = LoginServerOptions::for_cli(codex_home, client_id);
+ run_local_login_server_with_options(opts)
+}
+
+pub fn run_local_login_server_with_options(mut opts: LoginServerOptions) -> std::io::Result<()> {
+ let listener = TcpListener::bind(("127.0.0.1", opts.port))
+ .map_err(|e| std::io::Error::other(e.to_string()))?;
+ let actual_port = listener
+ .local_addr()
+ .map_err(|e| std::io::Error::other(e.to_string()))?
+ .port();
+ opts.port = actual_port;
+
+ if let Some(tx) = &opts.port_sender {
+ let _ = tx.send(actual_port);
+ }
+
+ let server =
+ Server::from_listener(listener, None).map_err(|e| std::io::Error::other(e.to_string()))?;
+
+ let issuer = opts.issuer.clone();
+ 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!("{url_base}/auth/callback");
+ let auth_url_str = format!("{issuer}/oauth/authorize");
+ let mut auth_url =
+ Url::parse(&auth_url_str).map_err(|e| std::io::Error::other(e.to_string()))?;
+ 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);
+
+ if opts.status_sender.is_none() {
+ eprintln!("Starting local login server on {url_base}");
+ }
+ if let Some(tx) = &opts.status_sender {
+ let _ = tx.send(LoginServerStatus::Url(auth_url.as_str().to_string()));
+ }
+ if opts.open_browser {
+ let _ = webbrowser::open(auth_url.as_str());
+ }
+ if opts.status_sender.is_none() {
+ eprintln!(
+ ". If your browser did not open, navigate to this URL to authenticate: \n\n{auth_url}"
+ );
+ }
+
+ // If a testing timeout is configured, schedule an internal exit request so tests don't hang CI.
+ #[cfg(feature = "http-e2e-tests")]
+ 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"));
+ });
+ }
+
+ // If cancellation is requested via cancel_receiver, trigger server shutdown by hitting /success
+ if let Some(rx) = opts.cancel_receiver.take() {
+ let port = opts.port;
+ std::thread::spawn(move || {
+ let _ = rx.recv();
+ let _ = reqwest::blocking::get(format!("http://127.0.0.1:{port}/success"));
+ });
+ }
+
+ // Main request loop
+ 'outer: loop {
+ let request = match server.recv() {
+ Ok(r) => r,
+ Err(e) => {
+ return Err(std::io::Error::other(e.to_string()));
+ }
+ };
+
+ 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),
+ };
+
+ trace!("{} {}", request.method().as_str(), request.url());
+
+ match (request.method().clone(), path.as_str()) {
+ (Method::Get, "/success") => {
+ let mut resp = Response::from_string(LOGIN_SUCCESS_HTML).with_status_code(200);
+ if let Ok(h) =
+ Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..])
+ {
+ resp.add_header(h);
+ }
+ if let Err(e) = request.respond(resp) {
+ error!("failed to respond to /success: {e}");
+ }
+ break 'outer;
+ }
+ (Method::Get, "/__test/exit") => {
+ if let Err(e) = request.respond(Response::from_string("bye").with_status_code(200))
+ {
+ error!("failed to respond to /__test/exit: {e}");
+ }
+ break 'outer;
+ }
+ // Test-only helper to retrieve the current state, enabled via options.
+ #[cfg(feature = "http-e2e-tests")]
+ (Method::Get, "/__test/state") if opts.expose_state_endpoint => {
+ let mut resp = Response::from_string(state.clone()).with_status_code(200);
+ if let Ok(h) = Header::from_bytes(&b"Content-Type"[..], &b"text/plain"[..]) {
+ resp.add_header(h);
+ }
+ if let Err(e) = request.respond(resp) {
+ error!("failed to respond to /__test/state: {e}");
+ }
+ }
+ (Method::Get, "/auth/callback") => {
+ // Parse query params
+ let params: HashMap<String, String> =
+ form_urlencoded::parse(query.as_deref().unwrap_or("").as_bytes())
+ .into_owned()
+ .collect();
+
+ // Preserve explicit error messages for tests
+ if params.get("state").map(|s| s.as_str()) != Some(state.as_str()) {
+ let mut resp =
+ Response::from_string(render_error_html("State parameter mismatch"))
+ .with_status_code(400);
+ if let Ok(h) =
+ Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..])
+ {
+ resp.add_header(h);
+ }
+ if let Err(e) = request.respond(resp) {
+ error!("failed to respond to state mismatch: {e}");
+ }
+ continue;
+ }
+ let code_opt = params.get("code").map(|s| s.as_str());
+ if code_opt.map(|s| s.is_empty()).unwrap_or(true) {
+ let mut resp =
+ Response::from_string(render_error_html("Missing authorization code"))
+ .with_status_code(400);
+ if let Ok(h) =
+ Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..])
+ {
+ resp.add_header(h);
+ }
+ if let Err(e) = request.respond(resp) {
+ error!("failed to respond to missing code: {e}");
+ }
+ continue;
+ }
+
+ // Delegate to shared headless callback handler
+ let http = DefaultHttp::default();
+ match process_callback_headless(
+ &opts,
+ &state,
+ &state,
+ code_opt,
+ &pkce.code_verifier,
+ &http,
+ ) {
+ Ok(outcome) => {
+ let mut resp = Response::empty(302);
+ if let Ok(h) =
+ Header::from_bytes(&b"Location"[..], outcome.success_url.as_str())
+ {
+ resp.add_header(h);
+ }
+ if let Err(e) = request.respond(resp) {
+ error!("failed to respond redirect to success: {e}");
+ }
+ }
+ Err(_) => {
+ let mut resp =
+ Response::from_string(render_error_html("Token exchange failed"))
+ .with_status_code(500);
+ if let Ok(h) = Header::from_bytes(
+ &b"Content-Type"[..],
+ &b"text/html; charset=utf-8"[..],
+ ) {
+ resp.add_header(h);
+ }
+ if let Err(e) = request.respond(resp) {
+ error!("failed to respond to token exchange failure: {e}");
+ }
+ }
+ }
+ }
+ _ => {
+ if let Err(e) = request
+ .respond(Response::from_string("Endpoint not supported").with_status_code(404))
+ {
+ error!("failed to respond 404: {e}");
+ }
+ }
+ }
+ }
+ if let Some(tx) = &opts.status_sender {
+ let _ = tx.send(LoginServerStatus::Completed);
+ }
+ 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 {
+ Self(Client::new())
+ }
+}
+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::other(e.to_string()))?;
+ let val = resp
+ .json::<serde_json::Value>()
+ .map_err(|e| std::io::Error::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::other(e.to_string()))?;
+ let val = resp
+ .json::<serde_json::Value>()
+ .map_err(|e| std::io::Error::other(e.to_string()))?;
+ Ok(val)
+ }
+}
+
+#[derive(serde::Deserialize)]
+struct TokenExchange {
+ id_token: String,
+ access_token: String,
+ refresh_token: String,
+}
+
+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));
+
+ 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 TokenExchange {
+ id_token,
+ access_token,
+ refresh_token,
+ } = serde_json::from_value(tokens_val)
+ .map_err(|e| std::io::Error::other(format!("invalid token response: {e}")))?;
+ if id_token.is_empty() || access_token.is_empty() || refresh_token.is_empty() {
+ return Err(std::io::Error::other("token exchange failed"));
+ }
+
+ let (account_id, org_id, project_id, needs_setup, plan_type) =
+ extract_login_context_from_tokens(&id_token, &access_token);
+
+ let api_key = None;
+
+ let tokens_struct = TokenData::from_raw(
+ id_token.clone(),
+ access_token.clone(),
+ refresh_token.clone(),
+ account_id,
+ )
+ .map_err(std::io::Error::other)?;
+ let auth = AuthDotJson {
+ openai_api_key: api_key.clone(),
+ tokens: Some(tokens_struct),
+ last_refresh: Some(chrono::Utc::now()),
+ };
+ let auth_file = get_auth_file(&opts.codex_home);
+ write_auth_json(&auth_file, &auth)?;
+
+ // Intentionally not redeeming credits here
+
+ let base = default_url_base(opts.port);
+ let platform_url = PLATFORM_BASE;
+ let success_url = build_success_url(
+ &base,
+ Some(&id_token),
+ org_id.as_deref(),
+ project_id.as_deref(),
+ plan_type.as_deref(),
+ needs_setup,
+ platform_url,
+ )
+ .map_err(|e| std::io::Error::other(e.to_string()))?;
+
+ Ok(HeadlessOutcome {
+ success_url: success_url.to_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 @@
+<!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>
+
+
diff --git a/codex-rs/login/src/success_url.rs b/codex-rs/login/src/success_url.rs
new file mode 100644
index 0000000000..1e4cf16547
--- /dev/null
+++ b/codex-rs/login/src/success_url.rs
@@ -0,0 +1,32 @@
+use url::Url;
+
+pub(crate) fn build_success_url(
+ url_base: &str,
+ id_token: Option<&str>,
+ org_id: Option<&str>,
+ project_id: Option<&str>,
+ plan_type: Option<&str>,
+ needs_setup: bool,
+ platform_url: &str,
+) -> Result<Url, url::ParseError> {
+ let mut success_url = Url::parse(&format!("{url_base}/success"))?;
+ if let Some(id) = id_token {
+ success_url.query_pairs_mut().append_pair("id_token", id);
+ }
+ 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);
+ Ok(success_url)
+}
diff --git a/codex-rs/login/src/token_data.rs b/codex-rs/login/src/token_data.rs
index fb4d83950f..bda256b946 100644
--- a/codex-rs/login/src/token_data.rs
+++ b/codex-rs/login/src/token_data.rs
@@ -3,23 +3,51 @@ use serde::Deserialize;
use serde::Serialize;
use thiserror::Error;
-#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Default)]
+#[derive(Clone, Debug, Default, Serialize, Deserialize)]
+#[serde(try_from = "TokenDataDe")]
pub struct TokenData {
- /// Flat info parsed from the JWT in auth.json.
- #[serde(deserialize_with = "deserialize_id_token")]
+ /// Flat info parsed from the JWT in auth.json (not serialized).
+ #[serde(skip)]
pub id_token: IdTokenInfo,
-
+ /// Raw JWT string used for serialization as `tokens.id_token` on disk.
+ #[serde(rename = "id_token")]
+ pub id_token_raw: String,
/// This is a JWT.
pub access_token: String,
-
pub refresh_token: String,
-
+ #[serde(skip_serializing_if = "Option::is_none")]
pub account_id: Option<String>,
}
+impl PartialEq for TokenData {
+ fn eq(&self, other: &Self) -> bool {
+ self.id_token == other.id_token
+ && self.access_token == other.access_token
+ && self.refresh_token == other.refresh_token
+ && self.account_id == other.account_id
+ }
+}
+
+impl Eq for TokenData {}
+/// Returns true if this is a plan that should use the traditional
+/// "metered" billing via an API key.
impl TokenData {
- /// Returns true if this is a plan that should use the traditional
- /// "metered" billing via an API key.
+ pub fn from_raw(
+ id_token_raw: String,
+ access_token: String,
+ refresh_token: String,
+ account_id: Option<String>,
+ ) -> Result<Self, IdTokenInfoError> {
+ let id_token = parse_id_token(&id_token_raw)?;
+ Ok(Self {
+ id_token,
+ id_token_raw,
+ access_token,
+ refresh_token,
+ account_id,
+ })
+ }
+
pub(crate) fn is_plan_that_should_use_api_key(&self) -> bool {
self.id_token
.chatgpt_plan_type
@@ -28,13 +56,35 @@ impl TokenData {
}
}
+#[derive(Deserialize)]
+struct TokenDataDe {
+ #[serde(rename = "id_token")]
+ id_token_raw: String,
+ access_token: String,
+ refresh_token: String,
+ #[serde(default)]
+ account_id: Option<String>,
+}
+
+impl TryFrom<TokenDataDe> for TokenData {
+ type Error = IdTokenInfoError;
+
+ fn try_from(de: TokenDataDe) -> Result<Self, Self::Error> {
+ let id_token = parse_id_token(&de.id_token_raw)?;
+ Ok(TokenData {
+ id_token,
+ id_token_raw: de.id_token_raw,
+ access_token: de.access_token,
+ refresh_token: de.refresh_token,
+ account_id: de.account_id,
+ })
+ }
+}
+
/// Flat subset of useful claims in id_token from auth.json.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)]
pub struct IdTokenInfo {
pub email: Option<String>,
- /// The ChatGPT subscription plan type
- /// (e.g., "free", "plus", "pro", "business", "enterprise", "edu").
- /// (Note: ae has not verified that those are the exact values.)
pub(crate) chatgpt_plan_type: Option<PlanType>,
}
@@ -88,19 +138,7 @@ pub(crate) enum KnownPlan {
Edu,
}
-#[derive(Deserialize)]
-struct IdClaims {
- #[serde(default)]
- email: Option<String>,
- #[serde(rename = "https://api.openai.com/auth", default)]
- auth: Option<AuthClaims>,
-}
-
-#[derive(Deserialize)]
-struct AuthClaims {
- #[serde(default)]
- chatgpt_plan_type: Option<PlanType>,
-}
+// Removed duplicate IdClaims/AuthClaims in favor of unified helpers below
#[derive(Debug, Error)]
pub enum IdTokenInfoError {
@@ -113,28 +151,105 @@ pub enum IdTokenInfoError {
}
pub(crate) fn parse_id_token(id_token: &str) -> Result<IdTokenInfo, IdTokenInfoError> {
- // JWT format: header.payload.signature
- let mut parts = id_token.split('.');
- let (_header_b64, payload_b64, _sig_b64) = match (parts.next(), parts.next(), parts.next()) {
- (Some(h), Some(p), Some(s)) if !h.is_empty() && !p.is_empty() && !s.is_empty() => (h, p, s),
- _ => return Err(IdTokenInfoError::InvalidFormat),
- };
-
- let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload_b64)?;
- let claims: IdClaims = serde_json::from_slice(&payload_bytes)?;
-
+ // Reuse the generic JWT parsing helpers to extract fields
+ let payload = decode_jwt_payload(id_token).ok_or(IdTokenInfoError::InvalidFormat)?;
+ // Reuse AuthOuterClaims instead of a local struct to avoid duplication
+ let claims: AuthOuterClaims = serde_json::from_slice(&payload)?;
Ok(IdTokenInfo {
email: claims.email,
chatgpt_plan_type: claims.auth.and_then(|a| a.chatgpt_plan_type),
})
}
-fn deserialize_id_token<'de, D>(deserializer: D) -> Result<IdTokenInfo, D::Error>
-where
- D: serde::Deserializer<'de>,
-{
- let s = String::deserialize(deserializer)?;
- parse_id_token(&s).map_err(serde::de::Error::custom)
+// -------- Helpers for parsing OpenAI auth claims from arbitrary JWTs --------
+
+#[derive(Default, Deserialize)]
+struct AuthOuterClaims {
+ #[serde(default)]
+ email: Option<String>,
+ #[serde(rename = "https://api.openai.com/auth", default)]
+ auth: Option<AuthInnerClaims>,
+}
+
+#[derive(Default, Deserialize, Clone)]
+struct AuthInnerClaims {
+ #[serde(default)]
+ chatgpt_account_id: Option<String>,
+ #[serde(default)]
+ organization_id: Option<String>,
+ #[serde(default)]
+ project_id: Option<String>,
+ #[serde(default)]
+ completed_platform_onboarding: Option<bool>,
+ #[serde(default)]
+ is_org_owner: Option<bool>,
+ #[serde(default)]
+ chatgpt_plan_type: Option<PlanType>,
+}
+
+fn decode_jwt_payload(token: &str) -> Option<Vec<u8>> {
+ let mut parts = token.split('.');
+ let _header = parts.next();
+ let payload_b64 = parts.next();
+ let _sig = parts.next();
+ payload_b64.and_then(|p| {
+ base64::engine::general_purpose::URL_SAFE_NO_PAD
+ .decode(p)
+ .ok()
+ })
+}
+
+fn parse_auth_inner_claims(token: &str) -> AuthInnerClaims {
+ decode_jwt_payload(token)
+ .and_then(|bytes| serde_json::from_slice::<AuthOuterClaims>(&bytes).ok())
+ .and_then(|o| o.auth)
+ .unwrap_or_default()
+}
+
+/// Extracts commonly used claims from ID and access tokens.
+/// - account_id is taken from the ID token.
+/// - org_id/project_id prefer ID token, falling back to access token.
+/// - plan_type comes from the access token (as lowercase string).
+/// - needs_setup is computed from (completed_platform_onboarding, is_org_owner)
+pub(crate) fn extract_login_context_from_tokens(
+ id_token: &str,
+ access_token: &str,
+) -> (
+ Option<String>, // account_id
+ Option<String>, // org_id
+ Option<String>, // project_id
+ bool, // needs_setup
+ Option<String>, // plan_type
+) {
+ let id_inner = parse_auth_inner_claims(id_token);
+ let access_inner = parse_auth_inner_claims(access_token);
+
+ let account_id = id_inner.chatgpt_account_id.clone();
+ let org_id = id_inner
+ .organization_id
+ .clone()
+ .or_else(|| access_inner.organization_id.clone());
+ let project_id = id_inner
+ .project_id
+ .clone()
+ .or_else(|| access_inner.project_id.clone());
+
+ let completed_onboarding = id_inner
+ .completed_platform_onboarding
+ .or(access_inner.completed_platform_onboarding)
+ .unwrap_or(false);
+ let is_org_owner = id_inner
+ .is_org_owner
+ .or(access_inner.is_org_owner)
+ .unwrap_or(false);
+ let needs_setup = !completed_onboarding && is_org_owner;
+
+ let plan_type = access_inner
+ .chatgpt_plan_type
+ .as_ref()
+ .map(PlanType::as_string);
+
+ (account_id, org_id, project_id, needs_setup, plan_type)
}
#[cfg(test)]
diff --git a/codex-rs/login/tests/api_key_login.rs b/codex-rs/login/tests/api_key_login.rs
new file mode 100644
index 0000000000..88686e6bb2
--- /dev/null
+++ b/codex-rs/login/tests/api_key_login.rs
@@ -0,0 +1,12 @@
+use tempfile::tempdir;
+
+#[tokio::test]
+async fn writes_api_key_and_loads_auth() -> Result<(), Box<dyn std::error::Error>> {
+ let dir = tempdir()?;
+ codex_login::login_with_api_key(dir.path(), "sk-test-key")?;
+ let auth = codex_login::CodexAuth::from_codex_home(dir.path())?
+ .ok_or_else(|| std::io::Error::other("expected Some(auth)"))?;
+ assert_eq!(auth.mode, codex_login::AuthMode::ApiKey);
+ assert_eq!(auth.get_token().await?.as_str(), "sk-test-key");
+ Ok(())
+}
diff --git a/codex-rs/login/tests/common/mod.rs b/codex-rs/login/tests/common/mod.rs
new file mode 100644
index 0000000000..8b8c665005
--- /dev/null
+++ b/codex-rs/login/tests/common/mod.rs
@@ -0,0 +1,10 @@
+#![allow(clippy::unwrap_used, clippy::expect_used)]
+pub 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}")
+}
diff --git a/codex-rs/login/tests/headless.rs b/codex-rs/login/tests/headless.rs
new file mode 100644
index 0000000000..bab9b6b973
--- /dev/null
+++ b/codex-rs/login/tests/headless.rs
@@ -0,0 +1,157 @@
+#![expect(clippy::unwrap_used)]
+use codex_login::LoginServerOptions;
+use codex_login::process_callback_headless;
+use serde_json::json;
+mod common;
+use std::cell::RefCell;
+use std::collections::VecDeque;
+use tempfile::TempDir;
+
+type FormCapture = (String, Vec<(String, String)>);
+
+#[derive(Default)]
+struct MockHttp {
+ forms: RefCell<Vec<FormCapture>>,
+ 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::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::other("no reply"))
+ }
+}
+
+use common::make_fake_jwt;
+
+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,
+ expose_state_endpoint: false,
+ testing_timeout_secs: None,
+ port_sender: None,
+ status_sender: None,
+ cancel_receiver: None,
+ }
+}
+
+// 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"
+ }));
+
+ let outcome =
+ process_callback_headless(&opts, "state", "state", Some("code"), "ver", &http).unwrap();
+ assert!(outcome.success_url.contains("/success"));
+ let auth_path = codex_login::get_auth_file(tmp.path());
+ let auth = codex_login::try_read_auth_json(&auth_path).unwrap();
+ assert!(auth.openai_api_key.is_none());
+}
+
+// 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) (Removed) Credit redemption is no longer attempted
+
+// 6) ID-token fallback for org/project/flags
+#[test]
+fn headless_id_token_fallback_for_org_and_project() {
+ let tmp = TempDir::new().unwrap();
+ let opts = default_opts(&tmp);
+ let http = MockHttp::default();
+ // Code exchange: put org/project/flags into ID token; plan_type into access
+ http.queue(json!({
+ "id_token": make_fake_jwt(json!({
+ "https://api.openai.com/auth": {
+ "chatgpt_account_id": "acc",
+ "organization_id": "id-org",
+ "project_id": "id-proj",
+ "completed_platform_onboarding": true,
+ "is_org_owner": false
+ }
+ })),
+ "access_token": make_fake_jwt(json!({
+ "https://api.openai.com/auth": {
+ "chatgpt_plan_type": "plus"
+ }
+ })),
+ "refresh_token": "r1"
+ }));
+
+ let outcome =
+ process_callback_headless(&opts, "state", "state", Some("code"), "ver", &http).unwrap();
+ assert!(outcome.success_url.contains("org_id=id-org"));
+ assert!(outcome.success_url.contains("project_id=id-proj"));
+}
diff --git a/codex-rs/login/tests/server.rs b/codex-rs/login/tests/server.rs
new file mode 100644
index 0000000000..846bb28446
--- /dev/null
+++ b/codex-rs/login/tests/server.rs
@@ -0,0 +1,489 @@
+#![cfg(feature = "http-e2e-tests")]
+#![allow(clippy::unwrap_used, clippy::expect_used)]
+mod common;
+use codex_login::LoginServerOptions;
+use codex_login::run_local_login_server_with_options;
+use std::thread;
+use std::time::Duration;
+use tempfile::TempDir;
+use wiremock::Mock;
+use wiremock::MockServer;
+use wiremock::ResponseTemplate;
+use wiremock::matchers::method;
+use wiremock::matchers::path;
+
+async fn start_mock_oauth_server(behavior: MockBehavior) -> MockServer {
+ let server = MockServer::start().await;
+
+ match behavior {
+ MockBehavior::Noop => {}
+ MockBehavior::Success => {
+ 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"
+ });
+ Mock::given(method("POST"))
+ .and(path("/oauth/token"))
+ .respond_with(
+ ResponseTemplate::new(200)
+ .insert_header("content-type", "application/json")
+ .set_body_json(payload),
+ )
+ .expect(1)
+ .mount(&server)
+ .await;
+ }
+ MockBehavior::SuccessTwice => {
+ 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"
+ });
+ Mock::given(method("POST"))
+ .and(path("/oauth/token"))
+ .respond_with(
+ ResponseTemplate::new(200)
+ .insert_header("content-type", "application/json")
+ .set_body_json(payload),
+ )
+ .expect(2)
+ .mount(&server)
+ .await;
+ }
+ MockBehavior::SuccessNeedsSetup => {
+ 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-2",
+ "project_id": "proj-2",
+ "completed_platform_onboarding": false,
+ "is_org_owner": true,
+ "chatgpt_plan_type": "pro"
+ }
+ }));
+ let payload = serde_json::json!({
+ "id_token": id_token,
+ "access_token": access_token,
+ "refresh_token": "refresh-1"
+ });
+ Mock::given(method("POST"))
+ .and(path("/oauth/token"))
+ .respond_with(
+ ResponseTemplate::new(200)
+ .insert_header("content-type", "application/json")
+ .set_body_json(payload),
+ )
+ .expect(1)
+ .mount(&server)
+ .await;
+ }
+ MockBehavior::SuccessIdClaimsOrgProject => {
+ let id_token = make_fake_jwt(serde_json::json!({
+ "https://api.openai.com/auth": {
+ "chatgpt_account_id": "acc-3",
+ "organization_id": "org-id",
+ "project_id": "proj-id",
+ "completed_platform_onboarding": true,
+ "is_org_owner": false
+ }
+ }));
+ let access_token = make_fake_jwt(serde_json::json!({
+ "https://api.openai.com/auth": {
+ "chatgpt_plan_type": "plus"
+ }
+ }));
+ let payload = serde_json::json!({
+ "id_token": id_token,
+ "access_token": access_token,
+ "refresh_token": "refresh-1"
+ });
+ Mock::given(method("POST"))
+ .and(path("/oauth/token"))
+ .respond_with(
+ ResponseTemplate::new(200)
+ .insert_header("content-type", "application/json")
+ .set_body_json(payload),
+ )
+ .expect(1)
+ .mount(&server)
+ .await;
+ }
+ MockBehavior::TokenError => {
+ Mock::given(method("POST"))
+ .and(path("/oauth/token"))
+ .respond_with(ResponseTemplate::new(500))
+ .expect(1)
+ .mount(&server)
+ .await;
+ }
+ MockBehavior::MissingOrgSkipExchange => {
+ let id_token = make_fake_jwt(serde_json::json!({
+ "https://api.openai.com/auth": {
+ "chatgpt_account_id": "acc-4"
+ }
+ }));
+ let access_token = make_fake_jwt(serde_json::json!({
+ "https://api.openai.com/auth": {
+ "chatgpt_plan_type": "plus"
+ }
+ }));
+ let payload = serde_json::json!({
+ "id_token": id_token,
+ "access_token": access_token,
+ "refresh_token": "refresh-4"
+ });
+ Mock::given(method("POST"))
+ .and(path("/oauth/token"))
+ .respond_with(
+ ResponseTemplate::new(200)
+ .insert_header("content-type", "application/json")
+ .set_body_json(payload),
+ )
+ .expect(1)
+ .mount(&server)
+ .await;
+ }
+ }
+
+ server
+}
+
+#[derive(Clone, Copy)]
+enum MockBehavior {
+ Noop,
+ Success,
+ SuccessTwice,
+ SuccessNeedsSetup,
+ SuccessIdClaimsOrgProject,
+ TokenError,
+ MissingOrgSkipExchange,
+ // Old token-exchange fallback behaviors removed
+}
+
+use common::make_fake_jwt;
+
+fn spawn_login_server_and_wait(
+ issuer: String,
+ codex_home: &tempfile::TempDir,
+) -> (std::thread::JoinHandle<std::io::Result<()>>, u16) {
+ let (tx, rx) = std::sync::mpsc::channel();
+ let opts = LoginServerOptions {
+ codex_home: codex_home.path().to_path_buf(),
+ client_id: "test-client".to_string(),
+ issuer,
+ port: 0,
+ open_browser: false,
+ expose_state_endpoint: true,
+ testing_timeout_secs: Some(5),
+ port_sender: Some(tx),
+ status_sender: None,
+ cancel_receiver: None,
+ };
+
+ let handle = thread::spawn(move || run_local_login_server_with_options(opts));
+ let port = rx.recv().unwrap();
+ wait_for_state_endpoint(port, Duration::from_secs(5));
+ (handle, port)
+}
+
+fn http_get(url: &str) -> (u16, String, Option<String>) {
+ let agent = ureq::AgentBuilder::new().redirects(0).build();
+ match agent.get(url).call() {
+ Ok(resp) => {
+ let status = resp.status();
+ let location = resp.header("Location").map(|s| s.to_string());
+ let body = resp.into_string().unwrap_or_default();
+ (status, body, location)
+ }
+ Err(ureq::Error::Status(code, resp)) => {
+ let location = resp.header("Location").map(|s| s.to_string());
+ let body = resp.into_string().unwrap_or_default();
+ (code, body, location)
+ }
+ Err(err) => panic!("http error: {err}"),
+ }
+}
+
+fn http_get_with_ct(url: &str) -> (u16, String, Option<String>) {
+ let agent = ureq::AgentBuilder::new().redirects(0).build();
+ match agent.get(url).call() {
+ Ok(resp) => {
+ let status = resp.status();
+ let content_type = resp.header("content-type").map(|s| s.to_string());
+ let body = resp.into_string().unwrap_or_default();
+ (status, body, content_type)
+ }
+ Err(ureq::Error::Status(code, resp)) => {
+ let content_type = resp.header("content-type").map(|s| s.to_string());
+ let body = resp.into_string().unwrap_or_default();
+ (code, body, content_type)
+ }
+ Err(err) => panic!("http error: {err}"),
+ }
+}
+
+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
+#[tokio::test]
+async fn login_server_happy_path() {
+ let server = start_mock_oauth_server(MockBehavior::SuccessTwice).await;
+
+ let codex_home = TempDir::new().unwrap();
+ let issuer = server.uri();
+ let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home);
+
+ // 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}");
+ // First, capture redirect without following
+ let (status, _body, location) = http_get(&cb_url);
+ assert_eq!(status, 302);
+ let location = location.expect("location header");
+ assert!(location.contains("/success"));
+ assert!(location.contains("needs_setup=false"));
+ assert!(location.contains("plan_type=plus"));
+ assert!(location.contains("org_id=org-1"));
+ assert!(location.contains("project_id=proj-1"));
+ // Now follow redirect (this will invoke the callback a second time)
+ let (status, body) = http_get_follow_redirect(&cb_url);
+ assert_eq!(status, 200);
+ assert!(body.contains("Signed in to Codex CLI"));
+
+ handle.join().unwrap().unwrap();
+
+ // Verify auth.json written
+ let auth_path = codex_login::get_auth_file(codex_home.path());
+ let auth = codex_login::try_read_auth_json(&auth_path).unwrap();
+ assert!(auth.openai_api_key.is_none());
+ assert!(auth.tokens.as_ref().is_some());
+ assert!(!auth.tokens.as_ref().unwrap().access_token.is_empty());
+}
+// 1b) needs_setup=true when onboarding incomplete and is_org_owner=true
+#[tokio::test]
+async fn login_server_needs_setup_true_and_params_present() {
+ let server = start_mock_oauth_server(MockBehavior::SuccessNeedsSetup).await;
+
+ let codex_home = TempDir::new().unwrap();
+ let issuer = server.uri();
+ let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home);
+ let state_url = format!("http://127.0.0.1:{port}/__test/state");
+ let (_s, state, _) = http_get(&state_url);
+ assert!(!state.is_empty());
+ let cb_url = format!("http://127.0.0.1:{port}/auth/callback?code=abc&state={state}");
+ let (status, _body, location) = http_get(&cb_url);
+ assert_eq!(status, 302);
+ let location = location.expect("location header");
+ assert!(location.contains("needs_setup=true"));
+ assert!(location.contains("plan_type=pro"));
+ assert!(location.contains("org_id=org-2"));
+ assert!(location.contains("project_id=proj-2"));
+ let _ = ureq::get(&format!("http://127.0.0.1:{port}/success")).call();
+ handle.join().unwrap().unwrap();
+}
+
+// 1c) org/project from ID token only should appear in redirect (fallback logic)
+#[tokio::test]
+async fn login_server_id_token_fallback_for_org_and_project() {
+ let server = start_mock_oauth_server(MockBehavior::SuccessIdClaimsOrgProject).await;
+
+ let codex_home = TempDir::new().unwrap();
+ let issuer = server.uri();
+ let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home);
+ let state_url = format!("http://127.0.0.1:{port}/__test/state");
+ let (_s, state, _) = http_get(&state_url);
+ let cb_url = format!("http://127.0.0.1:{port}/auth/callback?code=abc&state={state}");
+ let (status, _body, location) = http_get(&cb_url);
+ assert_eq!(status, 302);
+ let location = location.expect("location header");
+ assert!(location.contains("org_id=org-id"));
+ assert!(location.contains("project_id=proj-id"));
+ let _ = ureq::get(&format!("http://127.0.0.1:{port}/success")).call();
+ handle.join().unwrap().unwrap();
+}
+
+// 1d) Missing org/project in claims -> skip token-exchange, persist tokens without API key, still success
+#[tokio::test]
+async fn login_server_skips_exchange_when_no_org_or_project() {
+ let server = start_mock_oauth_server(MockBehavior::MissingOrgSkipExchange).await;
+
+ let codex_home = TempDir::new().unwrap();
+ let issuer = server.uri();
+ let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home);
+ let state_url = format!("http://127.0.0.1:{port}/__test/state");
+ let (_s, state, _) = http_get(&state_url);
+ let cb_url = format!("http://127.0.0.1:{port}/auth/callback?code=abc&state={state}");
+ let (status, _body, location) = http_get(&cb_url);
+ assert_eq!(status, 302);
+ let location = location.expect("location header");
+ // No org_id/project_id in redirect
+ assert!(!location.contains("org_id="));
+ assert!(!location.contains("project_id="));
+ let _ = ureq::get(&format!("http://127.0.0.1:{port}/success")).call();
+ handle.join().unwrap().unwrap();
+
+ // Verify auth.json OPENAI_API_KEY is null
+ let auth_path = codex_login::get_auth_file(codex_home.path());
+ let auth = codex_login::try_read_auth_json(&auth_path).unwrap();
+ assert!(auth.openai_api_key.is_none());
+}
+
+//
+// 2) State mismatch returns 400 and server stays up
+#[tokio::test]
+async fn login_server_state_mismatch() {
+ let server = start_mock_oauth_server(MockBehavior::Noop).await;
+ let codex_home = TempDir::new().unwrap();
+ let issuer = server.uri();
+ let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home);
+
+ let cb_url = format!("http://127.0.0.1:{port}/auth/callback?code=abc&state=wrong");
+ let (status, body, content_type) = http_get_with_ct(&cb_url);
+ assert_eq!(status, 400);
+ assert!(body.contains("State parameter mismatch"));
+ assert!(
+ content_type
+ .unwrap_or_default()
+ .to_ascii_lowercase()
+ .starts_with("text/html")
+ );
+
+ // Stop server
+ let _ = ureq::get(&format!("http://127.0.0.1:{port}/success")).call();
+ handle.join().unwrap().unwrap();
+}
+
+// 3) Missing code returns 400
+#[tokio::test]
+async fn login_server_missing_code() {
+ let server = start_mock_oauth_server(MockBehavior::Noop).await;
+ let codex_home = TempDir::new().unwrap();
+ let issuer = server.uri();
+ let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home);
+
+ // 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, content_type) = http_get_with_ct(&cb_url);
+ assert_eq!(status, 400);
+ assert!(body.contains("Missing authorization code"));
+ assert!(
+ content_type
+ .unwrap_or_default()
+ .to_ascii_lowercase()
+ .starts_with("text/html")
+ );
+ let _ = ureq::get(&format!("http://127.0.0.1:{port}/success")).call();
+ handle.join().unwrap().unwrap();
+}
+
+// 4) Token endpoint error returns 500 (on code exchange) and server stays up
+#[tokio::test]
+async fn login_server_token_exchange_error() {
+ let server = start_mock_oauth_server(MockBehavior::TokenError).await;
+ let codex_home = TempDir::new().unwrap();
+ let issuer = server.uri();
+ let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home);
+ 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, content_type) = http_get_with_ct(&cb_url);
+ assert_eq!(status, 500);
+ assert!(body.contains("Token exchange failed"));
+ assert!(
+ content_type
+ .unwrap_or_default()
+ .to_ascii_lowercase()
+ .starts_with("text/html")
+ );
+ let _ = ureq::get(&format!("http://127.0.0.1:{port}/success")).call();
+ handle.join().unwrap().unwrap();
+}
+
+// 5) Credit redemption errors do not block success
+#[tokio::test]
+async fn login_server_credit_redemption_best_effort() {
+ // Mock behavior success for token endpoints
+ let server = start_mock_oauth_server(MockBehavior::Success).await;
+ let codex_home = TempDir::new().unwrap();
+ let issuer = server.uri();
+ let (handle, port) = spawn_login_server_and_wait(issuer, &codex_home);
+ 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().unwrap();
+ // auth.json exists
+ assert!(codex_login::get_auth_file(codex_home.path()).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));
+ }
+}
diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs
index 6276552849..1faa1fe202 100644
--- a/codex-rs/tui/src/onboarding/auth.rs
+++ b/codex-rs/tui/src/onboarding/auth.rs
@@ -38,19 +38,20 @@ pub(crate) enum SignInState {
}
#[derive(Debug)]
-/// Used to manage the lifecycle of SpawnedLogin and FrameTicker and ensure they get cleaned up.
+/// Used to manage lifecycle of in-process login server and UI ticker.
pub(crate) struct ContinueInBrowserState {
- login_child: Option<codex_login::SpawnedLogin>,
+ join_handle: Option<std::thread::JoinHandle<std::io::Result<()>>>,
+ cancel_tx: Option<std::sync::mpsc::Sender<()>>,
_frame_ticker: Option<FrameTicker>,
+ url_shared: std::sync::Arc<std::sync::Mutex<Option<String>>>,
}
impl Drop for ContinueInBrowserState {
fn drop(&mut self) {
- if let Some(child) = &self.login_child {
- if let Ok(mut locked) = child.child.lock() {
- // Best-effort terminate and reap the child to avoid zombies.
- let _ = locked.kill();
- let _ = locked.wait();
- }
+ if let Some(tx) = self.cancel_tx.take() {
+ let _ = tx.send(());
+ }
+ if let Some(handle) = self.join_handle.take() {
+ let _ = handle.join();
}
}
}
@@ -186,11 +187,8 @@ impl AuthModeWidget {
let mut lines = vec![Line::from(spans), Line::from("")];
if let SignInState::ChatGptContinueInBrowser(state) = &self.sign_in_state {
- if let Some(url) = state
- .login_child
- .as_ref()
- .and_then(|child| child.get_login_url())
- {
+ let url_opt = state.url_shared.lock().ok().and_then(|g| g.clone());
+ if let Some(url) = url_opt {
lines.push(Line::from(" If the link doesn't open automatically, open the following link to authenticate:"));
lines.push(Line::from(vec![
Span::raw(" "),
@@ -291,22 +289,16 @@ impl AuthModeWidget {
fn start_chatgpt_login(&mut self) {
self.error = None;
- match codex_login::spawn_login_with_chatgpt(&self.codex_home) {
- Ok(child) => {
- self.spawn_completion_poller(child.clone());
- self.sign_in_state =
- SignInState::ChatGptContinueInBrowser(ContinueInBrowserState {
- login_child: Some(child),
- _frame_ticker: Some(FrameTicker::new(self.event_tx.clone())),
- });
- self.event_tx.send(AppEvent::RequestRedraw);
- }
- Err(e) => {
- self.sign_in_state = SignInState::PickMode;
- self.error = Some(e.to_string());
- self.event_tx.send(AppEvent::RequestRedraw);
- }
- }
+ let (handle, status_rx, cancel_tx) = codex_login::spawn_login_in_process(&self.codex_home);
+ let url_shared = std::sync::Arc::new(std::sync::Mutex::new(None));
+ self.spawn_status_listener(status_rx, url_shared.clone());
+ self.sign_in_state = SignInState::ChatGptContinueInBrowser(ContinueInBrowserState {
+ join_handle: Some(handle),
+ cancel_tx: Some(cancel_tx),
+ _frame_ticker: Some(FrameTicker::new(self.event_tx.clone())),
+ url_shared,
+ });
+ self.event_tx.send(AppEvent::RequestRedraw);
}
/// TODO: Read/write from the correct hierarchy config overrides + auth json + OPENAI_API_KEY.
@@ -319,37 +311,26 @@ impl AuthModeWidget {
self.event_tx.send(AppEvent::RequestRedraw);
}
- fn spawn_completion_poller(&self, child: codex_login::SpawnedLogin) {
- let child_arc = child.child.clone();
- let stderr_buf = child.stderr.clone();
+ fn spawn_status_listener(
+ &self,
+ status_rx: std::sync::mpsc::Receiver<codex_login::LoginServerStatus>,
+ url_shared: std::sync::Arc<std::sync::Mutex<Option<String>>>,
+ ) {
let event_tx = self.event_tx.clone();
std::thread::spawn(move || {
- loop {
- let done = {
- if let Ok(mut locked) = child_arc.lock() {
- match locked.try_wait() {
- Ok(Some(status)) => Some(status.success()),
- Ok(None) => None,
- Err(_) => Some(false),
+ while let Ok(status) = status_rx.recv() {
+ match status {
+ codex_login::LoginServerStatus::Url(u) => {
+ if let Ok(mut g) = url_shared.lock() {
+ *g = Some(u);
}
- } else {
- Some(false)
+ event_tx.send(AppEvent::RequestRedraw);
}
- };
- if let Some(success) = done {
- if success {
+ codex_login::LoginServerStatus::Completed => {
event_tx.send(AppEvent::OnboardingAuthComplete(Ok(())));
- } else {
- let err = stderr_buf
- .lock()
- .ok()
- .and_then(|b| String::from_utf8(b.clone()).ok())
- .unwrap_or_else(|| "login_with_chatgpt subprocess failed".to_string());
- event_tx.send(AppEvent::OnboardingAuthComplete(Err(err)));
+ break;
}
- break;
}
- std::thread::sleep(std::time::Duration::from_millis(250));
}
});
}
Review Comments
codex-rs/login/Cargo.toml
- Created: 2025-08-11 21:59:21 UTC | Link: https://github.com/openai/codex/pull/2047#discussion_r2268111038
@@ -21,7 +24,16 @@ tokio = { version = "1", features = [
"rt-multi-thread",
"signal",
] }
+hex = "0.4"
alpha-sort?
Again, I would like to get to a place where we are not bundling a small webserver with Codex CLI...
- Created: 2025-08-11 22:13:16 UTC | Link: https://github.com/openai/codex/pull/2047#discussion_r2268129610
@@ -6,10 +6,13 @@ version = { workspace = true }
[lints]
workspace = true
+[features]
+http-e2e-tests = []
Introducing a
featuremeans that we have to compile this crate under multiple permutations. This does not seem to be saving us all that much given that none of the new dependencies are limited to this feature.
codex-rs/login/src/auth.rs
- Created: 2025-08-11 22:02:30 UTC | Link: https://github.com/openai/codex/pull/2047#discussion_r2268115066
@@ -0,0 +1,208 @@
+use chrono::Utc;
This PR would be easier to review if the work to split up
lib.rswere done in its own PR.
codex-rs/login/src/server.rs
- Created: 2025-08-11 22:09:35 UTC | Link: https://github.com/openai/codex/pull/2047#discussion_r2268124185
@@ -0,0 +1,375 @@
+//
delete?
codex-rs/login/src/token_data.rs
- Created: 2025-08-11 22:10:55 UTC | Link: https://github.com/openai/codex/pull/2047#discussion_r2268126635
@@ -18,8 +18,6 @@ pub struct TokenData {
}
impl TokenData {
- /// Returns true if this is a plan that should use the traditional
I think the comments that were here were useful.