use std::path::{Path, PathBuf};
pub const AUTH_ENV_VARS: [&str; 3] = ["OPENAI_API_KEY", "CODEX_API_KEY", "CODEX_ACCESS_TOKEN"];
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum AuthStrategy {
None,
Stored {
mode: Option<String>,
},
Environment {
vars: Vec<&'static str>,
},
Mixed {
vars: Vec<&'static str>,
stored_mode: Option<String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthStatus {
pub strategy: AuthStrategy,
pub codex_home: PathBuf,
pub auth_file: PathBuf,
}
impl AuthStatus {
#[must_use]
pub fn is_configured(&self) -> bool {
self.strategy != AuthStrategy::None
}
}
#[must_use]
pub fn detect() -> AuthStatus {
detect_with(|key| std::env::var(key).ok())
}
#[must_use]
pub fn detect_in(codex_home: impl AsRef<Path>) -> AuthStatus {
let home = codex_home.as_ref().to_path_buf();
detect_with(move |key| {
if key == "CODEX_HOME" {
return Some(home.to_string_lossy().into_owned());
}
std::env::var(key).ok()
})
}
pub(crate) fn detect_with(env: impl Fn(&str) -> Option<String>) -> AuthStatus {
let codex_home = crate::codex_home::resolve(&env);
let auth_file = codex_home.join("auth.json");
let vars: Vec<&'static str> = AUTH_ENV_VARS
.iter()
.copied()
.filter(|key| env(key).is_some_and(|value| !value.trim().is_empty()))
.collect();
let stored = read_stored_mode(&auth_file);
let strategy = match (vars.is_empty(), stored) {
(true, None) => AuthStrategy::None,
(true, Some(mode)) => AuthStrategy::Stored { mode },
(false, None) => AuthStrategy::Environment { vars },
(false, Some(stored_mode)) => AuthStrategy::Mixed { vars, stored_mode },
};
AuthStatus {
strategy,
codex_home,
auth_file,
}
}
fn read_stored_mode(auth_file: &Path) -> Option<Option<String>> {
let contents = std::fs::read_to_string(auth_file).ok()?;
let parsed: serde_json::Value = serde_json::from_str(&contents).ok()?;
let object = parsed.as_object()?;
let has_credential = object.contains_key("tokens") || object.contains_key("OPENAI_API_KEY");
let mode = object
.get("auth_mode")
.and_then(serde_json::Value::as_str)
.map(str::to_string);
if mode.is_none() && !has_credential {
return None;
}
Some(mode)
}
#[cfg(test)]
mod tests {
use super::*;
fn write_auth(dir: &Path, contents: &str) {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join("auth.json"), contents).unwrap();
}
fn temp_dir(label: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("codex-wrapper-auth-{}-{label}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn with_home(home: &Path, extra: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
let home = home.to_path_buf();
let extra: Vec<(String, String)> = extra
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect();
move |key| {
if key == "CODEX_HOME" {
return Some(home.to_string_lossy().into_owned());
}
extra.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone())
}
}
#[test]
fn nothing_configured() {
let home = temp_dir("none");
let status = detect_with(with_home(&home, &[]));
assert_eq!(status.strategy, AuthStrategy::None);
assert!(!status.is_configured());
assert_eq!(status.auth_file, home.join("auth.json"));
}
#[test]
fn a_stored_chatgpt_login() {
let home = temp_dir("chatgpt");
write_auth(
&home,
r#"{"OPENAI_API_KEY":null,"auth_mode":"chatgpt","last_refresh":"2026-08-06T00:00:00Z","tokens":{"id_token":"x"}}"#,
);
let status = detect_with(with_home(&home, &[]));
assert_eq!(
status.strategy,
AuthStrategy::Stored {
mode: Some("chatgpt".into())
}
);
assert!(status.is_configured());
}
#[test]
fn a_stored_api_key_login() {
let home = temp_dir("apikey");
write_auth(
&home,
r#"{"OPENAI_API_KEY":"sk-secret","auth_mode":"apikey"}"#,
);
let status = detect_with(with_home(&home, &[]));
assert_eq!(
status.strategy,
AuthStrategy::Stored {
mode: Some("apikey".into())
}
);
}
#[test]
fn a_credential_value_is_never_exposed() {
let home = temp_dir("secret");
write_auth(
&home,
r#"{"OPENAI_API_KEY":"sk-super-secret","auth_mode":"apikey"}"#,
);
let status = detect_with(with_home(&home, &[("OPENAI_API_KEY", "sk-env-secret")]));
let rendered = format!("{status:?}");
assert!(!rendered.contains("sk-super-secret"), "{rendered}");
assert!(!rendered.contains("sk-env-secret"), "{rendered}");
assert!(rendered.contains("OPENAI_API_KEY"), "{rendered}");
}
#[test]
fn each_supported_env_var_is_detected() {
let home = temp_dir("envvars");
for var in AUTH_ENV_VARS {
let status = detect_with(with_home(&home, &[(var, "value")]));
assert_eq!(
status.strategy,
AuthStrategy::Environment { vars: vec![var] },
"{var} was not detected"
);
}
}
#[test]
fn both_sources_report_as_mixed() {
let home = temp_dir("mixed");
write_auth(
&home,
r#"{"auth_mode":"chatgpt","tokens":{"id_token":"x"}}"#,
);
let status = detect_with(with_home(&home, &[("OPENAI_API_KEY", "sk-env")]));
assert_eq!(
status.strategy,
AuthStrategy::Mixed {
vars: vec!["OPENAI_API_KEY"],
stored_mode: Some("chatgpt".into()),
}
);
}
#[test]
fn an_empty_env_var_is_not_a_credential() {
let home = temp_dir("blank");
let status = detect_with(with_home(&home, &[("OPENAI_API_KEY", " ")]));
assert_eq!(status.strategy, AuthStrategy::None);
}
#[test]
fn a_malformed_auth_file_is_not_a_login() {
let home = temp_dir("malformed");
write_auth(&home, "not json at all");
assert_eq!(
detect_with(with_home(&home, &[])).strategy,
AuthStrategy::None
);
write_auth(&home, r#"{"unrelated":true}"#);
assert_eq!(
detect_with(with_home(&home, &[])).strategy,
AuthStrategy::None
);
}
#[test]
fn codex_home_defaults_under_the_user_home() {
let status = detect_with(|key| match key {
"HOME" => Some("/home/someone".into()),
_ => None,
});
assert_eq!(status.codex_home, PathBuf::from("/home/someone/.codex"));
assert_eq!(
status.auth_file,
PathBuf::from("/home/someone/.codex/auth.json")
);
}
#[test]
fn an_empty_codex_home_falls_back_to_the_default() {
let status = detect_with(|key| match key {
"CODEX_HOME" => Some(String::new()),
"HOME" => Some("/home/someone".into()),
_ => None,
});
assert_eq!(status.codex_home, PathBuf::from("/home/someone/.codex"));
}
}