Skip to main content

gemini_cli/auth/
login.rs

1use std::io::IsTerminal;
2use std::path::Path;
3use std::process::Command;
4use std::{fs, io};
5
6use crate::auth::output;
7
8const GOOGLE_USERINFO_URL: &str = "https://openidconnect.googleapis.com/v1/userinfo";
9
10#[derive(Copy, Clone, Debug, Eq, PartialEq)]
11enum LoginMethod {
12    GeminiBrowser,
13    GeminiDeviceCode,
14    ApiKey,
15}
16
17pub fn run(api_key: bool, device_code: bool) -> i32 {
18    run_with_json(api_key, device_code, false)
19}
20
21pub fn run_with_json(api_key: bool, device_code: bool, output_json: bool) -> i32 {
22    let method = match resolve_method(api_key, device_code) {
23        Ok(method) => method,
24        Err((code, message, details)) => {
25            if output_json {
26                let _ = output::emit_error("auth login", "invalid-usage", message, details);
27            } else {
28                eprintln!("{message}");
29            }
30            return code;
31        }
32    };
33
34    if method == LoginMethod::ApiKey {
35        return run_api_key_login(output_json);
36    }
37
38    run_oauth_login(method, output_json)
39}
40
41fn run_api_key_login(output_json: bool) -> i32 {
42    let source = if std::env::var("GEMINI_API_KEY")
43        .ok()
44        .filter(|value| !value.trim().is_empty())
45        .is_some()
46    {
47        Some("GEMINI_API_KEY")
48    } else if std::env::var("GOOGLE_API_KEY")
49        .ok()
50        .filter(|value| !value.trim().is_empty())
51        .is_some()
52    {
53        Some("GOOGLE_API_KEY")
54    } else {
55        None
56    };
57
58    let Some(source) = source else {
59        if output_json {
60            let _ = output::emit_error(
61                "auth login",
62                "missing-api-key",
63                "gemini-login: set GEMINI_API_KEY or GOOGLE_API_KEY before using --api-key",
64                None,
65            );
66        } else {
67            eprintln!("gemini-login: set GEMINI_API_KEY or GOOGLE_API_KEY before using --api-key");
68        }
69        return 64;
70    };
71
72    if output_json {
73        let _ = output::emit_result(
74            "auth login",
75            output::obj(vec![
76                ("method", output::s("api-key")),
77                ("provider", output::s("gemini-api")),
78                ("completed", output::b(true)),
79                ("source", output::s(source)),
80            ]),
81        );
82    } else {
83        println!("gemini: login complete (method: api-key)");
84    }
85
86    0
87}
88
89fn run_oauth_login(method: LoginMethod, output_json: bool) -> i32 {
90    if output_json {
91        return run_oauth_session_check(method, true);
92    }
93
94    let interactive_terminal = std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
95    if !interactive_terminal {
96        // Keep non-interactive automation stable.
97        return run_oauth_session_check(method, false);
98    }
99
100    run_oauth_interactive_login(method)
101}
102
103fn run_oauth_session_check(method: LoginMethod, output_json: bool) -> i32 {
104    let auth_file = match crate::paths::resolve_auth_file() {
105        Some(path) => path,
106        None => {
107            emit_login_error(
108                output_json,
109                "auth-file-not-configured",
110                "gemini-login: GEMINI_AUTH_FILE is not configured".to_string(),
111                None,
112            );
113            return 1;
114        }
115    };
116
117    if !auth_file.is_file() {
118        emit_login_error(
119            output_json,
120            "auth-file-not-found",
121            format!("gemini-login: auth file not found: {}", auth_file.display()),
122            Some(output::obj(vec![(
123                "auth_file",
124                output::s(auth_file.display().to_string()),
125            )])),
126        );
127        return 1;
128    }
129
130    let mut refresh_attempted = false;
131    if has_refresh_token(&auth_file) {
132        refresh_attempted = true;
133        // Refresh failures are tolerated here if the current access token remains valid.
134        let _ = crate::auth::refresh::run_silent(&[]);
135    }
136
137    let auth_json = match crate::json::read_json(&auth_file) {
138        Ok(value) => value,
139        Err(err) => {
140            emit_login_error(
141                output_json,
142                "auth-read-failed",
143                format!(
144                    "gemini-login: failed to read auth file {}",
145                    auth_file.display()
146                ),
147                Some(output::obj(vec![
148                    ("auth_file", output::s(auth_file.display().to_string())),
149                    ("error", output::s(err.to_string())),
150                ])),
151            );
152            return 1;
153        }
154    };
155
156    let access_token = access_token_from_json(&auth_json);
157    let access_token = match access_token {
158        Some(token) => token,
159        None => {
160            emit_login_error(
161                output_json,
162                "missing-access-token",
163                format!(
164                    "gemini-login: missing access token in {}",
165                    auth_file.display()
166                ),
167                Some(output::obj(vec![(
168                    "auth_file",
169                    output::s(auth_file.display().to_string()),
170                )])),
171            );
172            return 2;
173        }
174    };
175
176    let userinfo = match fetch_google_userinfo(&access_token) {
177        Ok(value) => value,
178        Err(err) => {
179            emit_login_error(output_json, err.code, err.message, err.details);
180            return err.exit_code;
181        }
182    };
183
184    let email = userinfo
185        .get("email")
186        .and_then(|value| value.as_str())
187        .unwrap_or_default()
188        .to_string();
189
190    if output_json {
191        let _ = output::emit_result(
192            "auth login",
193            output::obj(vec![
194                ("method", output::s(method.as_str())),
195                ("provider", output::s(method.provider())),
196                ("completed", output::b(true)),
197                ("auth_file", output::s(auth_file.display().to_string())),
198                (
199                    "email",
200                    if email.is_empty() {
201                        output::null()
202                    } else {
203                        output::s(email)
204                    },
205                ),
206                ("refresh_attempted", output::b(refresh_attempted)),
207            ]),
208        );
209    } else {
210        println!("gemini: login complete (method: {})", method.as_str());
211    }
212
213    0
214}
215
216fn run_oauth_interactive_login(method: LoginMethod) -> i32 {
217    let auth_file = match crate::paths::resolve_auth_file() {
218        Some(path) => path,
219        None => {
220            emit_login_error(
221                false,
222                "auth-file-not-configured",
223                "gemini-login: GEMINI_AUTH_FILE is not configured".to_string(),
224                None,
225            );
226            return 1;
227        }
228    };
229
230    let backup = match backup_auth_file(&auth_file) {
231        Ok(backup) => backup,
232        Err(err) => {
233            emit_login_error(false, "auth-read-failed", err.to_string(), None);
234            return 1;
235        }
236    };
237
238    if let Some(parent) = auth_file.parent()
239        && let Err(err) = fs::create_dir_all(parent)
240    {
241        emit_login_error(
242            false,
243            "auth-dir-create-failed",
244            format!(
245                "gemini-login: failed to prepare auth directory {}: {err}",
246                parent.display()
247            ),
248            Some(output::obj(vec![(
249                "auth_file",
250                output::s(auth_file.display().to_string()),
251            )])),
252        );
253        return 1;
254    }
255
256    if auth_file.is_file()
257        && let Err(err) = fs::remove_file(&auth_file)
258    {
259        emit_login_error(
260            false,
261            "auth-file-remove-failed",
262            format!(
263                "gemini-login: failed to remove auth file {}: {err}",
264                auth_file.display()
265            ),
266            Some(output::obj(vec![(
267                "auth_file",
268                output::s(auth_file.display().to_string()),
269            )])),
270        );
271        return 1;
272    }
273
274    if method == LoginMethod::GeminiBrowser {
275        println!("Code Assist login required. Opening authentication page in your browser.");
276    }
277
278    let status = match run_gemini_interactive_login(method, &auth_file) {
279        Ok(status) => status,
280        Err(err) => {
281            let _ = restore_auth_backup(&auth_file, backup.as_deref());
282            emit_login_error(false, err.code, err.message, err.details);
283            return err.exit_code;
284        }
285    };
286
287    if !status.success() {
288        let _ = restore_auth_backup(&auth_file, backup.as_deref());
289        let exit_code = status.code().unwrap_or(1).max(1);
290        emit_login_error(
291            false,
292            "login-failed",
293            format!("gemini-login: login failed for method {}", method.as_str()),
294            Some(output::obj(vec![
295                ("method", output::s(method.as_str())),
296                ("exit_code", output::n(i64::from(exit_code))),
297            ])),
298        );
299        return exit_code;
300    }
301
302    let auth_json = match crate::json::read_json(&auth_file) {
303        Ok(value) => value,
304        Err(err) => {
305            let _ = restore_auth_backup(&auth_file, backup.as_deref());
306            emit_login_error(
307                false,
308                "auth-read-failed",
309                format!(
310                    "gemini-login: login completed but failed to read auth file {}: {err}",
311                    auth_file.display()
312                ),
313                Some(output::obj(vec![(
314                    "auth_file",
315                    output::s(auth_file.display().to_string()),
316                )])),
317            );
318            return 1;
319        }
320    };
321
322    let access_token = match access_token_from_json(&auth_json) {
323        Some(token) => token,
324        None => {
325            let _ = restore_auth_backup(&auth_file, backup.as_deref());
326            emit_login_error(
327                false,
328                "missing-access-token",
329                format!(
330                    "gemini-login: login completed but auth file is missing access token: {}",
331                    auth_file.display()
332                ),
333                Some(output::obj(vec![(
334                    "auth_file",
335                    output::s(auth_file.display().to_string()),
336                )])),
337            );
338            return 2;
339        }
340    };
341
342    if let Err(err) = fetch_google_userinfo(&access_token) {
343        let _ = restore_auth_backup(&auth_file, backup.as_deref());
344        emit_login_error(false, err.code, err.message, err.details);
345        return err.exit_code;
346    }
347
348    println!("gemini: login complete (method: {})", method.as_str());
349    0
350}
351
352fn backup_auth_file(path: &Path) -> io::Result<Option<Vec<u8>>> {
353    if !path.is_file() {
354        return Ok(None);
355    }
356    fs::read(path).map(Some)
357}
358
359fn restore_auth_backup(path: &Path, backup: Option<&[u8]>) -> io::Result<()> {
360    match backup {
361        Some(contents) => crate::auth::write_atomic(path, contents, crate::auth::SECRET_FILE_MODE),
362        None => {
363            if path.is_file() {
364                fs::remove_file(path)
365            } else {
366                Ok(())
367            }
368        }
369    }
370}
371
372fn run_gemini_interactive_login(
373    method: LoginMethod,
374    auth_file: &Path,
375) -> Result<std::process::ExitStatus, LoginError> {
376    let mut command = Command::new("gemini");
377    command.arg("--prompt-interactive").arg("/quit");
378    if method == LoginMethod::GeminiBrowser {
379        // Auto-accept the browser launch prompt without shell-side input hacks.
380        command.arg("--yolo");
381    }
382    command.env("GEMINI_AUTH_FILE", auth_file.to_string_lossy().to_string());
383
384    if method == LoginMethod::GeminiDeviceCode {
385        command.env("NO_BROWSER", "true");
386    } else {
387        command.env_remove("NO_BROWSER");
388    }
389
390    let status = command.status().map_err(|_| LoginError {
391        code: "login-exec-failed",
392        message: format!(
393            "gemini-login: failed to run `gemini` for method {}",
394            method.as_str()
395        ),
396        details: Some(output::obj(vec![("method", output::s(method.as_str()))])),
397        exit_code: 1,
398    })?;
399
400    if !auth_file.is_file() {
401        return Err(LoginError {
402            code: "auth-file-not-found",
403            message: format!(
404                "gemini-login: interactive login did not produce auth file: {}",
405                auth_file.display()
406            ),
407            details: Some(output::obj(vec![
408                ("method", output::s(method.as_str())),
409                ("auth_file", output::s(auth_file.display().to_string())),
410                ("exit_code", output::n(status.code().unwrap_or(0) as i64)),
411            ])),
412            exit_code: 1,
413        });
414    }
415
416    Ok(status)
417}
418
419struct LoginError {
420    code: &'static str,
421    message: String,
422    details: Option<output::JsonValue>,
423    exit_code: i32,
424}
425
426fn fetch_google_userinfo(access_token: &str) -> Result<serde_json::Value, LoginError> {
427    let connect_timeout = env_timeout("GEMINI_LOGIN_CURL_CONNECT_TIMEOUT_SECONDS", 2);
428    let max_time = env_timeout("GEMINI_LOGIN_CURL_MAX_TIME_SECONDS", 8);
429
430    let response = Command::new("curl")
431        .arg("-sS")
432        .arg("--connect-timeout")
433        .arg(connect_timeout.to_string())
434        .arg("--max-time")
435        .arg(max_time.to_string())
436        .arg("-H")
437        .arg(format!("Authorization: Bearer {access_token}"))
438        .arg("-H")
439        .arg("Accept: application/json")
440        .arg(GOOGLE_USERINFO_URL)
441        .arg("-w")
442        .arg("\n__HTTP_STATUS__:%{http_code}")
443        .output()
444        .map_err(|_| LoginError {
445            code: "login-request-failed",
446            message: format!("gemini-login: failed to query {GOOGLE_USERINFO_URL}"),
447            details: Some(output::obj(vec![(
448                "endpoint",
449                output::s(GOOGLE_USERINFO_URL),
450            )])),
451            exit_code: 3,
452        })?;
453
454    if !response.status.success() {
455        return Err(LoginError {
456            code: "login-request-failed",
457            message: format!("gemini-login: failed to query {GOOGLE_USERINFO_URL}"),
458            details: Some(output::obj(vec![(
459                "endpoint",
460                output::s(GOOGLE_USERINFO_URL),
461            )])),
462            exit_code: 3,
463        });
464    }
465
466    let response_text = String::from_utf8_lossy(&response.stdout).to_string();
467    let (body, http_status) = split_http_status_marker(&response_text);
468    if http_status != 200 {
469        let summary = http_error_summary(&body);
470        let mut details = vec![
471            ("endpoint".to_string(), output::s(GOOGLE_USERINFO_URL)),
472            ("http_status".to_string(), output::n(http_status as i64)),
473        ];
474        if let Some(summary) = summary {
475            details.push(("summary".to_string(), output::s(summary)));
476        }
477        return Err(LoginError {
478            code: "login-http-error",
479            message: format!(
480                "gemini-login: userinfo request failed (HTTP {http_status}) at {GOOGLE_USERINFO_URL}"
481            ),
482            details: Some(output::obj_dynamic(details)),
483            exit_code: 3,
484        });
485    }
486
487    let json: serde_json::Value = serde_json::from_str(&body).map_err(|_| LoginError {
488        code: "login-invalid-json",
489        message: "gemini-login: userinfo endpoint returned invalid JSON".to_string(),
490        details: Some(output::obj(vec![(
491            "endpoint",
492            output::s(GOOGLE_USERINFO_URL),
493        )])),
494        exit_code: 4,
495    })?;
496    Ok(json)
497}
498
499fn has_refresh_token(auth_file: &Path) -> bool {
500    let value = match crate::json::read_json(auth_file) {
501        Ok(value) => value,
502        Err(_) => return false,
503    };
504    refresh_token_from_json(&value).is_some()
505}
506
507fn access_token_from_json(value: &serde_json::Value) -> Option<String> {
508    crate::json::string_at(value, &["tokens", "access_token"])
509        .or_else(|| crate::json::string_at(value, &["access_token"]))
510}
511
512fn refresh_token_from_json(value: &serde_json::Value) -> Option<String> {
513    crate::json::string_at(value, &["tokens", "refresh_token"])
514        .or_else(|| crate::json::string_at(value, &["refresh_token"]))
515}
516
517fn split_http_status_marker(raw: &str) -> (String, u16) {
518    let marker = "__HTTP_STATUS__:";
519    if let Some(index) = raw.rfind(marker) {
520        let body = raw[..index]
521            .trim_end_matches('\n')
522            .trim_end_matches('\r')
523            .to_string();
524        let status_raw = raw[index + marker.len()..].trim();
525        let status = status_raw.parse::<u16>().unwrap_or(0);
526        (body, status)
527    } else {
528        (raw.to_string(), 0)
529    }
530}
531
532fn http_error_summary(body: &str) -> Option<String> {
533    let value: serde_json::Value = serde_json::from_str(body).ok()?;
534    let mut parts = Vec::new();
535
536    if let Some(error) = value.get("error") {
537        if let Some(error_str) = error.as_str() {
538            if !error_str.is_empty() {
539                parts.push(error_str.to_string());
540            }
541        } else if let Some(error_obj) = error.as_object() {
542            if let Some(status) = error_obj.get("status").and_then(|value| value.as_str())
543                && !status.is_empty()
544            {
545                parts.push(status.to_string());
546            }
547            if let Some(message) = error_obj.get("message").and_then(|value| value.as_str())
548                && !message.is_empty()
549            {
550                parts.push(message.to_string());
551            }
552        }
553    }
554
555    if let Some(desc) = value
556        .get("error_description")
557        .and_then(|value| value.as_str())
558        && !desc.is_empty()
559    {
560        parts.push(desc.to_string());
561    }
562
563    if parts.is_empty() {
564        None
565    } else {
566        Some(parts.join(": "))
567    }
568}
569
570fn env_timeout(key: &str, default: u64) -> u64 {
571    std::env::var(key)
572        .ok()
573        .and_then(|raw| raw.parse::<u64>().ok())
574        .unwrap_or(default)
575}
576
577fn emit_login_error(
578    output_json: bool,
579    code: &str,
580    message: String,
581    details: Option<output::JsonValue>,
582) {
583    if output_json {
584        let _ = output::emit_error("auth login", code, message, details);
585    } else {
586        eprintln!("{message}");
587    }
588}
589
590fn resolve_method(
591    api_key: bool,
592    device_code: bool,
593) -> std::result::Result<LoginMethod, ErrorTriplet> {
594    if api_key && device_code {
595        return Err((
596            64,
597            "gemini-login: --api-key cannot be combined with --device-code".to_string(),
598            Some(output::obj(vec![
599                ("api_key", output::b(true)),
600                ("device_code", output::b(true)),
601            ])),
602        ));
603    }
604
605    if api_key {
606        return Ok(LoginMethod::ApiKey);
607    }
608    if device_code {
609        return Ok(LoginMethod::GeminiDeviceCode);
610    }
611    Ok(LoginMethod::GeminiBrowser)
612}
613
614type ErrorTriplet = (i32, String, Option<output::JsonValue>);
615
616impl LoginMethod {
617    fn as_str(self) -> &'static str {
618        match self {
619            Self::GeminiBrowser => "gemini-browser",
620            Self::GeminiDeviceCode => "gemini-device-code",
621            Self::ApiKey => "api-key",
622        }
623    }
624
625    fn provider(self) -> &'static str {
626        match self {
627            Self::GeminiBrowser | Self::GeminiDeviceCode => "gemini",
628            Self::ApiKey => "gemini-api",
629        }
630    }
631}
632
633#[cfg(test)]
634mod tests {
635    use std::ffi::OsStr;
636    use std::fs;
637
638    use nils_test_support::fs as test_fs;
639    use nils_test_support::{EnvGuard, GlobalStateLock, StubBinDir, prepend_path};
640    use pretty_assertions::assert_eq;
641    use serde_json::json;
642    use tempfile::TempDir;
643
644    use super::{
645        LoginMethod, access_token_from_json, backup_auth_file, env_timeout, fetch_google_userinfo,
646        has_refresh_token, http_error_summary, refresh_token_from_json, resolve_method,
647        restore_auth_backup, run, run_api_key_login, run_gemini_interactive_login,
648        run_oauth_interactive_login, run_oauth_session_check, run_with_json,
649        split_http_status_marker,
650    };
651
652    fn set_env(lock: &GlobalStateLock, key: &str, value: impl AsRef<OsStr>) -> EnvGuard {
653        let value = value.as_ref().to_string_lossy().into_owned();
654        EnvGuard::set(lock, key, &value)
655    }
656
657    fn remove_env(lock: &GlobalStateLock, key: &str) -> EnvGuard {
658        EnvGuard::remove(lock, key)
659    }
660
661    fn curl_success_script() -> &'static str {
662        r#"#!/bin/sh
663set -eu
664cat <<'EOF'
665{"email":"alpha@example.com"}
666__HTTP_STATUS__:200
667EOF
668"#
669    }
670
671    fn curl_http_error_script() -> &'static str {
672        r#"#!/bin/sh
673set -eu
674cat <<'EOF'
675{"error":{"status":"UNAUTHENTICATED","message":"token expired"},"error_description":"refresh needed"}
676__HTTP_STATUS__:401
677EOF
678"#
679    }
680
681    fn curl_invalid_json_script() -> &'static str {
682        r#"#!/bin/sh
683set -eu
684cat <<'EOF'
685not-json
686__HTTP_STATUS__:200
687EOF
688"#
689    }
690
691    fn curl_exit_failure_script() -> &'static str {
692        r#"#!/bin/sh
693exit 9
694"#
695    }
696
697    #[test]
698    fn run_delegates_to_run_with_json_non_json_mode() {
699        let lock = GlobalStateLock::new();
700        let _api = set_env(&lock, "GEMINI_API_KEY", "dummy");
701        let _google = remove_env(&lock, "GOOGLE_API_KEY");
702        assert_eq!(run(true, false), 0);
703    }
704
705    #[test]
706    fn run_with_json_reports_invalid_usage_for_conflicting_flags() {
707        assert_eq!(run_with_json(true, true, true), 64);
708    }
709
710    #[test]
711    fn run_api_key_login_json_errors_when_keys_are_missing() {
712        let lock = GlobalStateLock::new();
713        let _api = set_env(&lock, "GEMINI_API_KEY", "");
714        let _google = set_env(&lock, "GOOGLE_API_KEY", "");
715        assert_eq!(run_api_key_login(true), 64);
716    }
717
718    #[test]
719    fn run_api_key_login_uses_google_api_key_when_gemini_key_missing() {
720        let lock = GlobalStateLock::new();
721        let _api = set_env(&lock, "GEMINI_API_KEY", "");
722        let _google = set_env(&lock, "GOOGLE_API_KEY", "google-key");
723        assert_eq!(run_api_key_login(true), 0);
724    }
725
726    #[test]
727    fn run_oauth_session_check_missing_auth_file_returns_error() {
728        let lock = GlobalStateLock::new();
729        let temp = TempDir::new().expect("temp dir");
730        let auth_file = temp.path().join("missing-auth.json");
731        let _auth = set_env(&lock, "GEMINI_AUTH_FILE", auth_file.as_os_str());
732        assert_eq!(run_oauth_session_check(LoginMethod::GeminiBrowser, true), 1);
733    }
734
735    #[test]
736    fn run_oauth_session_check_invalid_auth_json_returns_error() {
737        let lock = GlobalStateLock::new();
738        let temp = TempDir::new().expect("temp dir");
739        let auth_file = temp.path().join("oauth.json");
740        test_fs::write_text(&auth_file, "{invalid");
741        let _auth = set_env(&lock, "GEMINI_AUTH_FILE", auth_file.as_os_str());
742        assert_eq!(run_oauth_session_check(LoginMethod::GeminiBrowser, true), 1);
743    }
744
745    #[test]
746    fn run_oauth_session_check_missing_access_token_returns_error() {
747        let lock = GlobalStateLock::new();
748        let temp = TempDir::new().expect("temp dir");
749        let auth_file = temp.path().join("oauth.json");
750        test_fs::write_text(&auth_file, "{}");
751        let _auth = set_env(&lock, "GEMINI_AUTH_FILE", auth_file.as_os_str());
752        assert_eq!(run_oauth_session_check(LoginMethod::GeminiBrowser, true), 2);
753    }
754
755    #[test]
756    fn run_oauth_session_check_http_error_returns_error() {
757        let lock = GlobalStateLock::new();
758        let temp = TempDir::new().expect("temp dir");
759        let stubs = StubBinDir::new();
760        stubs.write_exe("curl", curl_http_error_script());
761
762        let auth_file = temp.path().join("oauth.json");
763        test_fs::write_text(&auth_file, r#"{"access_token":"tok"}"#);
764        let _path = prepend_path(&lock, stubs.path());
765        let _auth = set_env(&lock, "GEMINI_AUTH_FILE", auth_file.as_os_str());
766        assert_eq!(run_oauth_session_check(LoginMethod::GeminiBrowser, true), 3);
767    }
768
769    #[test]
770    fn run_oauth_session_check_invalid_userinfo_json_returns_error() {
771        let lock = GlobalStateLock::new();
772        let temp = TempDir::new().expect("temp dir");
773        let stubs = StubBinDir::new();
774        stubs.write_exe("curl", curl_invalid_json_script());
775
776        let auth_file = temp.path().join("oauth.json");
777        test_fs::write_text(&auth_file, r#"{"access_token":"tok"}"#);
778        let _path = prepend_path(&lock, stubs.path());
779        let _auth = set_env(&lock, "GEMINI_AUTH_FILE", auth_file.as_os_str());
780        assert_eq!(run_oauth_session_check(LoginMethod::GeminiBrowser, true), 4);
781    }
782
783    #[test]
784    fn run_oauth_session_check_success_supports_nested_tokens() {
785        let lock = GlobalStateLock::new();
786        let temp = TempDir::new().expect("temp dir");
787        let stubs = StubBinDir::new();
788        stubs.write_exe("curl", curl_success_script());
789
790        let auth_file = temp.path().join("oauth.json");
791        test_fs::write_text(
792            &auth_file,
793            r#"{"tokens":{"access_token":"tok","refresh_token":"refresh-token"}}"#,
794        );
795        let _path = prepend_path(&lock, stubs.path());
796        let _auth = set_env(&lock, "GEMINI_AUTH_FILE", auth_file.as_os_str());
797        assert_eq!(run_oauth_session_check(LoginMethod::GeminiBrowser, true), 0);
798    }
799
800    #[test]
801    fn run_oauth_interactive_login_success_device_code_returns_zero() {
802        let lock = GlobalStateLock::new();
803        let temp = TempDir::new().expect("temp dir");
804        let stubs = StubBinDir::new();
805        stubs.write_exe("curl", curl_success_script());
806        stubs.write_exe(
807            "gemini",
808            r#"#!/bin/sh
809set -eu
810[ "${NO_BROWSER:-}" = "true" ]
811cat > "$GEMINI_AUTH_FILE" <<'EOF'
812{"access_token":"new-token"}
813EOF
814"#,
815        );
816
817        let auth_file = temp.path().join("oauth.json");
818        test_fs::write_text(&auth_file, r#"{"access_token":"old-token"}"#);
819        let _path = prepend_path(&lock, stubs.path());
820        let _auth = set_env(&lock, "GEMINI_AUTH_FILE", auth_file.as_os_str());
821        assert_eq!(
822            run_oauth_interactive_login(LoginMethod::GeminiDeviceCode),
823            0
824        );
825        let updated = fs::read_to_string(&auth_file).expect("read auth");
826        assert!(updated.contains("new-token"));
827    }
828
829    #[test]
830    fn run_oauth_interactive_login_non_zero_status_restores_backup() {
831        let lock = GlobalStateLock::new();
832        let temp = TempDir::new().expect("temp dir");
833        let stubs = StubBinDir::new();
834        stubs.write_exe(
835            "gemini",
836            r#"#!/bin/sh
837set -eu
838cat > "$GEMINI_AUTH_FILE" <<'EOF'
839{"access_token":"new-token"}
840EOF
841exit 7
842"#,
843        );
844
845        let auth_file = temp.path().join("oauth.json");
846        let original = r#"{"access_token":"old-token"}"#;
847        test_fs::write_text(&auth_file, original);
848        let _path = prepend_path(&lock, stubs.path());
849        let _auth = set_env(&lock, "GEMINI_AUTH_FILE", auth_file.as_os_str());
850        assert_eq!(run_oauth_interactive_login(LoginMethod::GeminiBrowser), 7);
851        assert_eq!(fs::read_to_string(&auth_file).expect("read auth"), original);
852    }
853
854    #[test]
855    fn run_oauth_interactive_login_missing_token_restores_backup() {
856        let lock = GlobalStateLock::new();
857        let temp = TempDir::new().expect("temp dir");
858        let stubs = StubBinDir::new();
859        stubs.write_exe(
860            "gemini",
861            r#"#!/bin/sh
862set -eu
863cat > "$GEMINI_AUTH_FILE" <<'EOF'
864{}
865EOF
866"#,
867        );
868
869        let auth_file = temp.path().join("oauth.json");
870        let original = r#"{"access_token":"old-token"}"#;
871        test_fs::write_text(&auth_file, original);
872        let _path = prepend_path(&lock, stubs.path());
873        let _auth = set_env(&lock, "GEMINI_AUTH_FILE", auth_file.as_os_str());
874        assert_eq!(run_oauth_interactive_login(LoginMethod::GeminiBrowser), 2);
875        assert_eq!(fs::read_to_string(&auth_file).expect("read auth"), original);
876    }
877
878    #[test]
879    fn run_oauth_interactive_login_userinfo_error_restores_backup() {
880        let lock = GlobalStateLock::new();
881        let temp = TempDir::new().expect("temp dir");
882        let stubs = StubBinDir::new();
883        stubs.write_exe("curl", curl_http_error_script());
884        stubs.write_exe(
885            "gemini",
886            r#"#!/bin/sh
887set -eu
888cat > "$GEMINI_AUTH_FILE" <<'EOF'
889{"access_token":"new-token"}
890EOF
891"#,
892        );
893
894        let auth_file = temp.path().join("oauth.json");
895        let original = r#"{"access_token":"old-token"}"#;
896        test_fs::write_text(&auth_file, original);
897        let _path = prepend_path(&lock, stubs.path());
898        let _auth = set_env(&lock, "GEMINI_AUTH_FILE", auth_file.as_os_str());
899        assert_eq!(run_oauth_interactive_login(LoginMethod::GeminiBrowser), 3);
900        assert_eq!(fs::read_to_string(&auth_file).expect("read auth"), original);
901    }
902
903    #[test]
904    fn run_gemini_interactive_login_errors_when_auth_file_not_created() {
905        let lock = GlobalStateLock::new();
906        let temp = TempDir::new().expect("temp dir");
907        let stubs = StubBinDir::new();
908        stubs.write_exe(
909            "gemini",
910            r#"#!/bin/sh
911exit 0
912"#,
913        );
914        let _path = prepend_path(&lock, stubs.path());
915        let auth_file = temp.path().join("missing-output.json");
916        let err = run_gemini_interactive_login(LoginMethod::GeminiBrowser, &auth_file)
917            .expect_err("missing output file should fail");
918        assert_eq!(err.code, "auth-file-not-found");
919        assert_eq!(err.exit_code, 1);
920    }
921
922    #[test]
923    fn fetch_google_userinfo_handles_command_failures_and_invalid_json() {
924        let lock = GlobalStateLock::new();
925        let stubs = StubBinDir::new();
926
927        stubs.write_exe("curl", curl_exit_failure_script());
928        let _path = prepend_path(&lock, stubs.path());
929        let request_err =
930            fetch_google_userinfo("token").expect_err("non-zero curl exit should be an error");
931        assert_eq!(request_err.code, "login-request-failed");
932        assert_eq!(request_err.exit_code, 3);
933
934        stubs.write_exe("curl", curl_invalid_json_script());
935        let invalid_json_err =
936            fetch_google_userinfo("token").expect_err("invalid payload should fail");
937        assert_eq!(invalid_json_err.code, "login-invalid-json");
938        assert_eq!(invalid_json_err.exit_code, 4);
939    }
940
941    #[test]
942    fn split_http_status_marker_and_error_summary_are_stable() {
943        let (body, status) = split_http_status_marker("{\"ok\":true}\n__HTTP_STATUS__:200");
944        assert_eq!(body, "{\"ok\":true}");
945        assert_eq!(status, 200);
946
947        let (body_without_marker, status_without_marker) = split_http_status_marker("plain-body");
948        assert_eq!(body_without_marker, "plain-body");
949        assert_eq!(status_without_marker, 0);
950
951        let summary = http_error_summary(
952            r#"{"error":{"status":"UNAUTHENTICATED","message":"token expired"},"error_description":"reauth"}"#,
953        );
954        assert_eq!(
955            summary,
956            Some("UNAUTHENTICATED: token expired: reauth".to_string())
957        );
958    }
959
960    #[test]
961    fn env_timeout_and_token_helpers_cover_defaults_and_nested_values() {
962        let lock = GlobalStateLock::new();
963        let _timeout = set_env(&lock, "GEMINI_LOGIN_CURL_MAX_TIME_SECONDS", "11");
964        assert_eq!(env_timeout("GEMINI_LOGIN_CURL_MAX_TIME_SECONDS", 8), 11);
965        assert_eq!(env_timeout("GEMINI_LOGIN_CURL_UNKNOWN", 5), 5);
966
967        let nested =
968            json!({"tokens":{"access_token":"nested-access","refresh_token":"nested-refresh"}});
969        assert_eq!(
970            access_token_from_json(&nested),
971            Some("nested-access".to_string())
972        );
973        assert_eq!(
974            refresh_token_from_json(&nested),
975            Some("nested-refresh".to_string())
976        );
977
978        let top_level = json!({"access_token":"top-access","refresh_token":"top-refresh"});
979        assert_eq!(
980            access_token_from_json(&top_level),
981            Some("top-access".to_string())
982        );
983        assert_eq!(
984            refresh_token_from_json(&top_level),
985            Some("top-refresh".to_string())
986        );
987    }
988
989    #[test]
990    fn backup_restore_and_refresh_detection_behave_as_expected() {
991        let temp = TempDir::new().expect("temp dir");
992        let auth_file = temp.path().join("oauth.json");
993
994        assert_eq!(
995            backup_auth_file(&auth_file).expect("backup missing file"),
996            None
997        );
998        assert_eq!(has_refresh_token(&auth_file), false);
999
1000        fs::write(&auth_file, r#"{"refresh_token":"refresh"}"#).expect("write auth");
1001        assert_eq!(has_refresh_token(&auth_file), true);
1002
1003        let backup = backup_auth_file(&auth_file).expect("backup existing file");
1004        fs::write(&auth_file, r#"{"access_token":"mutated"}"#).expect("mutate auth");
1005        restore_auth_backup(&auth_file, backup.as_deref()).expect("restore backup");
1006        assert_eq!(
1007            fs::read_to_string(&auth_file).expect("read restored auth"),
1008            r#"{"refresh_token":"refresh"}"#
1009        );
1010
1011        restore_auth_backup(&auth_file, None).expect("remove backup target");
1012        assert_eq!(auth_file.exists(), false);
1013    }
1014
1015    #[test]
1016    fn resolve_method_defaults_to_gemini_browser() {
1017        assert_eq!(
1018            resolve_method(false, false).expect("method"),
1019            LoginMethod::GeminiBrowser
1020        );
1021    }
1022
1023    #[test]
1024    fn resolve_method_selects_device_code_and_api_key() {
1025        assert_eq!(
1026            resolve_method(false, true).expect("method"),
1027            LoginMethod::GeminiDeviceCode
1028        );
1029        assert_eq!(
1030            resolve_method(true, false).expect("method"),
1031            LoginMethod::ApiKey
1032        );
1033    }
1034
1035    #[test]
1036    fn resolve_method_rejects_conflicting_flags() {
1037        let err = resolve_method(true, true).expect_err("conflict should fail");
1038        assert_eq!(err.0, 64);
1039        assert!(err.1.contains("--api-key"));
1040    }
1041
1042    #[test]
1043    fn login_method_strings_and_providers_are_stable() {
1044        assert_eq!(LoginMethod::GeminiBrowser.as_str(), "gemini-browser");
1045        assert_eq!(LoginMethod::GeminiDeviceCode.as_str(), "gemini-device-code");
1046        assert_eq!(LoginMethod::ApiKey.as_str(), "api-key");
1047
1048        assert_eq!(LoginMethod::GeminiBrowser.provider(), "gemini");
1049        assert_eq!(LoginMethod::GeminiDeviceCode.provider(), "gemini");
1050        assert_eq!(LoginMethod::ApiKey.provider(), "gemini-api");
1051    }
1052}