diff --git a/.changeset/auth-login-open-browser.md b/.changeset/auth-login-open-browser.md new file mode 100644 index 00000000..c841d610 --- /dev/null +++ b/.changeset/auth-login-open-browser.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": minor +--- + +`gws auth login` now attempts to open the OAuth URL in the default browser automatically (`open` on macOS, `xdg-open` on Linux, `explorer` on Windows), falling back to the existing copy-paste flow when no opener is available. diff --git a/crates/google-workspace-cli/src/auth_commands.rs b/crates/google-workspace-cli/src/auth_commands.rs index d7571e74..6c8bcfae 100644 --- a/crates/google-workspace-cli/src/auth_commands.rs +++ b/crates/google-workspace-cli/src/auth_commands.rs @@ -128,6 +128,11 @@ async fn login_with_proxy_support( println!("Open this URL in your browser to authenticate:\n"); println!(" {}\n", auth_url); + if crate::browser::try_open_browser(&auth_url) { + println!( + "Your default browser should open automatically. If it doesn't, use the URL above.\n" + ); + } // Wait for OAuth callback let (mut stream, _) = listener @@ -567,6 +572,11 @@ impl yup_oauth2::authenticator_delegate::InstalledFlowDelegate for CliFlowDelega }; eprintln!("Open this URL in your browser to authenticate:\n"); eprintln!(" {display_url}\n"); + if crate::browser::try_open_browser(&display_url) { + eprintln!( + "Your default browser should open automatically. If it doesn't, use the URL above.\n" + ); + } Ok(String::new()) }) } diff --git a/crates/google-workspace-cli/src/browser.rs b/crates/google-workspace-cli/src/browser.rs new file mode 100644 index 00000000..8a146c45 --- /dev/null +++ b/crates/google-workspace-cli/src/browser.rs @@ -0,0 +1,165 @@ +//! Best-effort opening of a URL in the user's default browser. +//! +//! Used by `gws auth login` to spare the user from copy-pasting the OAuth +//! URL. Every failure path is silent by design: callers always print the +//! URL first, so the previous copy-paste behavior remains the fallback. + +use std::process::{Command, Stdio}; + +/// Returns the platform-specific program and arguments that open `url` in +/// the default browser, or `None` when the platform has no known opener. +fn opener_for_os(os: &str, url: &str) -> Option<(&'static str, Vec)> { + match os { + "macos" => Some(("open", vec![url.to_string()])), + "linux" => Some(("xdg-open", vec![url.to_string()])), + // `start` is a cmd.exe built-in that re-parses `&` inside URLs, and + // `rundll32 url.dll,FileProtocolHandler` is a well-known LOLBIN + // technique that EDR/WDAC policies commonly block. Spawning the + // Windows shell directly avoids both: no cmd re-parsing, and + // explorer.exe cannot be blocked without breaking the OS GUI. + "windows" => Some(("explorer", vec![url.to_string()])), + _ => None, + } +} + +/// Validates that `url` is a plain https URL that is safe to hand to an +/// external opener program. Rejects control characters, whitespace, +/// dangerous Unicode (zero-width chars, bidi overrides) that +/// `char::is_control()` does not cover (`Cf` category), and quote/shell +/// metacharacters in case an opener forwards its argument through a shell +/// (e.g. `xdg-open` wrapper scripts) or mis-parses quotes (`rundll32`). +/// Properly percent-encoded OAuth URLs never contain any of these. +fn is_openable_url(url: &str) -> bool { + url.starts_with("https://") + && !url.chars().any(|c| { + c.is_control() + || c.is_whitespace() + || crate::validate::is_dangerous_unicode(c) + || matches!(c, '"' | '\'' | '\\' | ';' | '$' | '|' | '`' | '<' | '>') + }) +} + +/// Spawns `program` detached from this process. Returns `true` when the +/// process was spawned successfully (e.g. `false` when the program is not +/// installed). The child is reaped on a background thread so a slow opener +/// never blocks the OAuth callback accept loop. +fn spawn_detached(program: &str, args: &[String]) -> bool { + match Command::new(program) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + { + Ok(mut child) => { + std::thread::spawn(move || { + let _ = child.wait(); + }); + true + } + Err(_) => false, + } +} + +/// Attempts to open `url` in the default browser. Returns `true` when an +/// opener process was launched; `false` means the caller's printed URL is +/// the only way for the user to proceed. +pub(crate) fn try_open_browser(url: &str) -> bool { + if !is_openable_url(url) { + return false; + } + match opener_for_os(std::env::consts::OS, url) { + Some((program, args)) => spawn_detached(program, &args), + None => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn opener_for_macos_uses_open() { + let (program, args) = opener_for_os("macos", "https://example.com").unwrap(); + assert_eq!(program, "open"); + assert_eq!(args, vec!["https://example.com".to_string()]); + } + + #[test] + fn opener_for_linux_uses_xdg_open() { + let (program, args) = opener_for_os("linux", "https://example.com").unwrap(); + assert_eq!(program, "xdg-open"); + assert_eq!(args, vec!["https://example.com".to_string()]); + } + + #[test] + fn opener_for_windows_uses_explorer() { + let (program, args) = opener_for_os("windows", "https://example.com").unwrap(); + assert_eq!(program, "explorer"); + assert_eq!(args, vec!["https://example.com".to_string()]); + } + + #[test] + fn opener_for_unknown_os_is_none() { + assert!(opener_for_os("freebsd", "https://example.com").is_none()); + } + + #[test] + fn openable_url_accepts_https() { + assert!(is_openable_url( + "https://accounts.google.com/o/oauth2/auth?scope=x&client_id=y" + )); + } + + #[test] + fn openable_url_rejects_non_https_schemes() { + assert!(!is_openable_url("http://example.com")); + assert!(!is_openable_url("javascript:alert(1)")); + assert!(!is_openable_url("file:///etc/passwd")); + } + + #[test] + fn openable_url_rejects_whitespace_and_control_chars() { + assert!(!is_openable_url("https://example.com/a b")); + assert!(!is_openable_url("https://example.com/\n")); + } + + #[test] + fn openable_url_rejects_quotes_and_shell_metacharacters() { + assert!(!is_openable_url("https://example.com/\"")); + assert!(!is_openable_url("https://example.com/'")); + assert!(!is_openable_url("https://example.com/\\")); + assert!(!is_openable_url("https://example.com/;evil")); + assert!(!is_openable_url("https://example.com/$(evil)")); + assert!(!is_openable_url("https://example.com/a|b")); + assert!(!is_openable_url("https://example.com/`evil`")); + assert!(!is_openable_url("https://example.com/")); + } + + #[test] + fn openable_url_rejects_dangerous_unicode() { + // RLO (bidi override, category Cf — not caught by is_control) + assert!(!is_openable_url("https://example.com/\u{202E}evil")); + // ZWSP (zero-width space) + assert!(!is_openable_url("https://example.com/a\u{200B}b")); + } + + #[test] + fn try_open_browser_rejects_unsafe_url() { + assert!(!try_open_browser("javascript:alert(1)")); + } + + #[cfg(unix)] + #[test] + fn spawn_detached_returns_true_for_existing_program() { + assert!(spawn_detached("true", &[])); + } + + #[test] + fn spawn_detached_returns_false_for_missing_program() { + assert!(!spawn_detached( + "gws-test-nonexistent-program-9f2c", + &["https://example.com".to_string()] + )); + } +} diff --git a/crates/google-workspace-cli/src/main.rs b/crates/google-workspace-cli/src/main.rs index 41dcc1e1..b19de150 100644 --- a/crates/google-workspace-cli/src/main.rs +++ b/crates/google-workspace-cli/src/main.rs @@ -21,6 +21,7 @@ mod auth; pub(crate) mod auth_commands; +mod browser; mod client; mod commands; pub(crate) mod credential_store;