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 token_data; const SOURCE_FOR_PYTHON_SERVER: &str = include_str!("./login_with_chatgpt.py"); 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, auth_dot_json: Arc>>, 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> { load_auth(codex_home, true) } pub async fn get_token_data(&self) -> Result { let auth_dot_json: Option = 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 { 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 { self.get_current_token_data() .and_then(|t| t.account_id.clone()) } pub fn get_plan_type(&self) -> Option { 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 { #[expect(clippy::unwrap_used)] self.auth_dot_json.lock().unwrap().clone() } fn get_current_token_data(&self) -> Option { 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> { // 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 ` 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 { 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 { 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>, pub stdout: Arc>>, pub stderr: Arc>>, } 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 { 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>>, } impl Write for AppendWriter { fn write(&mut self, data: &[u8]) -> io::Result { 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(mut reader: R, buf: Arc>>) { 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 { 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 { // 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 { 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, refresh_token: Option, ) -> std::io::Result { 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 { 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::() .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, refresh_token: Option, } /// 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, #[serde(default, skip_serializing_if = "Option::is_none")] pub tokens: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub last_refresh: Option>, } #[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, 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(()) } }