Skip to main content

codex_cli/auth/
refresh.rs

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