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