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}