1use std::path::{Path, PathBuf};
2use std::process::Command;
3
4use serde_json::{Value, json};
5
6use crate::auth;
7use crate::auth::output;
8
9macro_rules! parse_json_text {
10 ($raw:expr) => {{
11 let tmp_path = crate::auth::temp_file_path("gemini-refresh-json");
12 let parsed = (|| {
13 std::fs::write(&tmp_path, $raw).ok()?;
14 crate::json::read_json(&tmp_path).ok()
15 })();
16 let _ = std::fs::remove_file(&tmp_path);
17 parsed
18 }};
19}
20
21#[derive(Copy, Clone, Eq, PartialEq)]
22enum RefreshOutputMode {
23 Text,
24 Json,
25 Silent,
26}
27
28#[derive(Copy, Clone, Eq, PartialEq)]
29enum AuthProvider {
30 Google,
31 OpenAi,
32}
33
34const OPENAI_TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
35const OPENAI_DEFAULT_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
36const GOOGLE_TOKEN_URL: &str = "https://oauth2.googleapis.com/token";
37const GOOGLE_DEFAULT_CLIENT_ID: &str =
38 "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com";
39
40pub fn run(args: &[String]) -> i32 {
41 run_with_mode(args, RefreshOutputMode::Text)
42}
43
44pub fn run_with_json(args: &[String], output_json: bool) -> i32 {
45 let mode = if output_json {
46 RefreshOutputMode::Json
47 } else {
48 RefreshOutputMode::Text
49 };
50 run_with_mode(args, mode)
51}
52
53pub fn run_silent(args: &[String]) -> i32 {
54 run_with_mode(args, RefreshOutputMode::Silent)
55}
56
57fn run_with_mode(args: &[String], output_mode: RefreshOutputMode) -> i32 {
58 let output_json = output_mode == RefreshOutputMode::Json;
59 let output_text = output_mode == RefreshOutputMode::Text;
60
61 let target_file = match resolve_target(args, output_json) {
62 Some(path) => path,
63 None => return 64,
64 };
65
66 if !target_file.is_file() {
67 if output_json {
68 let _ = output::emit_error(
69 "auth refresh",
70 "target-not-found",
71 format!("gemini-refresh: {} not found", target_file.display()),
72 Some(output::obj(vec![(
73 "target_file",
74 output::s(target_file.display().to_string()),
75 )])),
76 );
77 } else if output_text {
78 eprintln!("gemini-refresh: {} not found", target_file.display());
79 }
80 return 1;
81 }
82
83 let mut value = match crate::json::read_json(&target_file) {
84 Ok(value) => value,
85 Err(_) => {
86 if output_json {
87 let _ = output::emit_error(
88 "auth refresh",
89 "refresh-token-read-failed",
90 format!(
91 "gemini-refresh: failed to read refresh token from {}",
92 target_file.display()
93 ),
94 Some(output::obj(vec![(
95 "target_file",
96 output::s(target_file.display().to_string()),
97 )])),
98 );
99 } else if output_text {
100 eprintln!(
101 "gemini-refresh: failed to read refresh token from {}",
102 target_file.display()
103 );
104 }
105 return 2;
106 }
107 };
108
109 let refresh_token = crate::json::string_at(&value, &["tokens", "refresh_token"])
110 .or_else(|| crate::json::string_at(&value, &["refresh_token"]));
111
112 let refresh_token = match refresh_token {
113 Some(token) => token,
114 None => {
115 if output_json {
116 let _ = output::emit_error(
117 "auth refresh",
118 "refresh-token-missing",
119 format!(
120 "gemini-refresh: failed to read refresh token from {}",
121 target_file.display()
122 ),
123 Some(output::obj(vec![(
124 "target_file",
125 output::s(target_file.display().to_string()),
126 )])),
127 );
128 } else if output_text {
129 eprintln!(
130 "gemini-refresh: failed to read refresh token from {}",
131 target_file.display()
132 );
133 }
134 return 2;
135 }
136 };
137
138 let now_iso = auth::now_utc_iso();
139 let provider = detect_provider(&value);
140 let token_endpoint = match provider {
141 AuthProvider::Google => GOOGLE_TOKEN_URL,
142 AuthProvider::OpenAi => OPENAI_TOKEN_URL,
143 };
144 let client_id = resolve_client_id(provider, &value);
145 let client_secret = std::env::var("GEMINI_OAUTH_CLIENT_SECRET")
146 .ok()
147 .filter(|value| !value.trim().is_empty());
148
149 let connect_timeout = env_timeout("GEMINI_REFRESH_AUTH_CURL_CONNECT_TIMEOUT_SECONDS", 2);
150 let max_time = env_timeout("GEMINI_REFRESH_AUTH_CURL_MAX_TIME_SECONDS", 8);
151
152 let mut command = Command::new("curl");
153 command
154 .arg("-sS")
155 .arg("--connect-timeout")
156 .arg(connect_timeout.to_string())
157 .arg("--max-time")
158 .arg(max_time.to_string())
159 .arg("-X")
160 .arg("POST")
161 .arg(token_endpoint)
162 .arg("-H")
163 .arg("Content-Type: application/x-www-form-urlencoded")
164 .arg("--data-urlencode")
165 .arg("grant_type=refresh_token")
166 .arg("--data-urlencode")
167 .arg(format!("client_id={client_id}"))
168 .arg("--data-urlencode")
169 .arg(format!("refresh_token={refresh_token}"));
170
171 if let Some(client_secret) = client_secret.as_deref() {
172 command
173 .arg("--data-urlencode")
174 .arg(format!("client_secret={client_secret}"));
175 }
176
177 let response = command
178 .arg("-w")
179 .arg("\n__HTTP_STATUS__:%{http_code}")
180 .output();
181
182 let response = match response {
183 Ok(resp) => resp,
184 Err(_) => {
185 if output_json {
186 let _ = output::emit_error(
187 "auth refresh",
188 "token-endpoint-request-failed",
189 format!(
190 "gemini-refresh: token endpoint request failed for {}",
191 target_file.display()
192 ),
193 Some(output::obj(vec![(
194 "target_file",
195 output::s(target_file.display().to_string()),
196 )])),
197 );
198 } else if output_text {
199 eprintln!(
200 "gemini-refresh: token endpoint request failed for {}",
201 target_file.display()
202 );
203 }
204 return 3;
205 }
206 };
207
208 if !response.status.success() {
209 if output_json {
210 let _ = output::emit_error(
211 "auth refresh",
212 "token-endpoint-request-failed",
213 format!(
214 "gemini-refresh: token endpoint request failed for {}",
215 target_file.display()
216 ),
217 Some(output::obj(vec![
218 ("target_file", output::s(target_file.display().to_string())),
219 ("endpoint", output::s(token_endpoint)),
220 ])),
221 );
222 } else if output_text {
223 eprintln!(
224 "gemini-refresh: token endpoint request failed for {}",
225 target_file.display()
226 );
227 }
228 return 3;
229 }
230
231 let response_text = String::from_utf8_lossy(&response.stdout).to_string();
232 let (body, http_status) = split_http_status_marker(&response_text);
233
234 if http_status != 200 {
235 let summary = error_summary(&body);
236 if output_json {
237 let mut details = vec![
238 ("http_status", output::n(http_status as i64)),
239 ("target_file", output::s(target_file.display().to_string())),
240 ("endpoint", output::s(token_endpoint)),
241 ];
242 if let Some(summary) = summary.clone() {
243 details.push(("summary", output::s(summary)));
244 }
245 let _ = output::emit_error(
246 "auth refresh",
247 "token-endpoint-failed",
248 format!(
249 "gemini-refresh: token endpoint failed (HTTP {}) for {}",
250 http_status,
251 target_file.display()
252 ),
253 Some(output::obj_dynamic(
254 details
255 .into_iter()
256 .map(|(key, value)| (key.to_string(), value))
257 .collect(),
258 )),
259 );
260 } else if output_text {
261 if let Some(summary) = summary {
262 eprintln!(
263 "gemini-refresh: token endpoint failed (HTTP {}) for {}: {}",
264 http_status,
265 target_file.display(),
266 summary
267 );
268 } else {
269 eprintln!(
270 "gemini-refresh: token endpoint failed (HTTP {}) for {}",
271 http_status,
272 target_file.display()
273 );
274 }
275 }
276 return 3;
277 }
278
279 let response_json = match parse_json_text!(body.as_str()) {
280 Some(value) => value,
281 None => {
282 if output_json {
283 let _ = output::emit_error(
284 "auth refresh",
285 "token-endpoint-invalid-json",
286 "gemini-refresh: token endpoint returned invalid JSON",
287 None,
288 );
289 } else if output_text {
290 eprintln!("gemini-refresh: token endpoint returned invalid JSON");
291 }
292 return 4;
293 }
294 };
295
296 let merge_ok = merge_refreshed_tokens(
297 &mut value,
298 &response_json,
299 &now_iso,
300 &refresh_token,
301 provider,
302 );
303
304 if !merge_ok {
305 return merge_failed(output_json, output_text);
306 }
307
308 let serialized = value.to_string();
309 if auth::write_atomic(&target_file, serialized.as_bytes(), auth::SECRET_FILE_MODE).is_err() {
310 if output_json {
311 let _ = output::emit_error(
312 "auth refresh",
313 "refresh-write-failed",
314 format!(
315 "gemini-refresh: failed to write refreshed tokens to {}",
316 target_file.display()
317 ),
318 Some(output::obj(vec![(
319 "target_file",
320 output::s(target_file.display().to_string()),
321 )])),
322 );
323 } else if output_text {
324 eprintln!(
325 "gemini-refresh: failed to write refreshed tokens to {}",
326 target_file.display()
327 );
328 }
329 return 1;
330 }
331
332 if let Some(timestamp_path) = crate::paths::resolve_secret_timestamp_path(&target_file) {
333 let _ = auth::write_timestamp(×tamp_path, Some(&now_iso));
334 }
335
336 let mut synced = false;
337 if is_auth_file(&target_file) {
338 let sync_rc = crate::auth::sync::run_with_json(false);
339 if sync_rc != 0 {
340 if output_json {
341 let _ = output::emit_error(
342 "auth refresh",
343 "sync-failed",
344 "gemini-refresh: failed to sync refreshed auth into matching secrets",
345 Some(output::obj(vec![(
346 "target_file",
347 output::s(target_file.display().to_string()),
348 )])),
349 );
350 }
351 return 6;
352 }
353 synced = true;
354 }
355
356 if output_json {
357 let _ = output::emit_result(
358 "auth refresh",
359 output::obj(vec![
360 ("target_file", output::s(target_file.display().to_string())),
361 ("refreshed", output::b(true)),
362 ("synced", output::b(synced)),
363 ("refreshed_at", output::s(now_iso)),
364 ]),
365 );
366 } else if output_text {
367 println!("gemini: refreshed {} at {}", target_file.display(), now_iso);
368 }
369
370 0
371}
372
373fn merge_failed(output_json: bool, output_text: bool) -> i32 {
374 if output_json {
375 let _ = output::emit_error(
376 "auth refresh",
377 "merge-failed",
378 "gemini-refresh: failed to merge refreshed tokens",
379 None,
380 );
381 } else if output_text {
382 eprintln!("gemini-refresh: failed to merge refreshed tokens");
383 }
384 5
385}
386
387fn merge_refreshed_tokens(
388 base: &mut Value,
389 refresh: &Value,
390 now_iso: &str,
391 current_refresh_token: &str,
392 provider: AuthProvider,
393) -> bool {
394 let google_subject = if provider == AuthProvider::Google {
395 subject_from_json(base)
396 } else {
397 None
398 };
399
400 let Some(root_obj) = base.as_object_mut() else {
401 return false;
402 };
403
404 if root_obj
405 .get("tokens")
406 .and_then(|token_value| token_value.as_object())
407 .is_none()
408 {
409 root_obj.insert("tokens".to_string(), json!({}));
410 }
411
412 let Some(refresh_obj) = refresh.as_object() else {
413 return false;
414 };
415
416 for (key, value) in refresh_obj {
417 if let Some(tokens_obj) = root_obj
418 .get_mut("tokens")
419 .and_then(|token_value| token_value.as_object_mut())
420 {
421 tokens_obj.insert(key.clone(), value.clone());
422 } else {
423 return false;
424 }
425 root_obj.insert(key.clone(), value.clone());
426 }
427
428 if !refresh_obj.contains_key("refresh_token") {
429 if let Some(tokens_obj) = root_obj
430 .get_mut("tokens")
431 .and_then(|token_value| token_value.as_object_mut())
432 {
433 tokens_obj.insert("refresh_token".to_string(), json!(current_refresh_token));
434 } else {
435 return false;
436 }
437 root_obj
438 .entry("refresh_token".to_string())
439 .or_insert_with(|| json!(current_refresh_token));
440 }
441
442 if let Some(expires_in) = refresh_obj
443 .get("expires_in")
444 .and_then(|value| value.as_i64())
445 {
446 let expiry_date = auth::now_epoch_seconds().saturating_add(expires_in) * 1000;
447 root_obj.insert("expiry_date".to_string(), json!(expiry_date));
448 }
449
450 root_obj.insert("last_refresh".to_string(), json!(now_iso));
451
452 if let Some(subject) = google_subject {
453 if let Some(tokens_obj) = root_obj
454 .get_mut("tokens")
455 .and_then(|token_value| token_value.as_object_mut())
456 {
457 tokens_obj.insert("account_id".to_string(), json!(subject.clone()));
458 } else {
459 return false;
460 }
461 root_obj.insert("account_id".to_string(), json!(subject));
462 }
463
464 true
465}
466
467fn resolve_target(args: &[String], output_json: bool) -> Option<PathBuf> {
468 if args.is_empty() {
469 return Some(
470 crate::paths::resolve_auth_file().unwrap_or_else(|| PathBuf::from("auth.json")),
471 );
472 }
473
474 let secret_name = &args[0];
475 if secret_name.is_empty() || secret_name.contains('/') || secret_name.contains("..") {
476 if output_json {
477 let _ = output::emit_error(
478 "auth refresh",
479 "invalid-secret-file-name",
480 format!("gemini-refresh: invalid secret file name: {secret_name}"),
481 Some(output::obj(vec![("secret", output::s(secret_name))])),
482 );
483 } else {
484 eprintln!("gemini-refresh: invalid secret file name: {secret_name}");
485 }
486 return None;
487 }
488
489 let secret_dir = crate::paths::resolve_secret_dir().unwrap_or_default();
490 Some(secret_dir.join(secret_name))
491}
492
493fn split_http_status_marker(raw: &str) -> (String, u16) {
494 let marker = "__HTTP_STATUS__:";
495 if let Some(index) = raw.rfind(marker) {
496 let body = raw[..index]
497 .trim_end_matches('\n')
498 .trim_end_matches('\r')
499 .to_string();
500 let status_raw = raw[index + marker.len()..].trim();
501 let status = status_raw.parse::<u16>().unwrap_or(0);
502 (body, status)
503 } else {
504 (raw.to_string(), 0)
505 }
506}
507
508fn error_summary(body: &str) -> Option<String> {
509 let value = parse_json_text!(body)?;
510 let mut parts = Vec::new();
511
512 if let Some(error) = value.get("error") {
513 if error.is_object() {
514 if let Some(code) = error.get("code").and_then(|v| v.as_str())
515 && !code.is_empty()
516 {
517 parts.push(code.to_string());
518 }
519 if let Some(message) = error.get("message").and_then(|v| v.as_str())
520 && !message.is_empty()
521 {
522 parts.push(message.to_string());
523 }
524 } else if let Some(error_str) = error.as_str()
525 && !error_str.is_empty()
526 {
527 parts.push(error_str.to_string());
528 }
529 }
530
531 if let Some(desc) = value.get("error_description").and_then(|v| v.as_str())
532 && !desc.is_empty()
533 {
534 parts.push(desc.to_string());
535 }
536
537 if parts.is_empty() {
538 None
539 } else {
540 Some(parts.join(": "))
541 }
542}
543
544fn detect_provider(value: &Value) -> AuthProvider {
545 if let Ok(raw) = std::env::var("GEMINI_OAUTH_PROVIDER") {
546 let normalized = raw.trim().to_ascii_lowercase();
547 if normalized == "google" || normalized == "gemini" {
548 return AuthProvider::Google;
549 }
550 if normalized == "openai" {
551 return AuthProvider::OpenAi;
552 }
553 }
554
555 let payload = id_payload_from_json(value);
556 let iss = payload
557 .as_ref()
558 .and_then(|payload| payload.get("iss"))
559 .and_then(|value| value.as_str())
560 .unwrap_or_default()
561 .to_ascii_lowercase();
562 if iss.contains("accounts.google.com") {
563 return AuthProvider::Google;
564 }
565
566 let aud = payload
567 .as_ref()
568 .and_then(|payload| payload.get("aud"))
569 .and_then(|value| value.as_str())
570 .unwrap_or_default()
571 .to_ascii_lowercase();
572 if aud.ends_with(".apps.googleusercontent.com") {
573 return AuthProvider::Google;
574 }
575
576 AuthProvider::OpenAi
577}
578
579fn resolve_client_id(provider: AuthProvider, value: &Value) -> String {
580 if let Ok(raw) = std::env::var("GEMINI_OAUTH_CLIENT_ID")
581 && !raw.trim().is_empty()
582 {
583 return raw;
584 }
585
586 if provider == AuthProvider::Google
587 && let Some(aud) = id_payload_from_json(value).and_then(|payload| {
588 payload
589 .get("aud")
590 .and_then(|value| value.as_str())
591 .map(str::to_string)
592 })
593 && !aud.trim().is_empty()
594 {
595 return aud;
596 }
597
598 match provider {
599 AuthProvider::Google => GOOGLE_DEFAULT_CLIENT_ID.to_string(),
600 AuthProvider::OpenAi => OPENAI_DEFAULT_CLIENT_ID.to_string(),
601 }
602}
603
604fn subject_from_json(value: &Value) -> Option<String> {
605 id_payload_from_json(value)
606 .and_then(|payload| {
607 payload
608 .get("sub")
609 .and_then(|value| value.as_str())
610 .map(str::to_string)
611 })
612 .map(|subject| crate::json::strip_newlines(&subject))
613}
614
615fn id_payload_from_json(value: &Value) -> Option<Value> {
616 let token = crate::json::string_at(value, &["tokens", "id_token"])
617 .or_else(|| crate::json::string_at(value, &["id_token"]))?;
618 crate::jwt::decode_payload_json(&token)
619}
620
621fn env_timeout(key: &str, default: u64) -> u64 {
622 std::env::var(key)
623 .ok()
624 .and_then(|raw| raw.parse::<u64>().ok())
625 .unwrap_or(default)
626}
627
628#[cfg(test)]
629fn file_name(path: &Path) -> String {
630 path.file_name()
631 .and_then(|name| name.to_str())
632 .unwrap_or("auth.json")
633 .to_string()
634}
635
636fn is_auth_file(target: &Path) -> bool {
637 if let Some(auth_file) = crate::paths::resolve_auth_file()
638 && auth_file == target
639 {
640 return true;
641 }
642 false
643}
644
645#[cfg(test)]
646mod tests {
647 use super::{
648 AuthProvider, detect_provider, env_timeout, error_summary, file_name, is_auth_file,
649 merge_failed, resolve_client_id, resolve_target, split_http_status_marker,
650 };
651 use nils_test_support::{EnvGuard, GlobalStateLock};
652 use std::path::Path;
653
654 #[test]
655 fn split_http_status_extracts_marker() {
656 let (body, status) = split_http_status_marker("{\"ok\":true}\n__HTTP_STATUS__:200");
657 assert_eq!(body, "{\"ok\":true}");
658 assert_eq!(status, 200);
659 }
660
661 #[test]
662 fn split_http_status_without_marker_returns_zero_status() {
663 let (body, status) = split_http_status_marker("{\"ok\":true}");
664 assert_eq!(body, "{\"ok\":true}");
665 assert_eq!(status, 0);
666 }
667
668 #[test]
669 fn env_timeout_uses_default_when_missing_or_invalid() {
670 let lock = GlobalStateLock::new();
671 let key = "GEMINI_TEST_ENV_TIMEOUT_SECONDS_DEFAULT";
672 {
673 let _guard = EnvGuard::remove(&lock, key);
674 assert_eq!(env_timeout(key, 123), 123);
675 }
676 {
677 let _guard = EnvGuard::set(&lock, key, "not-a-number");
678 assert_eq!(env_timeout(key, 456), 456);
679 }
680 }
681
682 #[test]
683 fn file_name_defaults_when_missing() {
684 assert_eq!(file_name(Path::new("")), "auth.json");
685 }
686
687 #[test]
688 fn resolve_target_rejects_invalid_secret_name() {
689 let args = vec!["../bad.json".to_string()];
690 assert!(resolve_target(&args, false).is_none());
691 }
692
693 #[test]
694 fn resolve_target_uses_default_auth_path_when_env_missing() {
695 let lock = GlobalStateLock::new();
696 let key = "GEMINI_AUTH_FILE";
697 let home_key = "HOME";
698 let temp_home = std::env::temp_dir().join(format!(
699 "nils-gemini-refresh-home-{}-{}",
700 std::process::id(),
701 super::auth::now_epoch_seconds()
702 ));
703 let _ = std::fs::create_dir_all(&temp_home);
704 let _auth_file_guard = EnvGuard::remove(&lock, key);
705 let temp_home_string = temp_home.to_string_lossy().to_string();
706 let _home_guard = EnvGuard::set(&lock, home_key, &temp_home_string);
707 let resolved = resolve_target(&[], false).expect("resolved path");
708 assert!(resolved.ends_with("oauth_creds.json"));
709 let _ = std::fs::remove_dir_all(temp_home);
710 }
711
712 #[test]
713 fn error_summary_extracts_object_and_description() {
714 let body = r#"{"error":{"code":"invalid_grant","message":"expired"},"error_description":"reauth"}"#;
715 let summary = error_summary(body).expect("summary");
716 assert!(summary.contains("invalid_grant"));
717 assert!(summary.contains("expired"));
718 assert!(summary.contains("reauth"));
719 }
720
721 #[test]
722 fn error_summary_supports_string_error_field() {
723 let body = r#"{"error":"bad_request"}"#;
724 assert_eq!(error_summary(body).as_deref(), Some("bad_request"));
725 }
726
727 #[test]
728 fn is_auth_file_matches_env_path() {
729 let lock = GlobalStateLock::new();
730 let key = "GEMINI_AUTH_FILE";
731 let _guard = EnvGuard::set(&lock, key, "/tmp/gemini-auth.json");
732 assert!(is_auth_file(Path::new("/tmp/gemini-auth.json")));
733 }
734
735 #[test]
736 fn merge_failed_always_returns_exit_code_five() {
737 assert_eq!(merge_failed(false, true), 5);
738 assert_eq!(merge_failed(true, false), 5);
739 }
740
741 #[test]
742 fn detect_provider_prefers_google_issuer_and_audience() {
743 let google: serde_json::Value = serde_json::json!({
744 "id_token": "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhdWQiOiJhYmMuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20ifQ.sig"
745 });
746 assert!(matches!(detect_provider(&google), AuthProvider::Google));
747
748 let openai: serde_json::Value = serde_json::json!({
749 "id_token": "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpc3MiOiJodHRwczovL2F1dGgub3BlbmFpLmNvbSJ9.sig"
750 });
751 assert!(matches!(detect_provider(&openai), AuthProvider::OpenAi));
752 }
753
754 #[test]
755 fn resolve_client_id_uses_google_audience_when_available() {
756 let value: serde_json::Value = serde_json::json!({
757 "id_token": "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJhdWQiOiJhYmMta2V5LmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29tIn0.sig"
758 });
759 let client_id = resolve_client_id(AuthProvider::Google, &value);
760 assert_eq!(client_id, "abc-key.apps.googleusercontent.com");
761 }
762}