Skip to main content

codex_wrapper/
auth.rs

1//! Which credential the CLI would use, without spawning it.
2//!
3//! A cheap synchronous pre-flight check for health endpoints and for failing
4//! fast with a clear message instead of an opaque non-zero exit. It answers a
5//! different question from
6//! [`LoginStatusCommand`](crate::LoginStatusCommand): that one asks the CLI
7//! whether a stored credential is currently valid, this one asks which
8//! credential the CLI would pick. Keep both.
9//!
10//! Nothing here reads or returns a credential value. Environment variables are
11//! reported by name, and stored credentials by mode and presence.
12//!
13//! # How this was determined
14//!
15//! Read off `codex-cli` 0.145.0 rather than assumed, using `codex doctor`,
16//! which reports its own auth resolution. Each state below is a captured run:
17//!
18//! | Setup | What the CLI reports |
19//! |---|---|
20//! | neither | `no Codex credentials were found` |
21//! | `auth.json` only | `auth is configured`, `stored auth mode chatgpt` |
22//! | env var only | `auth is provided by environment`, `auth mode none` |
23//! | both | `mixed auth signals: ChatGPT login plus API key env var; HTTP reachability uses API-key mode` |
24//!
25//! The last row is the precedence: with both present the environment key is
26//! what reaches the API, and the CLI itself flags the combination as a
27//! warning. [`AuthStrategy::Mixed`] preserves that rather than silently
28//! picking a winner.
29//!
30//! `codex login status` is not the authority here: it reports only stored
31//! logins, and says "Not logged in" when an environment variable would in fact
32//! be used.
33//!
34//! # Example
35//!
36//! ```no_run
37//! use codex_wrapper::auth::{self, AuthStrategy};
38//!
39//! let status = auth::detect();
40//! match &status.strategy {
41//!     AuthStrategy::None => eprintln!("run `codex login` first"),
42//!     AuthStrategy::Mixed { .. } => eprintln!("both configured; the env key wins"),
43//!     other => println!("will authenticate via {other:?}"),
44//! }
45//! ```
46
47use std::path::{Path, PathBuf};
48
49/// Environment variables the CLI accepts a credential from.
50///
51/// Taken from the 0.145.0 binary, and confirmed for `OPENAI_API_KEY` with a
52/// captured `codex doctor` run reporting `auth env vars present`.
53pub const AUTH_ENV_VARS: [&str; 3] = ["OPENAI_API_KEY", "CODEX_API_KEY", "CODEX_ACCESS_TOKEN"];
54
55/// Which credential the CLI would use.
56#[derive(Debug, Clone, PartialEq, Eq)]
57#[non_exhaustive]
58pub enum AuthStrategy {
59    /// Nothing configured. The CLI reports `no Codex credentials were found`.
60    None,
61
62    /// A stored login at `$CODEX_HOME/auth.json`.
63    Stored {
64        /// The file's `auth_mode`, `chatgpt` and `apikey` being the observed
65        /// values. `None` if the file has no such field.
66        mode: Option<String>,
67    },
68
69    /// One or more auth environment variables are set, and no stored login.
70    Environment {
71        /// Names only. Values are never read.
72        vars: Vec<&'static str>,
73    },
74
75    /// Both are present, which the CLI warns about as "mixed auth signals".
76    ///
77    /// The environment key is what reaches the API.
78    Mixed {
79        /// Names only.
80        vars: Vec<&'static str>,
81        /// The stored login's mode, which is not what gets used.
82        stored_mode: Option<String>,
83    },
84}
85
86/// The result of [`detect`].
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct AuthStatus {
89    /// Which credential the CLI would use.
90    pub strategy: AuthStrategy,
91    /// The resolved `CODEX_HOME`.
92    pub codex_home: PathBuf,
93    /// Where a stored login would live, whether or not it exists.
94    pub auth_file: PathBuf,
95}
96
97impl AuthStatus {
98    /// Whether the CLI would find any credential at all.
99    ///
100    /// True does not mean the credential is valid: nothing here contacts the
101    /// API. Use [`LoginStatusCommand`](crate::LoginStatusCommand) for that.
102    #[must_use]
103    pub fn is_configured(&self) -> bool {
104        self.strategy != AuthStrategy::None
105    }
106}
107
108/// Detect how the CLI would authenticate, from the current environment.
109///
110/// Honors `CODEX_HOME`, defaulting to `~/.codex`.
111#[must_use]
112pub fn detect() -> AuthStatus {
113    detect_with(|key| std::env::var(key).ok())
114}
115
116/// [`detect`], but against an explicit `CODEX_HOME`.
117///
118/// Environment variables are still read from the process.
119#[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
130/// The whole of the resolution, over an injected environment.
131///
132/// Taking a lookup rather than reading the process environment keeps this
133/// testable without mutating global state, which would make the tests race
134/// each other.
135pub(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
161/// `Some(mode)` when a stored login exists, where the inner `Option` is its
162/// `auth_mode` field. `None` when there is no readable credential file.
163///
164/// An unreadable or malformed file counts as no stored login: the CLI cannot
165/// use it either, and reporting it as usable would be worse than reporting
166/// nothing.
167fn 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    // A file with neither a mode nor any credential field is not a login.
173    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    /// The shape of a real chatgpt login: the field names come from an actual
226    /// `~/.codex/auth.json`, with the values replaced.
227    #[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    /// Nothing may expose the credential itself, including through Debug,
263    /// which is where a health endpoint would most easily leak it.
264    #[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        // The variable's name is reported, which is the useful part.
278        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    /// The CLI warns about this combination rather than silently preferring
295    /// one, and reports that the environment key is what reaches the API.
296    #[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    /// A file the CLI cannot use must not read as configured, or a pre-flight
322    /// check passes and the run fails anyway.
323    #[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}