Skip to main content

gemini_cli/auth/
refresh.rs

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(&timestamp_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}