Compare commits

...

24 Commits

Author SHA1 Message Date
easong-openai
339c114cb0 better? 2025-08-13 17:23:10 -07:00
easong-openai
7e4cb46cd0 merge 2025-08-13 14:27:03 -07:00
easong-openai
b791c1cc3e in-process server 2025-08-13 14:08:44 -07:00
Eason Goodale
d357fb7dba remove write_new_auth_json 2025-08-12 00:25:50 -07:00
Eason Goodale
423f92a71b remove old python script 2025-08-12 00:21:27 -07:00
Eason Goodale
701c2f0802 write_auth_json 2025-08-12 00:20:54 -07:00
Eason Goodale
7cad669e09 cleanup 2025-08-11 23:41:41 -07:00
Eason Goodale
e5b2703374 fmt 2025-08-11 23:36:16 -07:00
Eason Goodale
96225c0d8f alpha sort 2025-08-11 23:35:54 -07:00
Eason Goodale
04ac0839e7 fmt 2025-08-11 18:29:26 -07:00
Eason Goodale
f7025fc317 fmt 2025-08-11 18:29:14 -07:00
Eason Goodale
90db5317d7 feedback, error page 2025-08-11 18:12:38 -07:00
Eason Goodale
47dcb4377c merge 2025-08-10 01:47:12 -07:00
Eason Goodale
df17f64784 fmt 2025-08-08 19:24:21 -07:00
Eason Goodale
b1a93a06f0 reuse 2025-08-08 19:23:56 -07:00
Eason Goodale
0a47c9f5d9 wiremock 2025-08-08 19:22:15 -07:00
Eason Goodale
865bd3a773 port discovery 2025-08-08 19:05:47 -07:00
Eason Goodale
5477eb9c5e comments 2025-08-08 18:22:31 -07:00
Eason Goodale
7c7ccdd72b fmt, clippy 2025-08-08 18:18:59 -07:00
Eason Goodale
b668483951 simplify? 2025-08-08 18:14:31 -07:00
Eason Goodale
45514562d9 cleaning 2025-08-08 17:23:36 -07:00
Eason Goodale
0ce885cb98 works, fmt, and clippy 2025-08-08 15:33:58 -07:00
Eason Goodale
3ba4ac8ee6 split 2025-08-08 14:08:24 -07:00
Eason Goodale
339a8b69f4 initial 2025-08-08 13:43:30 -07:00
21 changed files with 2864 additions and 1783 deletions

270
codex-rs/Cargo.lock generated
View File

@@ -203,6 +203,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "ascii"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
[[package]]
name = "ascii-canvas"
version = "3.0.0"
@@ -481,6 +487,12 @@ dependencies = [
"shlex",
]
[[package]]
name = "cesu8"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
[[package]]
name = "cfg-expr"
version = "0.15.8"
@@ -518,6 +530,12 @@ dependencies = [
"windows-link",
]
[[package]]
name = "chunked_transfer"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
[[package]]
name = "clap"
version = "4.5.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"

View File

@@ -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);
}
}
}
}

View File

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

209
codex-rs/login/src/auth.rs Normal file
View File

@@ -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())
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

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

View File

@@ -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");
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;
const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
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;

View File

@@ -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"));
}

View File

@@ -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()

View File

@@ -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,
}
}

View File

@@ -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()
)))
}
}

View File

@@ -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,
})
}
//

View File

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

View File

@@ -0,0 +1,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)
}

View File

@@ -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)]

View File

@@ -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(())
}

View File

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

View File

@@ -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"));
}

View File

@@ -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));
}
}

View File

@@ -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));
}
});
}