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