1use std::path::{Path, PathBuf};
48
49pub const AUTH_ENV_VARS: [&str; 3] = ["OPENAI_API_KEY", "CODEX_API_KEY", "CODEX_ACCESS_TOKEN"];
54
55#[derive(Debug, Clone, PartialEq, Eq)]
57#[non_exhaustive]
58pub enum AuthStrategy {
59 None,
61
62 Stored {
64 mode: Option<String>,
67 },
68
69 Environment {
71 vars: Vec<&'static str>,
73 },
74
75 Mixed {
79 vars: Vec<&'static str>,
81 stored_mode: Option<String>,
83 },
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct AuthStatus {
89 pub strategy: AuthStrategy,
91 pub codex_home: PathBuf,
93 pub auth_file: PathBuf,
95}
96
97impl AuthStatus {
98 #[must_use]
103 pub fn is_configured(&self) -> bool {
104 self.strategy != AuthStrategy::None
105 }
106}
107
108#[must_use]
112pub fn detect() -> AuthStatus {
113 detect_with(|key| std::env::var(key).ok())
114}
115
116#[must_use]
120pub fn detect_in(codex_home: impl AsRef<Path>) -> AuthStatus {
121 let home = codex_home.as_ref().to_path_buf();
122 detect_with(move |key| {
123 if key == "CODEX_HOME" {
124 return Some(home.to_string_lossy().into_owned());
125 }
126 std::env::var(key).ok()
127 })
128}
129
130pub(crate) fn detect_with(env: impl Fn(&str) -> Option<String>) -> AuthStatus {
136 let codex_home = crate::codex_home::resolve(&env);
137 let auth_file = codex_home.join("auth.json");
138
139 let vars: Vec<&'static str> = AUTH_ENV_VARS
140 .iter()
141 .copied()
142 .filter(|key| env(key).is_some_and(|value| !value.trim().is_empty()))
143 .collect();
144
145 let stored = read_stored_mode(&auth_file);
146
147 let strategy = match (vars.is_empty(), stored) {
148 (true, None) => AuthStrategy::None,
149 (true, Some(mode)) => AuthStrategy::Stored { mode },
150 (false, None) => AuthStrategy::Environment { vars },
151 (false, Some(stored_mode)) => AuthStrategy::Mixed { vars, stored_mode },
152 };
153
154 AuthStatus {
155 strategy,
156 codex_home,
157 auth_file,
158 }
159}
160
161fn read_stored_mode(auth_file: &Path) -> Option<Option<String>> {
168 let contents = std::fs::read_to_string(auth_file).ok()?;
169 let parsed: serde_json::Value = serde_json::from_str(&contents).ok()?;
170 let object = parsed.as_object()?;
171
172 let has_credential = object.contains_key("tokens") || object.contains_key("OPENAI_API_KEY");
174 let mode = object
175 .get("auth_mode")
176 .and_then(serde_json::Value::as_str)
177 .map(str::to_string);
178
179 if mode.is_none() && !has_credential {
180 return None;
181 }
182 Some(mode)
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 fn write_auth(dir: &Path, contents: &str) {
190 std::fs::create_dir_all(dir).unwrap();
191 std::fs::write(dir.join("auth.json"), contents).unwrap();
192 }
193
194 fn temp_dir(label: &str) -> PathBuf {
195 let dir =
196 std::env::temp_dir().join(format!("codex-wrapper-auth-{}-{label}", std::process::id()));
197 let _ = std::fs::remove_dir_all(&dir);
198 std::fs::create_dir_all(&dir).unwrap();
199 dir
200 }
201
202 fn with_home(home: &Path, extra: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
203 let home = home.to_path_buf();
204 let extra: Vec<(String, String)> = extra
205 .iter()
206 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
207 .collect();
208 move |key| {
209 if key == "CODEX_HOME" {
210 return Some(home.to_string_lossy().into_owned());
211 }
212 extra.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone())
213 }
214 }
215
216 #[test]
217 fn nothing_configured() {
218 let home = temp_dir("none");
219 let status = detect_with(with_home(&home, &[]));
220 assert_eq!(status.strategy, AuthStrategy::None);
221 assert!(!status.is_configured());
222 assert_eq!(status.auth_file, home.join("auth.json"));
223 }
224
225 #[test]
228 fn a_stored_chatgpt_login() {
229 let home = temp_dir("chatgpt");
230 write_auth(
231 &home,
232 r#"{"OPENAI_API_KEY":null,"auth_mode":"chatgpt","last_refresh":"2026-08-06T00:00:00Z","tokens":{"id_token":"x"}}"#,
233 );
234
235 let status = detect_with(with_home(&home, &[]));
236 assert_eq!(
237 status.strategy,
238 AuthStrategy::Stored {
239 mode: Some("chatgpt".into())
240 }
241 );
242 assert!(status.is_configured());
243 }
244
245 #[test]
246 fn a_stored_api_key_login() {
247 let home = temp_dir("apikey");
248 write_auth(
249 &home,
250 r#"{"OPENAI_API_KEY":"sk-secret","auth_mode":"apikey"}"#,
251 );
252
253 let status = detect_with(with_home(&home, &[]));
254 assert_eq!(
255 status.strategy,
256 AuthStrategy::Stored {
257 mode: Some("apikey".into())
258 }
259 );
260 }
261
262 #[test]
265 fn a_credential_value_is_never_exposed() {
266 let home = temp_dir("secret");
267 write_auth(
268 &home,
269 r#"{"OPENAI_API_KEY":"sk-super-secret","auth_mode":"apikey"}"#,
270 );
271
272 let status = detect_with(with_home(&home, &[("OPENAI_API_KEY", "sk-env-secret")]));
273 let rendered = format!("{status:?}");
274
275 assert!(!rendered.contains("sk-super-secret"), "{rendered}");
276 assert!(!rendered.contains("sk-env-secret"), "{rendered}");
277 assert!(rendered.contains("OPENAI_API_KEY"), "{rendered}");
279 }
280
281 #[test]
282 fn each_supported_env_var_is_detected() {
283 let home = temp_dir("envvars");
284 for var in AUTH_ENV_VARS {
285 let status = detect_with(with_home(&home, &[(var, "value")]));
286 assert_eq!(
287 status.strategy,
288 AuthStrategy::Environment { vars: vec![var] },
289 "{var} was not detected"
290 );
291 }
292 }
293
294 #[test]
297 fn both_sources_report_as_mixed() {
298 let home = temp_dir("mixed");
299 write_auth(
300 &home,
301 r#"{"auth_mode":"chatgpt","tokens":{"id_token":"x"}}"#,
302 );
303
304 let status = detect_with(with_home(&home, &[("OPENAI_API_KEY", "sk-env")]));
305 assert_eq!(
306 status.strategy,
307 AuthStrategy::Mixed {
308 vars: vec!["OPENAI_API_KEY"],
309 stored_mode: Some("chatgpt".into()),
310 }
311 );
312 }
313
314 #[test]
315 fn an_empty_env_var_is_not_a_credential() {
316 let home = temp_dir("blank");
317 let status = detect_with(with_home(&home, &[("OPENAI_API_KEY", " ")]));
318 assert_eq!(status.strategy, AuthStrategy::None);
319 }
320
321 #[test]
324 fn a_malformed_auth_file_is_not_a_login() {
325 let home = temp_dir("malformed");
326 write_auth(&home, "not json at all");
327 assert_eq!(
328 detect_with(with_home(&home, &[])).strategy,
329 AuthStrategy::None
330 );
331
332 write_auth(&home, r#"{"unrelated":true}"#);
333 assert_eq!(
334 detect_with(with_home(&home, &[])).strategy,
335 AuthStrategy::None
336 );
337 }
338
339 #[test]
340 fn codex_home_defaults_under_the_user_home() {
341 let status = detect_with(|key| match key {
342 "HOME" => Some("/home/someone".into()),
343 _ => None,
344 });
345 assert_eq!(status.codex_home, PathBuf::from("/home/someone/.codex"));
346 assert_eq!(
347 status.auth_file,
348 PathBuf::from("/home/someone/.codex/auth.json")
349 );
350 }
351
352 #[test]
353 fn an_empty_codex_home_falls_back_to_the_default() {
354 let status = detect_with(|key| match key {
355 "CODEX_HOME" => Some(String::new()),
356 "HOME" => Some("/home/someone".into()),
357 _ => None,
358 });
359 assert_eq!(status.codex_home, PathBuf::from("/home/someone/.codex"));
360 }
361}