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::fs;
9use crate::json;
10use crate::paths;
11
12pub fn run(args: &[String]) -> Result<i32> {
13    let target_file = match resolve_target(args)? {
14        Some(path) => path,
15        None => return Ok(64),
16    };
17
18    if !target_file.is_file() {
19        eprintln!("codex-refresh: {} not found", target_file.display());
20        return Ok(1);
21    }
22
23    let value = match json::read_json(&target_file) {
24        Ok(value) => value,
25        Err(_) => {
26            eprintln!(
27                "codex-refresh: failed to read refresh token from {}",
28                target_file.display()
29            );
30            return Ok(2);
31        }
32    };
33
34    let refresh_token = refresh_token_from_json(&value);
35    let refresh_token = match refresh_token {
36        Some(token) => token,
37        None => {
38            eprintln!(
39                "codex-refresh: failed to read refresh token from {}",
40                target_file.display()
41            );
42            return Ok(2);
43        }
44    };
45
46    let now_iso = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
47
48    let client_id = std::env::var("CODEX_OAUTH_CLIENT_ID")
49        .unwrap_or_else(|_| "app_EMoamEEZ73f0CkXaXp7hrann".to_string());
50
51    let connect_timeout = env_timeout("CODEX_REFRESH_AUTH_CURL_CONNECT_TIMEOUT_SECONDS", 2);
52    let max_time = env_timeout("CODEX_REFRESH_AUTH_CURL_MAX_TIME_SECONDS", 8);
53
54    let client = Client::builder()
55        .connect_timeout(Duration::from_secs(connect_timeout))
56        .timeout(Duration::from_secs(max_time))
57        .build()?;
58
59    let response = client
60        .post("https://auth.openai.com/oauth/token")
61        .header("Content-Type", "application/x-www-form-urlencoded")
62        .form(&[
63            ("grant_type", "refresh_token"),
64            ("client_id", client_id.as_str()),
65            ("refresh_token", refresh_token.as_str()),
66        ])
67        .send();
68
69    let response = match response {
70        Ok(resp) => resp,
71        Err(_) => {
72            eprintln!(
73                "codex-refresh: token endpoint request failed for {}",
74                target_file.display()
75            );
76            return Ok(3);
77        }
78    };
79
80    let status = response.status();
81    let body = response.text().unwrap_or_default();
82
83    if status.as_u16() != 200 {
84        let summary = error_summary(&body);
85        if let Some(summary) = summary {
86            eprintln!(
87                "codex-refresh: token endpoint failed (HTTP {}) for {}: {}",
88                status.as_u16(),
89                target_file.display(),
90                summary
91            );
92        } else {
93            eprintln!(
94                "codex-refresh: token endpoint failed (HTTP {}) for {}",
95                status.as_u16(),
96                target_file.display()
97            );
98        }
99        return Ok(3);
100    }
101
102    let response_json: Value = match serde_json::from_str(&body) {
103        Ok(value) => value,
104        Err(_) => {
105            eprintln!("codex-refresh: token endpoint returned invalid JSON");
106            return Ok(4);
107        }
108    };
109
110    let merged = match merge_tokens(&value, &response_json, &now_iso) {
111        Ok(value) => value,
112        Err(_) => {
113            eprintln!("codex-refresh: failed to merge refreshed tokens");
114            return Ok(5);
115        }
116    };
117
118    let output = serde_json::to_vec(&merged)?;
119    fs::write_atomic(&target_file, &output, fs::SECRET_FILE_MODE)?;
120
121    let cache_dir = match paths::resolve_secret_cache_dir() {
122        Some(dir) => dir,
123        None => PathBuf::new(),
124    };
125    let timestamp_path = cache_dir.join(format!("{}.timestamp", file_name(&target_file)));
126    if !cache_dir.as_os_str().is_empty() {
127        fs::write_timestamp(&timestamp_path, Some(&now_iso))?;
128    }
129
130    if is_auth_file(&target_file) {
131        let sync_rc = crate::auth::sync::run()?;
132        if sync_rc != 0 {
133            return Ok(6);
134        }
135    }
136
137    println!("codex: refreshed {} at {}", target_file.display(), now_iso);
138    Ok(0)
139}
140
141fn resolve_target(args: &[String]) -> Result<Option<PathBuf>> {
142    if args.is_empty() {
143        return Ok(Some(
144            paths::resolve_auth_file().unwrap_or_else(|| PathBuf::from("auth.json")),
145        ));
146    }
147
148    let secret_name = &args[0];
149    if secret_name.is_empty() || secret_name.contains('/') || secret_name.contains("..") {
150        eprintln!("codex-refresh: invalid secret file name: {secret_name}");
151        return Ok(None);
152    }
153
154    let secret_dir = paths::resolve_secret_dir().unwrap_or_default();
155    Ok(Some(secret_dir.join(secret_name)))
156}
157
158fn refresh_token_from_json(value: &Value) -> Option<String> {
159    json::string_at(value, &["tokens", "refresh_token"])
160        .or_else(|| json::string_at(value, &["refresh_token"]))
161}
162
163fn merge_tokens(base: &Value, refresh: &Value, now_iso: &str) -> Result<Value> {
164    let mut root = base.as_object().cloned().unwrap_or_else(Map::new);
165    let mut tokens = root
166        .get("tokens")
167        .and_then(|value| value.as_object())
168        .cloned()
169        .unwrap_or_else(Map::new);
170
171    if let Some(refresh_obj) = refresh.as_object() {
172        for (key, value) in refresh_obj {
173            tokens.insert(key.clone(), value.clone());
174        }
175    } else {
176        return Err(anyhow::anyhow!("refresh payload is not object"));
177    }
178
179    root.insert("tokens".to_string(), Value::Object(tokens));
180    root.insert(
181        "last_refresh".to_string(),
182        Value::String(now_iso.to_string()),
183    );
184    Ok(Value::Object(root))
185}
186
187fn error_summary(body: &str) -> Option<String> {
188    let value: Value = serde_json::from_str(body).ok()?;
189    let mut parts = Vec::new();
190
191    if let Some(error) = value.get("error") {
192        if error.is_object() {
193            if let Some(code) = error.get("code").and_then(|v| v.as_str())
194                && !code.is_empty()
195            {
196                parts.push(code.to_string());
197            }
198            if let Some(message) = error.get("message").and_then(|v| v.as_str())
199                && !message.is_empty()
200            {
201                parts.push(message.to_string());
202            }
203        } else if let Some(error_str) = error.as_str()
204            && !error_str.is_empty()
205        {
206            parts.push(error_str.to_string());
207        }
208    }
209
210    if let Some(desc) = value.get("error_description").and_then(|v| v.as_str())
211        && !desc.is_empty()
212    {
213        parts.push(desc.to_string());
214    }
215
216    if parts.is_empty() {
217        None
218    } else {
219        Some(parts.join(": "))
220    }
221}
222
223fn env_timeout(key: &str, default: u64) -> u64 {
224    std::env::var(key)
225        .ok()
226        .and_then(|raw| raw.parse::<u64>().ok())
227        .unwrap_or(default)
228}
229
230fn file_name(path: &Path) -> String {
231    path.file_name()
232        .and_then(|name| name.to_str())
233        .unwrap_or("auth.json")
234        .to_string()
235}
236
237fn is_auth_file(target: &Path) -> bool {
238    if let Some(auth_file) = paths::resolve_auth_file()
239        && auth_file == target
240    {
241        return true;
242    }
243    false
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use pretty_assertions::assert_eq;
250
251    struct EnvVarGuard {
252        key: String,
253        previous: Option<std::ffi::OsString>,
254    }
255
256    impl EnvVarGuard {
257        fn set(key: &str, value: &str) -> Self {
258            let previous = std::env::var_os(key);
259            // SAFETY: tests mutate process env only in scoped guard usage.
260            unsafe { std::env::set_var(key, value) };
261            Self {
262                key: key.to_string(),
263                previous,
264            }
265        }
266
267        fn remove(key: &str) -> Self {
268            let previous = std::env::var_os(key);
269            // SAFETY: tests mutate process env only in scoped guard usage.
270            unsafe { std::env::remove_var(key) };
271            Self {
272                key: key.to_string(),
273                previous,
274            }
275        }
276    }
277
278    impl Drop for EnvVarGuard {
279        fn drop(&mut self) {
280            if let Some(previous) = self.previous.take() {
281                // SAFETY: tests restore process env only in scoped guard usage.
282                unsafe { std::env::set_var(&self.key, previous) };
283            } else {
284                // SAFETY: tests restore process env only in scoped guard usage.
285                unsafe { std::env::remove_var(&self.key) };
286            }
287        }
288    }
289
290    #[test]
291    fn auth_refresh_error_summary() {
292        let body = r#"{"error":{"code":"invalid_grant","message":"Bad token"}}"#;
293        let summary = error_summary(body).expect("summary");
294        assert_eq!(summary, "invalid_grant: Bad token");
295    }
296
297    #[test]
298    fn auth_refresh_merge_tokens() {
299        let base: Value = serde_json::from_str(r#"{"tokens":{"access_token":"old"}}"#).unwrap();
300        let refresh: Value =
301            serde_json::from_str(r#"{"access_token":"new","refresh_token":"r1"}"#).unwrap();
302        let merged = merge_tokens(&base, &refresh, "2025-01-20T00:00:00Z").unwrap();
303        let tokens = merged.get("tokens").unwrap();
304        assert_eq!(tokens.get("access_token").unwrap(), "new");
305        assert_eq!(tokens.get("refresh_token").unwrap(), "r1");
306        assert_eq!(merged.get("last_refresh").unwrap(), "2025-01-20T00:00:00Z");
307    }
308
309    #[test]
310    fn auth_refresh_resolve_target_defaults_when_no_args() {
311        let args: Vec<String> = Vec::new();
312        let target = resolve_target(&args).unwrap().expect("target");
313        assert!(!target.as_os_str().is_empty());
314    }
315
316    #[test]
317    fn auth_refresh_resolve_target_rejects_invalid_secret_names() {
318        for secret in ["", "a/b", "a..b", "../x"] {
319            let args = vec![secret.to_string()];
320            let target = resolve_target(&args).unwrap();
321            assert!(target.is_none(), "expected None for secret={secret:?}");
322        }
323    }
324
325    #[test]
326    fn auth_refresh_resolve_target_joins_secret_name() {
327        let secret_name = "my-secret.json";
328        let args = vec![secret_name.to_string()];
329        let target = resolve_target(&args).unwrap().expect("target");
330        assert!(target.ends_with(secret_name));
331    }
332
333    #[test]
334    fn auth_refresh_refresh_token_from_json_prefers_nested() {
335        let value = serde_json::json!({
336            "refresh_token": "top",
337            "tokens": { "refresh_token": "nested" }
338        });
339        let token = refresh_token_from_json(&value).expect("token");
340        assert_eq!(token, "nested");
341    }
342
343    #[test]
344    fn auth_refresh_refresh_token_from_json_falls_back_to_top_level() {
345        let value = serde_json::json!({ "refresh_token": "top" });
346        let token = refresh_token_from_json(&value).expect("token");
347        assert_eq!(token, "top");
348    }
349
350    #[test]
351    fn auth_refresh_refresh_token_from_json_none_when_missing() {
352        let value = serde_json::json!({ "tokens": { "access_token": "a1" } });
353        assert!(refresh_token_from_json(&value).is_none());
354    }
355
356    #[test]
357    fn auth_refresh_env_timeout_uses_default_when_missing_or_invalid() {
358        let key = "CODEX_TEST_ENV_TIMEOUT_SECONDS_DEFAULT";
359        let _guard = EnvVarGuard::remove(key);
360        assert_eq!(env_timeout(key, 123), 123);
361
362        let _guard = EnvVarGuard::set(key, "not-a-number");
363        assert_eq!(env_timeout(key, 456), 456);
364
365        let _guard = EnvVarGuard::set(key, "-1");
366        assert_eq!(env_timeout(key, 789), 789);
367    }
368
369    #[test]
370    fn auth_refresh_env_timeout_parses_value() {
371        let key = "CODEX_TEST_ENV_TIMEOUT_SECONDS_PARSE";
372        let _guard = EnvVarGuard::set(key, "42");
373        assert_eq!(env_timeout(key, 1), 42);
374    }
375
376    #[test]
377    fn auth_refresh_file_name_returns_basename() {
378        let path = Path::new("my-auth.json");
379        assert_eq!(file_name(path), "my-auth.json");
380    }
381
382    #[test]
383    fn auth_refresh_file_name_defaults_when_missing() {
384        let path = Path::new("");
385        assert_eq!(file_name(path), "auth.json");
386    }
387
388    #[cfg(unix)]
389    #[test]
390    fn auth_refresh_file_name_defaults_when_non_utf8() {
391        use std::ffi::OsString;
392        use std::os::unix::ffi::OsStringExt;
393
394        let path = PathBuf::from(OsString::from_vec(vec![0xFF]));
395        assert_eq!(file_name(&path), "auth.json");
396    }
397}