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::OsString;
636    use std::fs;
637    use std::path::{Path, PathBuf};
638    use std::sync::{Mutex, OnceLock};
639
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 env_lock() -> std::sync::MutexGuard<'static, ()> {
653        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
654        match LOCK.get_or_init(|| Mutex::new(())).lock() {
655            Ok(guard) => guard,
656            Err(poisoned) => poisoned.into_inner(),
657        }
658    }
659
660    struct EnvGuard {
661        key: String,
662        old: Option<OsString>,
663    }
664
665    impl EnvGuard {
666        fn set(key: &str, value: &str) -> Self {
667            let old = std::env::var_os(key);
668            // SAFETY: tests serialize process environment mutations with `env_lock`.
669            unsafe { std::env::set_var(key, value) };
670            Self {
671                key: key.to_string(),
672                old,
673            }
674        }
675
676        fn unset(key: &str) -> Self {
677            let old = std::env::var_os(key);
678            // SAFETY: tests serialize process environment mutations with `env_lock`.
679            unsafe { std::env::remove_var(key) };
680            Self {
681                key: key.to_string(),
682                old,
683            }
684        }
685    }
686
687    impl Drop for EnvGuard {
688        fn drop(&mut self) {
689            if let Some(value) = self.old.take() {
690                // SAFETY: tests serialize process environment mutations with `env_lock`.
691                unsafe { std::env::set_var(&self.key, value) };
692            } else {
693                // SAFETY: tests serialize process environment mutations with `env_lock`.
694                unsafe { std::env::remove_var(&self.key) };
695            }
696        }
697    }
698
699    fn prepend_path(dir: &Path) -> EnvGuard {
700        let mut value = dir.display().to_string();
701        if let Ok(path) = std::env::var("PATH")
702            && !path.is_empty()
703        {
704            value.push(':');
705            value.push_str(&path);
706        }
707        EnvGuard::set("PATH", &value)
708    }
709
710    #[cfg(unix)]
711    fn write_exe(path: &Path, content: &str) {
712        use std::os::unix::fs::PermissionsExt;
713
714        fs::write(path, content).expect("write executable");
715        let mut perms = fs::metadata(path).expect("metadata").permissions();
716        perms.set_mode(0o755);
717        fs::set_permissions(path, perms).expect("chmod");
718    }
719
720    #[cfg(not(unix))]
721    fn write_exe(path: &Path, content: &str) {
722        fs::write(path, content).expect("write executable");
723    }
724
725    fn write_script(dir: &Path, name: &str, content: &str) -> PathBuf {
726        let path = dir.join(name);
727        write_exe(&path, content);
728        path
729    }
730
731    fn curl_success_script() -> &'static str {
732        r#"#!/bin/sh
733set -eu
734cat <<'EOF'
735{"email":"alpha@example.com"}
736__HTTP_STATUS__:200
737EOF
738"#
739    }
740
741    fn curl_http_error_script() -> &'static str {
742        r#"#!/bin/sh
743set -eu
744cat <<'EOF'
745{"error":{"status":"UNAUTHENTICATED","message":"token expired"},"error_description":"refresh needed"}
746__HTTP_STATUS__:401
747EOF
748"#
749    }
750
751    fn curl_invalid_json_script() -> &'static str {
752        r#"#!/bin/sh
753set -eu
754cat <<'EOF'
755not-json
756__HTTP_STATUS__:200
757EOF
758"#
759    }
760
761    fn curl_exit_failure_script() -> &'static str {
762        r#"#!/bin/sh
763exit 9
764"#
765    }
766
767    #[test]
768    fn run_delegates_to_run_with_json_non_json_mode() {
769        let _lock = env_lock();
770        let _api = EnvGuard::set("GEMINI_API_KEY", "dummy");
771        let _google = EnvGuard::unset("GOOGLE_API_KEY");
772        assert_eq!(run(true, false), 0);
773    }
774
775    #[test]
776    fn run_with_json_reports_invalid_usage_for_conflicting_flags() {
777        let _lock = env_lock();
778        assert_eq!(run_with_json(true, true, true), 64);
779    }
780
781    #[test]
782    fn run_api_key_login_json_errors_when_keys_are_missing() {
783        let _lock = env_lock();
784        let _api = EnvGuard::set("GEMINI_API_KEY", "");
785        let _google = EnvGuard::set("GOOGLE_API_KEY", "");
786        assert_eq!(run_api_key_login(true), 64);
787    }
788
789    #[test]
790    fn run_api_key_login_uses_google_api_key_when_gemini_key_missing() {
791        let _lock = env_lock();
792        let _api = EnvGuard::set("GEMINI_API_KEY", "");
793        let _google = EnvGuard::set("GOOGLE_API_KEY", "google-key");
794        assert_eq!(run_api_key_login(true), 0);
795    }
796
797    #[test]
798    fn run_oauth_session_check_missing_auth_file_returns_error() {
799        let _lock = env_lock();
800        let temp = TempDir::new().expect("temp dir");
801        let auth_file = temp.path().join("missing-auth.json");
802        let _auth = EnvGuard::set("GEMINI_AUTH_FILE", &auth_file.display().to_string());
803        assert_eq!(run_oauth_session_check(LoginMethod::GeminiBrowser, true), 1);
804    }
805
806    #[test]
807    fn run_oauth_session_check_invalid_auth_json_returns_error() {
808        let _lock = env_lock();
809        let temp = TempDir::new().expect("temp dir");
810        let auth_file = temp.path().join("oauth.json");
811        fs::write(&auth_file, "{invalid").expect("write auth");
812        let _auth = EnvGuard::set("GEMINI_AUTH_FILE", &auth_file.display().to_string());
813        assert_eq!(run_oauth_session_check(LoginMethod::GeminiBrowser, true), 1);
814    }
815
816    #[test]
817    fn run_oauth_session_check_missing_access_token_returns_error() {
818        let _lock = env_lock();
819        let temp = TempDir::new().expect("temp dir");
820        let auth_file = temp.path().join("oauth.json");
821        fs::write(&auth_file, "{}").expect("write auth");
822        let _auth = EnvGuard::set("GEMINI_AUTH_FILE", &auth_file.display().to_string());
823        assert_eq!(run_oauth_session_check(LoginMethod::GeminiBrowser, true), 2);
824    }
825
826    #[test]
827    fn run_oauth_session_check_http_error_returns_error() {
828        let _lock = env_lock();
829        let temp = TempDir::new().expect("temp dir");
830        let bin_dir = temp.path().join("bin");
831        fs::create_dir_all(&bin_dir).expect("create bin");
832        write_script(&bin_dir, "curl", curl_http_error_script());
833
834        let auth_file = temp.path().join("oauth.json");
835        fs::write(&auth_file, r#"{"access_token":"tok"}"#).expect("write auth");
836        let _path = prepend_path(&bin_dir);
837        let _auth = EnvGuard::set("GEMINI_AUTH_FILE", &auth_file.display().to_string());
838        assert_eq!(run_oauth_session_check(LoginMethod::GeminiBrowser, true), 3);
839    }
840
841    #[test]
842    fn run_oauth_session_check_invalid_userinfo_json_returns_error() {
843        let _lock = env_lock();
844        let temp = TempDir::new().expect("temp dir");
845        let bin_dir = temp.path().join("bin");
846        fs::create_dir_all(&bin_dir).expect("create bin");
847        write_script(&bin_dir, "curl", curl_invalid_json_script());
848
849        let auth_file = temp.path().join("oauth.json");
850        fs::write(&auth_file, r#"{"access_token":"tok"}"#).expect("write auth");
851        let _path = prepend_path(&bin_dir);
852        let _auth = EnvGuard::set("GEMINI_AUTH_FILE", &auth_file.display().to_string());
853        assert_eq!(run_oauth_session_check(LoginMethod::GeminiBrowser, true), 4);
854    }
855
856    #[test]
857    fn run_oauth_session_check_success_supports_nested_tokens() {
858        let _lock = env_lock();
859        let temp = TempDir::new().expect("temp dir");
860        let bin_dir = temp.path().join("bin");
861        fs::create_dir_all(&bin_dir).expect("create bin");
862        write_script(&bin_dir, "curl", curl_success_script());
863
864        let auth_file = temp.path().join("oauth.json");
865        fs::write(
866            &auth_file,
867            r#"{"tokens":{"access_token":"tok","refresh_token":"refresh-token"}}"#,
868        )
869        .expect("write auth");
870        let _path = prepend_path(&bin_dir);
871        let _auth = EnvGuard::set("GEMINI_AUTH_FILE", &auth_file.display().to_string());
872        assert_eq!(run_oauth_session_check(LoginMethod::GeminiBrowser, true), 0);
873    }
874
875    #[test]
876    fn run_oauth_interactive_login_success_device_code_returns_zero() {
877        let _lock = env_lock();
878        let temp = TempDir::new().expect("temp dir");
879        let bin_dir = temp.path().join("bin");
880        fs::create_dir_all(&bin_dir).expect("create bin");
881        write_script(&bin_dir, "curl", curl_success_script());
882        write_script(
883            &bin_dir,
884            "gemini",
885            r#"#!/bin/sh
886set -eu
887[ "${NO_BROWSER:-}" = "true" ]
888cat > "$GEMINI_AUTH_FILE" <<'EOF'
889{"access_token":"new-token"}
890EOF
891"#,
892        );
893
894        let auth_file = temp.path().join("oauth.json");
895        fs::write(&auth_file, r#"{"access_token":"old-token"}"#).expect("write auth");
896        let _path = prepend_path(&bin_dir);
897        let _auth = EnvGuard::set("GEMINI_AUTH_FILE", &auth_file.display().to_string());
898        assert_eq!(
899            run_oauth_interactive_login(LoginMethod::GeminiDeviceCode),
900            0
901        );
902        let updated = fs::read_to_string(&auth_file).expect("read auth");
903        assert!(updated.contains("new-token"));
904    }
905
906    #[test]
907    fn run_oauth_interactive_login_non_zero_status_restores_backup() {
908        let _lock = env_lock();
909        let temp = TempDir::new().expect("temp dir");
910        let bin_dir = temp.path().join("bin");
911        fs::create_dir_all(&bin_dir).expect("create bin");
912        write_script(
913            &bin_dir,
914            "gemini",
915            r#"#!/bin/sh
916set -eu
917cat > "$GEMINI_AUTH_FILE" <<'EOF'
918{"access_token":"new-token"}
919EOF
920exit 7
921"#,
922        );
923
924        let auth_file = temp.path().join("oauth.json");
925        let original = r#"{"access_token":"old-token"}"#;
926        fs::write(&auth_file, original).expect("write auth");
927        let _path = prepend_path(&bin_dir);
928        let _auth = EnvGuard::set("GEMINI_AUTH_FILE", &auth_file.display().to_string());
929        assert_eq!(run_oauth_interactive_login(LoginMethod::GeminiBrowser), 7);
930        assert_eq!(fs::read_to_string(&auth_file).expect("read auth"), original);
931    }
932
933    #[test]
934    fn run_oauth_interactive_login_missing_token_restores_backup() {
935        let _lock = env_lock();
936        let temp = TempDir::new().expect("temp dir");
937        let bin_dir = temp.path().join("bin");
938        fs::create_dir_all(&bin_dir).expect("create bin");
939        write_script(
940            &bin_dir,
941            "gemini",
942            r#"#!/bin/sh
943set -eu
944cat > "$GEMINI_AUTH_FILE" <<'EOF'
945{}
946EOF
947"#,
948        );
949
950        let auth_file = temp.path().join("oauth.json");
951        let original = r#"{"access_token":"old-token"}"#;
952        fs::write(&auth_file, original).expect("write auth");
953        let _path = prepend_path(&bin_dir);
954        let _auth = EnvGuard::set("GEMINI_AUTH_FILE", &auth_file.display().to_string());
955        assert_eq!(run_oauth_interactive_login(LoginMethod::GeminiBrowser), 2);
956        assert_eq!(fs::read_to_string(&auth_file).expect("read auth"), original);
957    }
958
959    #[test]
960    fn run_oauth_interactive_login_userinfo_error_restores_backup() {
961        let _lock = env_lock();
962        let temp = TempDir::new().expect("temp dir");
963        let bin_dir = temp.path().join("bin");
964        fs::create_dir_all(&bin_dir).expect("create bin");
965        write_script(&bin_dir, "curl", curl_http_error_script());
966        write_script(
967            &bin_dir,
968            "gemini",
969            r#"#!/bin/sh
970set -eu
971cat > "$GEMINI_AUTH_FILE" <<'EOF'
972{"access_token":"new-token"}
973EOF
974"#,
975        );
976
977        let auth_file = temp.path().join("oauth.json");
978        let original = r#"{"access_token":"old-token"}"#;
979        fs::write(&auth_file, original).expect("write auth");
980        let _path = prepend_path(&bin_dir);
981        let _auth = EnvGuard::set("GEMINI_AUTH_FILE", &auth_file.display().to_string());
982        assert_eq!(run_oauth_interactive_login(LoginMethod::GeminiBrowser), 3);
983        assert_eq!(fs::read_to_string(&auth_file).expect("read auth"), original);
984    }
985
986    #[test]
987    fn run_gemini_interactive_login_errors_when_auth_file_not_created() {
988        let _lock = env_lock();
989        let temp = TempDir::new().expect("temp dir");
990        let bin_dir = temp.path().join("bin");
991        fs::create_dir_all(&bin_dir).expect("create bin");
992        write_script(
993            &bin_dir,
994            "gemini",
995            r#"#!/bin/sh
996exit 0
997"#,
998        );
999        let _path = prepend_path(&bin_dir);
1000        let auth_file = temp.path().join("missing-output.json");
1001        let err = run_gemini_interactive_login(LoginMethod::GeminiBrowser, &auth_file)
1002            .expect_err("missing output file should fail");
1003        assert_eq!(err.code, "auth-file-not-found");
1004        assert_eq!(err.exit_code, 1);
1005    }
1006
1007    #[test]
1008    fn fetch_google_userinfo_handles_command_failures_and_invalid_json() {
1009        let _lock = env_lock();
1010        let temp = TempDir::new().expect("temp dir");
1011        let bin_dir = temp.path().join("bin");
1012        fs::create_dir_all(&bin_dir).expect("create bin");
1013
1014        write_script(&bin_dir, "curl", curl_exit_failure_script());
1015        let _path = prepend_path(&bin_dir);
1016        let request_err =
1017            fetch_google_userinfo("token").expect_err("non-zero curl exit should be an error");
1018        assert_eq!(request_err.code, "login-request-failed");
1019        assert_eq!(request_err.exit_code, 3);
1020
1021        write_script(&bin_dir, "curl", curl_invalid_json_script());
1022        let invalid_json_err =
1023            fetch_google_userinfo("token").expect_err("invalid payload should fail");
1024        assert_eq!(invalid_json_err.code, "login-invalid-json");
1025        assert_eq!(invalid_json_err.exit_code, 4);
1026    }
1027
1028    #[test]
1029    fn split_http_status_marker_and_error_summary_are_stable() {
1030        let (body, status) = split_http_status_marker("{\"ok\":true}\n__HTTP_STATUS__:200");
1031        assert_eq!(body, "{\"ok\":true}");
1032        assert_eq!(status, 200);
1033
1034        let (body_without_marker, status_without_marker) = split_http_status_marker("plain-body");
1035        assert_eq!(body_without_marker, "plain-body");
1036        assert_eq!(status_without_marker, 0);
1037
1038        let summary = http_error_summary(
1039            r#"{"error":{"status":"UNAUTHENTICATED","message":"token expired"},"error_description":"reauth"}"#,
1040        );
1041        assert_eq!(
1042            summary,
1043            Some("UNAUTHENTICATED: token expired: reauth".to_string())
1044        );
1045    }
1046
1047    #[test]
1048    fn env_timeout_and_token_helpers_cover_defaults_and_nested_values() {
1049        let _lock = env_lock();
1050        let _timeout = EnvGuard::set("GEMINI_LOGIN_CURL_MAX_TIME_SECONDS", "11");
1051        assert_eq!(env_timeout("GEMINI_LOGIN_CURL_MAX_TIME_SECONDS", 8), 11);
1052        assert_eq!(env_timeout("GEMINI_LOGIN_CURL_UNKNOWN", 5), 5);
1053
1054        let nested =
1055            json!({"tokens":{"access_token":"nested-access","refresh_token":"nested-refresh"}});
1056        assert_eq!(
1057            access_token_from_json(&nested),
1058            Some("nested-access".to_string())
1059        );
1060        assert_eq!(
1061            refresh_token_from_json(&nested),
1062            Some("nested-refresh".to_string())
1063        );
1064
1065        let top_level = json!({"access_token":"top-access","refresh_token":"top-refresh"});
1066        assert_eq!(
1067            access_token_from_json(&top_level),
1068            Some("top-access".to_string())
1069        );
1070        assert_eq!(
1071            refresh_token_from_json(&top_level),
1072            Some("top-refresh".to_string())
1073        );
1074    }
1075
1076    #[test]
1077    fn backup_restore_and_refresh_detection_behave_as_expected() {
1078        let _lock = env_lock();
1079        let temp = TempDir::new().expect("temp dir");
1080        let auth_file = temp.path().join("oauth.json");
1081
1082        assert_eq!(
1083            backup_auth_file(&auth_file).expect("backup missing file"),
1084            None
1085        );
1086        assert_eq!(has_refresh_token(&auth_file), false);
1087
1088        fs::write(&auth_file, r#"{"refresh_token":"refresh"}"#).expect("write auth");
1089        assert_eq!(has_refresh_token(&auth_file), true);
1090
1091        let backup = backup_auth_file(&auth_file).expect("backup existing file");
1092        fs::write(&auth_file, r#"{"access_token":"mutated"}"#).expect("mutate auth");
1093        restore_auth_backup(&auth_file, backup.as_deref()).expect("restore backup");
1094        assert_eq!(
1095            fs::read_to_string(&auth_file).expect("read restored auth"),
1096            r#"{"refresh_token":"refresh"}"#
1097        );
1098
1099        restore_auth_backup(&auth_file, None).expect("remove backup target");
1100        assert_eq!(auth_file.exists(), false);
1101    }
1102
1103    #[test]
1104    fn resolve_method_defaults_to_gemini_browser() {
1105        assert_eq!(
1106            resolve_method(false, false).expect("method"),
1107            LoginMethod::GeminiBrowser
1108        );
1109    }
1110
1111    #[test]
1112    fn resolve_method_selects_device_code_and_api_key() {
1113        assert_eq!(
1114            resolve_method(false, true).expect("method"),
1115            LoginMethod::GeminiDeviceCode
1116        );
1117        assert_eq!(
1118            resolve_method(true, false).expect("method"),
1119            LoginMethod::ApiKey
1120        );
1121    }
1122
1123    #[test]
1124    fn resolve_method_rejects_conflicting_flags() {
1125        let err = resolve_method(true, true).expect_err("conflict should fail");
1126        assert_eq!(err.0, 64);
1127        assert!(err.1.contains("--api-key"));
1128    }
1129
1130    #[test]
1131    fn login_method_strings_and_providers_are_stable() {
1132        assert_eq!(LoginMethod::GeminiBrowser.as_str(), "gemini-browser");
1133        assert_eq!(LoginMethod::GeminiDeviceCode.as_str(), "gemini-device-code");
1134        assert_eq!(LoginMethod::ApiKey.as_str(), "api-key");
1135
1136        assert_eq!(LoginMethod::GeminiBrowser.provider(), "gemini");
1137        assert_eq!(LoginMethod::GeminiDeviceCode.provider(), "gemini");
1138        assert_eq!(LoginMethod::ApiKey.provider(), "gemini-api");
1139    }
1140}