Skip to main content

codex_cli/auth/
status.rs

1use anyhow::Result;
2use serde::Serialize;
3use serde_json::Value;
4use std::path::{Path, PathBuf};
5
6use crate::auth;
7use crate::auth::output::{self, AuthStatusResult};
8use crate::json;
9use crate::paths;
10use nils_common::fs;
11
12#[derive(Debug, Clone)]
13pub struct ActiveAuthStatus {
14    pub auth_file: Option<PathBuf>,
15    pub exists: bool,
16    pub readable: bool,
17    pub parse_ok: bool,
18    pub authenticated: bool,
19    pub prompt_segment_authenticated: bool,
20    pub auth_kind: Option<AuthKind>,
21    pub has_oauth_access_token: bool,
22    pub has_oauth_refresh_token: bool,
23    pub has_api_key: bool,
24    pub last_refresh: Option<String>,
25    pub identity: Option<String>,
26    pub matched_secret: Option<String>,
27    pub match_mode: Option<SecretMatchMode>,
28    pub reason: AuthStatusReason,
29}
30
31#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize)]
32#[serde(rename_all = "kebab-case")]
33pub enum AuthKind {
34    ChatgptOauth,
35    OpenaiApiKey,
36}
37
38impl AuthKind {
39    fn as_str(self) -> &'static str {
40        match self {
41            Self::ChatgptOauth => "chatgpt-oauth",
42            Self::OpenaiApiKey => "openai-api-key",
43        }
44    }
45}
46
47#[derive(Debug, Copy, Clone, Eq, PartialEq)]
48pub enum SecretMatchMode {
49    Exact,
50    Identity,
51}
52
53impl SecretMatchMode {
54    fn as_str(self) -> &'static str {
55        match self {
56            Self::Exact => "exact",
57            Self::Identity => "identity",
58        }
59    }
60}
61
62#[derive(Debug, Copy, Clone, Eq, PartialEq)]
63pub enum AuthStatusReason {
64    Ready,
65    AuthFileNotConfigured,
66    AuthFileNotFound,
67    AuthFileReadFailed,
68    AuthFileInvalidJson,
69    CredentialsMissing,
70}
71
72impl AuthStatusReason {
73    pub fn as_str(self) -> &'static str {
74        match self {
75            Self::Ready => "ready",
76            Self::AuthFileNotConfigured => "auth-file-not-configured",
77            Self::AuthFileNotFound => "auth-file-not-found",
78            Self::AuthFileReadFailed => "auth-file-read-failed",
79            Self::AuthFileInvalidJson => "auth-file-invalid-json",
80            Self::CredentialsMissing => "credentials-missing",
81        }
82    }
83}
84
85pub fn run() -> Result<i32> {
86    run_with_json(false)
87}
88
89pub fn run_with_json(output_json: bool) -> Result<i32> {
90    let status = inspect_active_auth();
91    if output_json {
92        output::emit_result("auth status", AuthStatusResult::from(&status))?;
93    } else {
94        print_text_status(&status);
95    }
96    Ok(0)
97}
98
99pub fn inspect_active_auth() -> ActiveAuthStatus {
100    let Some(auth_file) = paths::resolve_auth_file() else {
101        return ActiveAuthStatus {
102            auth_file: None,
103            exists: false,
104            readable: false,
105            parse_ok: false,
106            authenticated: false,
107            prompt_segment_authenticated: false,
108            auth_kind: None,
109            has_oauth_access_token: false,
110            has_oauth_refresh_token: false,
111            has_api_key: false,
112            last_refresh: None,
113            identity: None,
114            matched_secret: None,
115            match_mode: None,
116            reason: AuthStatusReason::AuthFileNotConfigured,
117        };
118    };
119
120    if !auth_file.is_file() {
121        return inactive_with_file(auth_file, AuthStatusReason::AuthFileNotFound);
122    }
123
124    let raw = match std::fs::read_to_string(&auth_file) {
125        Ok(raw) => raw,
126        Err(_) => {
127            let mut status = inactive_with_file(auth_file, AuthStatusReason::AuthFileReadFailed);
128            status.exists = true;
129            return status;
130        }
131    };
132
133    let value: Value = match serde_json::from_str(&raw) {
134        Ok(value) => value,
135        Err(_) => {
136            let mut status = inactive_with_file(auth_file, AuthStatusReason::AuthFileInvalidJson);
137            status.exists = true;
138            status.readable = true;
139            return status;
140        }
141    };
142
143    let has_oauth_access_token = has_non_empty_string(&value, &["tokens", "access_token"])
144        || has_non_empty_string(&value, &["access_token"]);
145    let has_oauth_refresh_token = has_real_refresh_token(&value, &["tokens", "refresh_token"])
146        || has_real_refresh_token(&value, &["refresh_token"]);
147    let has_api_key = has_non_empty_string(&value, &["OPENAI_API_KEY"])
148        || has_non_empty_string(&value, &["api_key"])
149        || has_non_empty_string(&value, &["openai_api_key"])
150        || has_non_empty_string(&value, &["tokens", "api_key"])
151        || has_non_empty_string(&value, &["tokens", "openai_api_key"]);
152
153    let auth_kind = if has_oauth_access_token || has_oauth_refresh_token {
154        Some(AuthKind::ChatgptOauth)
155    } else if has_api_key {
156        Some(AuthKind::OpenaiApiKey)
157    } else {
158        None
159    };
160
161    let authenticated = auth_kind.is_some();
162    let prompt_segment_authenticated = has_oauth_access_token;
163    let (matched_secret, match_mode) = inspect_matching_secret(&auth_file);
164
165    ActiveAuthStatus {
166        auth_file: Some(auth_file.clone()),
167        exists: true,
168        readable: true,
169        parse_ok: true,
170        authenticated,
171        prompt_segment_authenticated,
172        auth_kind,
173        has_oauth_access_token,
174        has_oauth_refresh_token,
175        has_api_key,
176        last_refresh: json::string_at(&value, &["last_refresh"]),
177        identity: auth::identity_from_auth_file(&auth_file).ok().flatten(),
178        matched_secret,
179        match_mode,
180        reason: if authenticated {
181            AuthStatusReason::Ready
182        } else {
183            AuthStatusReason::CredentialsMissing
184        },
185    }
186}
187
188impl AuthStatusResult {
189    pub fn from(status: &ActiveAuthStatus) -> Self {
190        Self {
191            auth_file: status
192                .auth_file
193                .as_ref()
194                .map(|path| path.display().to_string()),
195            exists: status.exists,
196            readable: status.readable,
197            parse_ok: status.parse_ok,
198            authenticated: status.authenticated,
199            prompt_segment_authenticated: status.prompt_segment_authenticated,
200            auth_kind: status.auth_kind.map(|kind| kind.as_str().to_string()),
201            has_oauth_access_token: status.has_oauth_access_token,
202            has_oauth_refresh_token: status.has_oauth_refresh_token,
203            has_api_key: status.has_api_key,
204            last_refresh: status.last_refresh.clone(),
205            identity: status.identity.clone(),
206            matched_secret: status.matched_secret.clone(),
207            match_mode: status.match_mode.map(|mode| mode.as_str().to_string()),
208            reason: status.reason.as_str().to_string(),
209        }
210    }
211}
212
213fn inactive_with_file(auth_file: PathBuf, reason: AuthStatusReason) -> ActiveAuthStatus {
214    ActiveAuthStatus {
215        auth_file: Some(auth_file),
216        exists: false,
217        readable: false,
218        parse_ok: false,
219        authenticated: false,
220        prompt_segment_authenticated: false,
221        auth_kind: None,
222        has_oauth_access_token: false,
223        has_oauth_refresh_token: false,
224        has_api_key: false,
225        last_refresh: None,
226        identity: None,
227        matched_secret: None,
228        match_mode: None,
229        reason,
230    }
231}
232
233fn has_non_empty_string(value: &Value, path: &[&str]) -> bool {
234    json::string_at(value, path)
235        .map(|value| !value.trim().is_empty())
236        .unwrap_or(false)
237}
238
239fn has_real_refresh_token(value: &Value, path: &[&str]) -> bool {
240    json::string_at(value, path)
241        .map(|value| auth::is_real_refresh_token(value.trim()))
242        .unwrap_or(false)
243}
244
245fn inspect_matching_secret(auth_file: &Path) -> (Option<String>, Option<SecretMatchMode>) {
246    let Some(secret_dir) = paths::resolve_secret_dir() else {
247        return (None, None);
248    };
249    let Ok(entries) = std::fs::read_dir(secret_dir) else {
250        return (None, None);
251    };
252
253    let auth_key = auth::identity_key_from_auth_file(auth_file).ok().flatten();
254    let auth_hash = fs::sha256_file(auth_file).ok();
255
256    for entry in entries.flatten() {
257        let path = entry.path();
258        if path.extension().and_then(|s| s.to_str()) != Some("json") {
259            continue;
260        }
261
262        if let Some(key) = auth_key.as_deref()
263            && let Ok(Some(candidate_key)) = auth::identity_key_from_auth_file(&path)
264            && candidate_key == key
265        {
266            let mode = if hash_matches(auth_hash.as_deref(), &path) {
267                SecretMatchMode::Exact
268            } else {
269                SecretMatchMode::Identity
270            };
271            return (Some(file_name(&path)), Some(mode));
272        }
273
274        if hash_matches(auth_hash.as_deref(), &path) {
275            return (Some(file_name(&path)), Some(SecretMatchMode::Exact));
276        }
277    }
278
279    (None, None)
280}
281
282fn hash_matches(expected: Option<&str>, path: &Path) -> bool {
283    expected
284        .zip(fs::sha256_file(path).ok())
285        .map(|(expected, actual)| expected == actual)
286        .unwrap_or(false)
287}
288
289fn file_name(path: &Path) -> String {
290    path.file_name()
291        .and_then(|name| name.to_str())
292        .unwrap_or_default()
293        .to_string()
294}
295
296fn print_text_status(status: &ActiveAuthStatus) {
297    let auth_file = status
298        .auth_file
299        .as_ref()
300        .map(|path| path.display().to_string())
301        .unwrap_or_else(|| "<not configured>".to_string());
302    let auth_kind = status.auth_kind.map(|kind| kind.as_str()).unwrap_or("none");
303    let matched = status.matched_secret.as_deref().unwrap_or("none");
304    println!(
305        "codex: auth status authenticated={} kind={} prompt_segment_authenticated={} reason={} auth_file={} matched_secret={}",
306        status.authenticated,
307        auth_kind,
308        status.prompt_segment_authenticated,
309        status.reason.as_str(),
310        auth_file,
311        matched,
312    );
313}
314
315#[cfg(test)]
316mod tests {
317    use super::{AuthKind, AuthStatusReason, has_non_empty_string, has_real_refresh_token};
318    use crate::auth::ACCESS_ONLY_REFRESH_TOKEN_PLACEHOLDER;
319    use pretty_assertions::assert_eq;
320    use serde_json::json;
321
322    #[test]
323    fn has_non_empty_string_rejects_missing_null_and_blank_values() {
324        let value = json!({
325            "a": { "present": "x", "blank": "   ", "null": null }
326        });
327        assert!(has_non_empty_string(&value, &["a", "present"]));
328        assert!(!has_non_empty_string(&value, &["a", "blank"]));
329        assert!(!has_non_empty_string(&value, &["a", "null"]));
330        assert!(!has_non_empty_string(&value, &["a", "missing"]));
331    }
332
333    #[test]
334    fn has_real_refresh_token_rejects_access_only_placeholder() {
335        let value = json!({
336            "tokens": {
337                "refresh_token": ACCESS_ONLY_REFRESH_TOKEN_PLACEHOLDER
338            },
339            "real": {
340                "refresh_token": "refresh-secret"
341            }
342        });
343
344        assert!(!has_real_refresh_token(
345            &value,
346            &["tokens", "refresh_token"]
347        ));
348        assert!(has_real_refresh_token(&value, &["real", "refresh_token"]));
349    }
350
351    #[test]
352    fn enum_string_contracts_are_stable() {
353        assert_eq!(AuthKind::ChatgptOauth.as_str(), "chatgpt-oauth");
354        assert_eq!(AuthKind::OpenaiApiKey.as_str(), "openai-api-key");
355        assert_eq!(AuthStatusReason::Ready.as_str(), "ready");
356    }
357}