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(cache_dir) = crate::paths::resolve_secret_cache_dir() {
333        let timestamp_path = cache_dir.join(format!("{}.timestamp", file_name(&target_file)));
334        let _ = auth::write_timestamp(&timestamp_path, Some(&now_iso));
335    }
336
337    let mut synced = false;
338    if is_auth_file(&target_file) {
339        let sync_rc = crate::auth::sync::run_with_json(false);
340        if sync_rc != 0 {
341            if output_json {
342                let _ = output::emit_error(
343                    "auth refresh",
344                    "sync-failed",
345                    "gemini-refresh: failed to sync refreshed auth into matching secrets",
346                    Some(output::obj(vec![(
347                        "target_file",
348                        output::s(target_file.display().to_string()),
349                    )])),
350                );
351            }
352            return 6;
353        }
354        synced = true;
355    }
356
357    if output_json {
358        let _ = output::emit_result(
359            "auth refresh",
360            output::obj(vec![
361                ("target_file", output::s(target_file.display().to_string())),
362                ("refreshed", output::b(true)),
363                ("synced", output::b(synced)),
364                ("refreshed_at", output::s(now_iso)),
365            ]),
366        );
367    } else if output_text {
368        println!("gemini: refreshed {} at {}", target_file.display(), now_iso);
369    }
370
371    0
372}
373
374fn merge_failed(output_json: bool, output_text: bool) -> i32 {
375    if output_json {
376        let _ = output::emit_error(
377            "auth refresh",
378            "merge-failed",
379            "gemini-refresh: failed to merge refreshed tokens",
380            None,
381        );
382    } else if output_text {
383        eprintln!("gemini-refresh: failed to merge refreshed tokens");
384    }
385    5
386}
387
388fn merge_refreshed_tokens(
389    base: &mut Value,
390    refresh: &Value,
391    now_iso: &str,
392    current_refresh_token: &str,
393    provider: AuthProvider,
394) -> bool {
395    let google_subject = if provider == AuthProvider::Google {
396        subject_from_json(base)
397    } else {
398        None
399    };
400
401    let Some(root_obj) = base.as_object_mut() else {
402        return false;
403    };
404
405    if root_obj
406        .get("tokens")
407        .and_then(|token_value| token_value.as_object())
408        .is_none()
409    {
410        root_obj.insert("tokens".to_string(), json!({}));
411    }
412
413    let Some(refresh_obj) = refresh.as_object() else {
414        return false;
415    };
416
417    for (key, value) in refresh_obj {
418        if let Some(tokens_obj) = root_obj
419            .get_mut("tokens")
420            .and_then(|token_value| token_value.as_object_mut())
421        {
422            tokens_obj.insert(key.clone(), value.clone());
423        } else {
424            return false;
425        }
426        root_obj.insert(key.clone(), value.clone());
427    }
428
429    if !refresh_obj.contains_key("refresh_token") {
430        if let Some(tokens_obj) = root_obj
431            .get_mut("tokens")
432            .and_then(|token_value| token_value.as_object_mut())
433        {
434            tokens_obj.insert("refresh_token".to_string(), json!(current_refresh_token));
435        } else {
436            return false;
437        }
438        root_obj
439            .entry("refresh_token".to_string())
440            .or_insert_with(|| json!(current_refresh_token));
441    }
442
443    if let Some(expires_in) = refresh_obj
444        .get("expires_in")
445        .and_then(|value| value.as_i64())
446    {
447        let expiry_date = auth::now_epoch_seconds().saturating_add(expires_in) * 1000;
448        root_obj.insert("expiry_date".to_string(), json!(expiry_date));
449    }
450
451    root_obj.insert("last_refresh".to_string(), json!(now_iso));
452
453    if let Some(subject) = google_subject {
454        if let Some(tokens_obj) = root_obj
455            .get_mut("tokens")
456            .and_then(|token_value| token_value.as_object_mut())
457        {
458            tokens_obj.insert("account_id".to_string(), json!(subject.clone()));
459        } else {
460            return false;
461        }
462        root_obj.insert("account_id".to_string(), json!(subject));
463    }
464
465    true
466}
467
468fn resolve_target(args: &[String], output_json: bool) -> Option<PathBuf> {
469    if args.is_empty() {
470        return Some(
471            crate::paths::resolve_auth_file().unwrap_or_else(|| PathBuf::from("auth.json")),
472        );
473    }
474
475    let secret_name = &args[0];
476    if secret_name.is_empty() || secret_name.contains('/') || secret_name.contains("..") {
477        if output_json {
478            let _ = output::emit_error(
479                "auth refresh",
480                "invalid-secret-file-name",
481                format!("gemini-refresh: invalid secret file name: {secret_name}"),
482                Some(output::obj(vec![("secret", output::s(secret_name))])),
483            );
484        } else {
485            eprintln!("gemini-refresh: invalid secret file name: {secret_name}");
486        }
487        return None;
488    }
489
490    let secret_dir = crate::paths::resolve_secret_dir().unwrap_or_default();
491    Some(secret_dir.join(secret_name))
492}
493
494fn split_http_status_marker(raw: &str) -> (String, u16) {
495    let marker = "__HTTP_STATUS__:";
496    if let Some(index) = raw.rfind(marker) {
497        let body = raw[..index]
498            .trim_end_matches('\n')
499            .trim_end_matches('\r')
500            .to_string();
501        let status_raw = raw[index + marker.len()..].trim();
502        let status = status_raw.parse::<u16>().unwrap_or(0);
503        (body, status)
504    } else {
505        (raw.to_string(), 0)
506    }
507}
508
509fn error_summary(body: &str) -> Option<String> {
510    let value = parse_json_text!(body)?;
511    let mut parts = Vec::new();
512
513    if let Some(error) = value.get("error") {
514        if error.is_object() {
515            if let Some(code) = error.get("code").and_then(|v| v.as_str())
516                && !code.is_empty()
517            {
518                parts.push(code.to_string());
519            }
520            if let Some(message) = error.get("message").and_then(|v| v.as_str())
521                && !message.is_empty()
522            {
523                parts.push(message.to_string());
524            }
525        } else if let Some(error_str) = error.as_str()
526            && !error_str.is_empty()
527        {
528            parts.push(error_str.to_string());
529        }
530    }
531
532    if let Some(desc) = value.get("error_description").and_then(|v| v.as_str())
533        && !desc.is_empty()
534    {
535        parts.push(desc.to_string());
536    }
537
538    if parts.is_empty() {
539        None
540    } else {
541        Some(parts.join(": "))
542    }
543}
544
545fn detect_provider(value: &Value) -> AuthProvider {
546    if let Ok(raw) = std::env::var("GEMINI_OAUTH_PROVIDER") {
547        let normalized = raw.trim().to_ascii_lowercase();
548        if normalized == "google" || normalized == "gemini" {
549            return AuthProvider::Google;
550        }
551        if normalized == "openai" {
552            return AuthProvider::OpenAi;
553        }
554    }
555
556    let payload = id_payload_from_json(value);
557    let iss = payload
558        .as_ref()
559        .and_then(|payload| payload.get("iss"))
560        .and_then(|value| value.as_str())
561        .unwrap_or_default()
562        .to_ascii_lowercase();
563    if iss.contains("accounts.google.com") {
564        return AuthProvider::Google;
565    }
566
567    let aud = payload
568        .as_ref()
569        .and_then(|payload| payload.get("aud"))
570        .and_then(|value| value.as_str())
571        .unwrap_or_default()
572        .to_ascii_lowercase();
573    if aud.ends_with(".apps.googleusercontent.com") {
574        return AuthProvider::Google;
575    }
576
577    AuthProvider::OpenAi
578}
579
580fn resolve_client_id(provider: AuthProvider, value: &Value) -> String {
581    if let Ok(raw) = std::env::var("GEMINI_OAUTH_CLIENT_ID")
582        && !raw.trim().is_empty()
583    {
584        return raw;
585    }
586
587    if provider == AuthProvider::Google
588        && let Some(aud) = id_payload_from_json(value).and_then(|payload| {
589            payload
590                .get("aud")
591                .and_then(|value| value.as_str())
592                .map(str::to_string)
593        })
594        && !aud.trim().is_empty()
595    {
596        return aud;
597    }
598
599    match provider {
600        AuthProvider::Google => GOOGLE_DEFAULT_CLIENT_ID.to_string(),
601        AuthProvider::OpenAi => OPENAI_DEFAULT_CLIENT_ID.to_string(),
602    }
603}
604
605fn subject_from_json(value: &Value) -> Option<String> {
606    id_payload_from_json(value)
607        .and_then(|payload| {
608            payload
609                .get("sub")
610                .and_then(|value| value.as_str())
611                .map(str::to_string)
612        })
613        .map(|subject| crate::json::strip_newlines(&subject))
614}
615
616fn id_payload_from_json(value: &Value) -> Option<Value> {
617    let token = crate::json::string_at(value, &["tokens", "id_token"])
618        .or_else(|| crate::json::string_at(value, &["id_token"]))?;
619    crate::jwt::decode_payload_json(&token)
620}
621
622fn env_timeout(key: &str, default: u64) -> u64 {
623    std::env::var(key)
624        .ok()
625        .and_then(|raw| raw.parse::<u64>().ok())
626        .unwrap_or(default)
627}
628
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 std::path::Path;
652
653    #[test]
654    fn split_http_status_extracts_marker() {
655        let (body, status) = split_http_status_marker("{\"ok\":true}\n__HTTP_STATUS__:200");
656        assert_eq!(body, "{\"ok\":true}");
657        assert_eq!(status, 200);
658    }
659
660    #[test]
661    fn split_http_status_without_marker_returns_zero_status() {
662        let (body, status) = split_http_status_marker("{\"ok\":true}");
663        assert_eq!(body, "{\"ok\":true}");
664        assert_eq!(status, 0);
665    }
666
667    #[test]
668    fn env_timeout_uses_default_when_missing_or_invalid() {
669        let _lock = crate::auth::test_env_lock();
670        let key = "GEMINI_TEST_ENV_TIMEOUT_SECONDS_DEFAULT";
671        // SAFETY: test-scoped env mutation.
672        unsafe { std::env::remove_var(key) };
673        assert_eq!(env_timeout(key, 123), 123);
674
675        // SAFETY: test-scoped env mutation.
676        unsafe { std::env::set_var(key, "not-a-number") };
677        assert_eq!(env_timeout(key, 456), 456);
678
679        // SAFETY: test-scoped env cleanup.
680        unsafe { std::env::remove_var(key) };
681    }
682
683    #[test]
684    fn file_name_defaults_when_missing() {
685        assert_eq!(file_name(Path::new("")), "auth.json");
686    }
687
688    #[test]
689    fn resolve_target_rejects_invalid_secret_name() {
690        let args = vec!["../bad.json".to_string()];
691        assert!(resolve_target(&args, false).is_none());
692    }
693
694    #[test]
695    fn resolve_target_uses_default_auth_path_when_env_missing() {
696        let _lock = crate::auth::test_env_lock();
697        let key = "GEMINI_AUTH_FILE";
698        let home_key = "HOME";
699        let old = std::env::var_os(key);
700        let old_home = std::env::var_os(home_key);
701        let temp_home = std::env::temp_dir().join(format!(
702            "nils-gemini-refresh-home-{}-{}",
703            std::process::id(),
704            super::auth::now_epoch_seconds()
705        ));
706        let _ = std::fs::create_dir_all(&temp_home);
707        // SAFETY: test-scoped env mutation.
708        unsafe { std::env::remove_var(key) };
709        // SAFETY: test-scoped env mutation.
710        unsafe { std::env::set_var(home_key, &temp_home) };
711        let resolved = resolve_target(&[], false).expect("resolved path");
712        assert!(resolved.ends_with("oauth_creds.json"));
713        if let Some(value) = old {
714            // SAFETY: test-scoped env restore.
715            unsafe { std::env::set_var(key, value) };
716        } else {
717            // SAFETY: test-scoped env restore.
718            unsafe { std::env::remove_var(key) };
719        }
720        if let Some(value) = old_home {
721            // SAFETY: test-scoped env restore.
722            unsafe { std::env::set_var(home_key, value) };
723        } else {
724            // SAFETY: test-scoped env restore.
725            unsafe { std::env::remove_var(home_key) };
726        }
727        let _ = std::fs::remove_dir_all(temp_home);
728    }
729
730    #[test]
731    fn error_summary_extracts_object_and_description() {
732        let body = r#"{"error":{"code":"invalid_grant","message":"expired"},"error_description":"reauth"}"#;
733        let summary = error_summary(body).expect("summary");
734        assert!(summary.contains("invalid_grant"));
735        assert!(summary.contains("expired"));
736        assert!(summary.contains("reauth"));
737    }
738
739    #[test]
740    fn error_summary_supports_string_error_field() {
741        let body = r#"{"error":"bad_request"}"#;
742        assert_eq!(error_summary(body).as_deref(), Some("bad_request"));
743    }
744
745    #[test]
746    fn is_auth_file_matches_env_path() {
747        let _lock = crate::auth::test_env_lock();
748        let key = "GEMINI_AUTH_FILE";
749        let old = std::env::var_os(key);
750        // SAFETY: test-scoped env mutation.
751        unsafe { std::env::set_var(key, "/tmp/gemini-auth.json") };
752        assert!(is_auth_file(Path::new("/tmp/gemini-auth.json")));
753        if let Some(value) = old {
754            // SAFETY: test-scoped env restore.
755            unsafe { std::env::set_var(key, value) };
756        } else {
757            // SAFETY: test-scoped env restore.
758            unsafe { std::env::remove_var(key) };
759        }
760    }
761
762    #[test]
763    fn merge_failed_always_returns_exit_code_five() {
764        assert_eq!(merge_failed(false, true), 5);
765        assert_eq!(merge_failed(true, false), 5);
766    }
767
768    #[test]
769    fn detect_provider_prefers_google_issuer_and_audience() {
770        let google: serde_json::Value = serde_json::json!({
771            "id_token": "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhdWQiOiJhYmMuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20ifQ.sig"
772        });
773        assert!(matches!(detect_provider(&google), AuthProvider::Google));
774
775        let openai: serde_json::Value = serde_json::json!({
776            "id_token": "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpc3MiOiJodHRwczovL2F1dGgub3BlbmFpLmNvbSJ9.sig"
777        });
778        assert!(matches!(detect_provider(&openai), AuthProvider::OpenAi));
779    }
780
781    #[test]
782    fn resolve_client_id_uses_google_audience_when_available() {
783        let value: serde_json::Value = serde_json::json!({
784            "id_token": "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJhdWQiOiJhYmMta2V5LmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29tIn0.sig"
785        });
786        let client_id = resolve_client_id(AuthProvider::Google, &value);
787        assert_eq!(client_id, "abc-key.apps.googleusercontent.com");
788    }
789}