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(×tamp_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 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 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 unsafe { std::env::set_var(&self.key, previous) };
283 } else {
284 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}