Skip to content
Open
5 changes: 5 additions & 0 deletions .changeset/auth-login-open-browser.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions crates/google-workspace-cli/src/auth_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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())
})
}
Expand Down
165 changes: 165 additions & 0 deletions crates/google-workspace-cli/src/browser.rs
Original file line number Diff line number Diff line change
@@ -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<String>)> {
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, '"' | '\'' | '\\' | ';' | '$' | '|' | '`' | '<' | '>')
})
}
Comment thread
nktks marked this conversation as resolved.
Comment thread
nktks marked this conversation as resolved.

/// 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/<a>"));
}

#[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"));
}
Comment thread
nktks marked this conversation as resolved.

#[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()]
));
}
}
1 change: 1 addition & 0 deletions crates/google-workspace-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

mod auth;
pub(crate) mod auth_commands;
mod browser;
mod client;
mod commands;
pub(crate) mod credential_store;
Expand Down
Loading