Skip to main content

codex_cli/auth/
refresh.rs

1use anyhow::Result;
2use chrono::Utc;
3use reqwest::blocking::Client;
4use serde_json::{Map, Value};
5use std::path::{Path, PathBuf};
6use std::time::Duration;
7
8use crate::auth::output::{self, AuthRefreshResult};
9use crate::fs;
10use crate::json;
11use crate::paths;
12
13#[derive(Copy, Clone, Eq, PartialEq)]
14enum RefreshOutputMode {
15    Text,
16    Json,
17    Silent,
18}
19
20pub fn run(args: &[String]) -> Result<i32> {
21    run_with_mode(args, RefreshOutputMode::Text)
22}
23
24pub fn run_with_json(args: &[String], output_json: bool) -> Result<i32> {
25    let mode = if output_json {
26        RefreshOutputMode::Json
27    } else {
28        RefreshOutputMode::Text
29    };
30    run_with_mode(args, mode)
31}
32
33pub fn run_silent(args: &[String]) -> Result<i32> {
34    run_with_mode(args, RefreshOutputMode::Silent)
35}
36
37fn run_with_mode(args: &[String], output_mode: RefreshOutputMode) -> Result<i32> {
38    let output_json = output_mode == RefreshOutputMode::Json;
39    let output_text = output_mode == RefreshOutputMode::Text;
40
41    let target_file = match resolve_target(args, output_json)? {
42        Some(path) => path,
43        None => return Ok(64),
44    };
45
46    if !target_file.is_file() {
47        if output_json {
48            output::emit_error(
49                "auth refresh",
50                "target-not-found",
51                format!("codex-refresh: {} not found", target_file.display()),
52                Some(serde_json::json!({
53                    "target_file": target_file.display().to_string(),
54                })),
55            )?;
56        } else if output_text {
57            eprintln!("codex-refresh: {} not found", target_file.display());
58        }
59        return Ok(1);
60    }
61
62    let value = match json::read_json(&target_file) {
63        Ok(value) => value,
64        Err(_) => {
65            if output_json {
66                output::emit_error(
67                    "auth refresh",
68                    "refresh-token-read-failed",
69                    format!(
70                        "codex-refresh: failed to read refresh token from {}",
71                        target_file.display()
72                    ),
73                    Some(serde_json::json!({
74                        "target_file": target_file.display().to_string(),
75                    })),
76                )?;
77            } else if output_text {
78                eprintln!(
79                    "codex-refresh: failed to read refresh token from {}",
80                    target_file.display()
81                );
82            }
83            return Ok(2);
84        }
85    };
86
87    let refresh_token = refresh_token_from_json(&value);
88    let refresh_token = match refresh_token {
89        Some(token) => token,
90        None => {
91            if output_json {
92                output::emit_error(
93                    "auth refresh",
94                    "refresh-token-missing",
95                    format!(
96                        "codex-refresh: failed to read refresh token from {}",
97                        target_file.display()
98                    ),
99                    Some(serde_json::json!({
100                        "target_file": target_file.display().to_string(),
101                    })),
102                )?;
103            } else if output_text {
104                eprintln!(
105                    "codex-refresh: failed to read refresh token from {}",
106                    target_file.display()
107                );
108            }
109            return Ok(2);
110        }
111    };
112
113    let now_iso = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
114
115    let client_id = std::env::var("CODEX_OAUTH_CLIENT_ID")
116        .unwrap_or_else(|_| "app_EMoamEEZ73f0CkXaXp7hrann".to_string());
117
118    let connect_timeout = env_timeout("CODEX_REFRESH_AUTH_CURL_CONNECT_TIMEOUT_SECONDS", 2);
119    let max_time = env_timeout("CODEX_REFRESH_AUTH_CURL_MAX_TIME_SECONDS", 8);
120
121    let client = Client::builder()
122        .connect_timeout(Duration::from_secs(connect_timeout))
123        .timeout(Duration::from_secs(max_time))
124        .build()?;
125
126    let response = client
127        .post("https://auth.openai.com/oauth/token")
128        .header("Content-Type", "application/x-www-form-urlencoded")
129        .form(&[
130            ("grant_type", "refresh_token"),
131            ("client_id", client_id.as_str()),
132            ("refresh_token", refresh_token.as_str()),
133        ])
134        .send();
135
136    let response = match response {
137        Ok(resp) => resp,
138        Err(_) => {
139            if output_json {
140                output::emit_error(
141                    "auth refresh",
142                    "token-endpoint-request-failed",
143                    format!(
144                        "codex-refresh: token endpoint request failed for {}",
145                        target_file.display()
146                    ),
147                    Some(serde_json::json!({
148                        "target_file": target_file.display().to_string(),
149                    })),
150                )?;
151            } else if output_text {
152                eprintln!(
153                    "codex-refresh: token endpoint request failed for {}",
154                    target_file.display()
155                );
156            }
157            return Ok(3);
158        }
159    };
160
161    let status = response.status();
162    let body = response.text().unwrap_or_default();
163
164    if status.as_u16() != 200 {
165        let summary = error_summary(&body);
166        if output_json {
167            output::emit_error(
168                "auth refresh",
169                "token-endpoint-failed",
170                format!(
171                    "codex-refresh: token endpoint failed (HTTP {}) for {}",
172                    status.as_u16(),
173                    target_file.display()
174                ),
175                Some(serde_json::json!({
176                    "http_status": status.as_u16(),
177                    "target_file": target_file.display().to_string(),
178                    "summary": summary,
179                })),
180            )?;
181        } else if output_text {
182            if let Some(summary) = summary {
183                eprintln!(
184                    "codex-refresh: token endpoint failed (HTTP {}) for {}: {}",
185                    status.as_u16(),
186                    target_file.display(),
187                    summary
188                );
189            } else {
190                eprintln!(
191                    "codex-refresh: token endpoint failed (HTTP {}) for {}",
192                    status.as_u16(),
193                    target_file.display()
194                );
195            }
196        }
197        return Ok(3);
198    }
199
200    let response_json: Value = match serde_json::from_str(&body) {
201        Ok(value) => value,
202        Err(_) => {
203            if output_json {
204                output::emit_error(
205                    "auth refresh",
206                    "token-endpoint-invalid-json",
207                    "codex-refresh: token endpoint returned invalid JSON",
208                    None,
209                )?;
210            } else if output_text {
211                eprintln!("codex-refresh: token endpoint returned invalid JSON");
212            }
213            return Ok(4);
214        }
215    };
216
217    let merged = match merge_tokens(&value, &response_json, &now_iso) {
218        Ok(value) => value,
219        Err(_) => {
220            if output_json {
221                output::emit_error(
222                    "auth refresh",
223                    "merge-failed",
224                    "codex-refresh: failed to merge refreshed tokens",
225                    None,
226                )?;
227            } else if output_text {
228                eprintln!("codex-refresh: failed to merge refreshed tokens");
229            }
230            return Ok(5);
231        }
232    };
233
234    let output = serde_json::to_vec(&merged)?;
235    fs::write_atomic(&target_file, &output, fs::SECRET_FILE_MODE)?;
236
237    let cache_dir = match paths::resolve_secret_cache_dir() {
238        Some(dir) => dir,
239        None => PathBuf::new(),
240    };
241    let timestamp_path = cache_dir.join(format!("{}.timestamp", file_name(&target_file)));
242    if !cache_dir.as_os_str().is_empty() {
243        fs::write_timestamp(&timestamp_path, Some(&now_iso))?;
244    }
245
246    let mut synced = false;
247    if is_auth_file(&target_file) {
248        let sync_rc = crate::auth::sync::run_with_json(false)?;
249        if sync_rc != 0 {
250            if output_json {
251                output::emit_error(
252                    "auth refresh",
253                    "sync-failed",
254                    "codex-refresh: failed to sync refreshed auth into matching secrets",
255                    Some(serde_json::json!({
256                        "target_file": target_file.display().to_string(),
257                    })),
258                )?;
259            }
260            return Ok(6);
261        }
262        synced = true;
263    }
264
265    if output_json {
266        output::emit_result(
267            "auth refresh",
268            AuthRefreshResult {
269                target_file: target_file.display().to_string(),
270                refreshed: true,
271                synced,
272                refreshed_at: Some(now_iso),
273            },
274        )?;
275    } else if output_text {
276        println!("codex: refreshed {} at {}", target_file.display(), now_iso);
277    }
278    Ok(0)
279}
280
281fn resolve_target(args: &[String], output_json: bool) -> Result<Option<PathBuf>> {
282    if args.is_empty() {
283        return Ok(Some(
284            paths::resolve_auth_file().unwrap_or_else(|| PathBuf::from("auth.json")),
285        ));
286    }
287
288    let secret_name = &args[0];
289    if secret_name.is_empty() || secret_name.contains('/') || secret_name.contains("..") {
290        if output_json {
291            output::emit_error(
292                "auth refresh",
293                "invalid-secret-file-name",
294                format!("codex-refresh: invalid secret file name: {secret_name}"),
295                Some(serde_json::json!({
296                    "secret": secret_name,
297                })),
298            )?;
299        } else {
300            eprintln!("codex-refresh: invalid secret file name: {secret_name}");
301        }
302        return Ok(None);
303    }
304
305    let secret_dir = paths::resolve_secret_dir().unwrap_or_default();
306    Ok(Some(secret_dir.join(secret_name)))
307}
308
309fn refresh_token_from_json(value: &Value) -> Option<String> {
310    json::string_at(value, &["tokens", "refresh_token"])
311        .or_else(|| json::string_at(value, &["refresh_token"]))
312}
313
314fn merge_tokens(base: &Value, refresh: &Value, now_iso: &str) -> Result<Value> {
315    let mut root = base.as_object().cloned().unwrap_or_else(Map::new);
316    let mut tokens = root
317        .get("tokens")
318        .and_then(|value| value.as_object())
319        .cloned()
320        .unwrap_or_else(Map::new);
321
322    if let Some(refresh_obj) = refresh.as_object() {
323        for (key, value) in refresh_obj {
324            tokens.insert(key.clone(), value.clone());
325        }
326    } else {
327        return Err(anyhow::anyhow!("refresh payload is not object"));
328    }
329
330    root.insert("tokens".to_string(), Value::Object(tokens));
331    root.insert(
332        "last_refresh".to_string(),
333        Value::String(now_iso.to_string()),
334    );
335    Ok(Value::Object(root))
336}
337
338fn error_summary(body: &str) -> Option<String> {
339    let value: Value = serde_json::from_str(body).ok()?;
340    let mut parts = Vec::new();
341
342    if let Some(error) = value.get("error") {
343        if error.is_object() {
344            if let Some(code) = error.get("code").and_then(|v| v.as_str())
345                && !code.is_empty()
346            {
347                parts.push(code.to_string());
348            }
349            if let Some(message) = error.get("message").and_then(|v| v.as_str())
350                && !message.is_empty()
351            {
352                parts.push(message.to_string());
353            }
354        } else if let Some(error_str) = error.as_str()
355            && !error_str.is_empty()
356        {
357            parts.push(error_str.to_string());
358        }
359    }
360
361    if let Some(desc) = value.get("error_description").and_then(|v| v.as_str())
362        && !desc.is_empty()
363    {
364        parts.push(desc.to_string());
365    }
366
367    if parts.is_empty() {
368        None
369    } else {
370        Some(parts.join(": "))
371    }
372}
373
374fn env_timeout(key: &str, default: u64) -> u64 {
375    std::env::var(key)
376        .ok()
377        .and_then(|raw| raw.parse::<u64>().ok())
378        .unwrap_or(default)
379}
380
381fn file_name(path: &Path) -> String {
382    path.file_name()
383        .and_then(|name| name.to_str())
384        .unwrap_or("auth.json")
385        .to_string()
386}
387
388fn is_auth_file(target: &Path) -> bool {
389    if let Some(auth_file) = paths::resolve_auth_file()
390        && auth_file == target
391    {
392        return true;
393    }
394    false
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use pretty_assertions::assert_eq;
401
402    struct EnvVarGuard {
403        key: String,
404        previous: Option<std::ffi::OsString>,
405    }
406
407    impl EnvVarGuard {
408        fn set(key: &str, value: &str) -> Self {
409            let previous = std::env::var_os(key);
410            // SAFETY: tests mutate process env only in scoped guard usage.
411            unsafe { std::env::set_var(key, value) };
412            Self {
413                key: key.to_string(),
414                previous,
415            }
416        }
417
418        fn remove(key: &str) -> Self {
419            let previous = std::env::var_os(key);
420            // SAFETY: tests mutate process env only in scoped guard usage.
421            unsafe { std::env::remove_var(key) };
422            Self {
423                key: key.to_string(),
424                previous,
425            }
426        }
427    }
428
429    impl Drop for EnvVarGuard {
430        fn drop(&mut self) {
431            if let Some(previous) = self.previous.take() {
432                // SAFETY: tests restore process env only in scoped guard usage.
433                unsafe { std::env::set_var(&self.key, previous) };
434            } else {
435                // SAFETY: tests restore process env only in scoped guard usage.
436                unsafe { std::env::remove_var(&self.key) };
437            }
438        }
439    }
440
441    #[test]
442    fn auth_refresh_error_summary() {
443        let body = r#"{"error":{"code":"invalid_grant","message":"Bad token"}}"#;
444        let summary = error_summary(body).expect("summary");
445        assert_eq!(summary, "invalid_grant: Bad token");
446    }
447
448    #[test]
449    fn auth_refresh_merge_tokens() {
450        let base: Value = serde_json::from_str(r#"{"tokens":{"access_token":"old"}}"#).unwrap();
451        let refresh: Value =
452            serde_json::from_str(r#"{"access_token":"new","refresh_token":"r1"}"#).unwrap();
453        let merged = merge_tokens(&base, &refresh, "2025-01-20T00:00:00Z").unwrap();
454        let tokens = merged.get("tokens").unwrap();
455        assert_eq!(tokens.get("access_token").unwrap(), "new");
456        assert_eq!(tokens.get("refresh_token").unwrap(), "r1");
457        assert_eq!(merged.get("last_refresh").unwrap(), "2025-01-20T00:00:00Z");
458    }
459
460    #[test]
461    fn auth_refresh_resolve_target_defaults_when_no_args() {
462        let args: Vec<String> = Vec::new();
463        let target = resolve_target(&args, false).unwrap().expect("target");
464        assert!(!target.as_os_str().is_empty());
465    }
466
467    #[test]
468    fn auth_refresh_resolve_target_rejects_invalid_secret_names() {
469        for secret in ["", "a/b", "a..b", "../x"] {
470            let args = vec![secret.to_string()];
471            let target = resolve_target(&args, false).unwrap();
472            assert!(target.is_none(), "expected None for secret={secret:?}");
473        }
474    }
475
476    #[test]
477    fn auth_refresh_resolve_target_joins_secret_name() {
478        let secret_name = "my-secret.json";
479        let args = vec![secret_name.to_string()];
480        let target = resolve_target(&args, false).unwrap().expect("target");
481        assert!(target.ends_with(secret_name));
482    }
483
484    #[test]
485    fn auth_refresh_refresh_token_from_json_prefers_nested() {
486        let value = serde_json::json!({
487            "refresh_token": "top",
488            "tokens": { "refresh_token": "nested" }
489        });
490        let token = refresh_token_from_json(&value).expect("token");
491        assert_eq!(token, "nested");
492    }
493
494    #[test]
495    fn auth_refresh_refresh_token_from_json_falls_back_to_top_level() {
496        let value = serde_json::json!({ "refresh_token": "top" });
497        let token = refresh_token_from_json(&value).expect("token");
498        assert_eq!(token, "top");
499    }
500
501    #[test]
502    fn auth_refresh_refresh_token_from_json_none_when_missing() {
503        let value = serde_json::json!({ "tokens": { "access_token": "a1" } });
504        assert!(refresh_token_from_json(&value).is_none());
505    }
506
507    #[test]
508    fn auth_refresh_env_timeout_uses_default_when_missing_or_invalid() {
509        let key = "CODEX_TEST_ENV_TIMEOUT_SECONDS_DEFAULT";
510        let _guard = EnvVarGuard::remove(key);
511        assert_eq!(env_timeout(key, 123), 123);
512
513        let _guard = EnvVarGuard::set(key, "not-a-number");
514        assert_eq!(env_timeout(key, 456), 456);
515
516        let _guard = EnvVarGuard::set(key, "-1");
517        assert_eq!(env_timeout(key, 789), 789);
518    }
519
520    #[test]
521    fn auth_refresh_env_timeout_parses_value() {
522        let key = "CODEX_TEST_ENV_TIMEOUT_SECONDS_PARSE";
523        let _guard = EnvVarGuard::set(key, "42");
524        assert_eq!(env_timeout(key, 1), 42);
525    }
526
527    #[test]
528    fn auth_refresh_file_name_returns_basename() {
529        let path = Path::new("my-auth.json");
530        assert_eq!(file_name(path), "my-auth.json");
531    }
532
533    #[test]
534    fn auth_refresh_file_name_defaults_when_missing() {
535        let path = Path::new("");
536        assert_eq!(file_name(path), "auth.json");
537    }
538
539    #[cfg(unix)]
540    #[test]
541    fn auth_refresh_file_name_defaults_when_non_utf8() {
542        use std::ffi::OsString;
543        use std::os::unix::ffi::OsStringExt;
544
545        let path = PathBuf::from(OsString::from_vec(vec![0xFF]));
546        assert_eq!(file_name(&path), "auth.json");
547    }
548}