From 78e158134df2f1df8d826610e39b2c1ffe769802 Mon Sep 17 00:00:00 2001 From: "takeshi.nakata" <7553415+nktks@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:49:42 +0900 Subject: [PATCH 1/7] feat(auth): open OAuth URL in default browser automatically gws auth login now attempts to launch the default browser with the OAuth URL (open on macOS, xdg-open on Linux, rundll32 on Windows). When no opener is available the existing copy-paste flow is unchanged. --- .changeset/auth-login-open-browser.md | 5 + .../google-workspace-cli/src/auth_commands.rs | 10 ++ crates/google-workspace-cli/src/browser.rs | 140 ++++++++++++++++++ crates/google-workspace-cli/src/main.rs | 1 + 4 files changed, 156 insertions(+) create mode 100644 .changeset/auth-login-open-browser.md create mode 100644 crates/google-workspace-cli/src/browser.rs diff --git a/.changeset/auth-login-open-browser.md b/.changeset/auth-login-open-browser.md new file mode 100644 index 00000000..1251b215 --- /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, `rundll32` 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..d91d54ce --- /dev/null +++ b/crates/google-workspace-cli/src/browser.rs @@ -0,0 +1,140 @@ +//! 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, so + // use rundll32 which receives the URL as a plain argument instead. + "windows" => Some(( + "rundll32", + vec!["url.dll,FileProtocolHandler".to_string(), url.to_string()], + )), + _ => None, + } +} + +/// Validates that `url` is a plain https URL that is safe to hand to an +/// external opener program. +fn is_openable_url(url: &str) -> bool { + url.starts_with("https://") && !url.chars().any(|c| c.is_control() || c.is_whitespace()) +} + +/// 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_rundll32() { + let (program, args) = opener_for_os("windows", "https://example.com").unwrap(); + assert_eq!(program, "rundll32"); + assert_eq!( + args, + vec![ + "url.dll,FileProtocolHandler".to_string(), + "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 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; From 452fb7bbac906fa8a808e8fc0505561679ee79f2 Mon Sep 17 00:00:00 2001 From: "takeshi.nakata" <7553415+nktks@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:39:08 +0900 Subject: [PATCH 2/7] fix(auth): reject dangerous Unicode (Cf category) in browser URL check char::is_control() only covers the Cc category, so zero-width chars and bidi overrides (Cf) passed the opener URL validation. Reuse the existing google_workspace::validate::is_dangerous_unicode helper. --- crates/google-workspace-cli/src/browser.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/google-workspace-cli/src/browser.rs b/crates/google-workspace-cli/src/browser.rs index d91d54ce..28273ce6 100644 --- a/crates/google-workspace-cli/src/browser.rs +++ b/crates/google-workspace-cli/src/browser.rs @@ -23,9 +23,16 @@ fn opener_for_os(os: &str, url: &str) -> Option<(&'static str, Vec)> { } /// Validates that `url` is a plain https URL that is safe to hand to an -/// external opener program. +/// external opener program. Rejects control characters, whitespace, and +/// dangerous Unicode (zero-width chars, bidi overrides) that +/// `char::is_control()` does not cover (`Cf` category). fn is_openable_url(url: &str) -> bool { - url.starts_with("https://") && !url.chars().any(|c| c.is_control() || c.is_whitespace()) + url.starts_with("https://") + && !url.chars().any(|c| { + c.is_control() + || c.is_whitespace() + || google_workspace::validate::is_dangerous_unicode(c) + }) } /// Spawns `program` detached from this process. Returns `true` when the @@ -119,6 +126,14 @@ mod tests { assert!(!is_openable_url("https://example.com/\n")); } + #[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)")); From 992d9d0ae98cced5d94acd15adb2e29d05621a4e Mon Sep 17 00:00:00 2001 From: "takeshi.nakata" <7553415+nktks@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:52:22 +0900 Subject: [PATCH 3/7] fix(auth): reject quote and shell metacharacters in browser URL check Some openers forward their argument through a shell (xdg-open wrapper scripts) and rundll32 mis-parses quotes, so explicitly reject " ' \ ; $ | ` < > before handing the URL to the opener. Properly percent-encoded OAuth URLs never contain these characters. --- crates/google-workspace-cli/src/browser.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/google-workspace-cli/src/browser.rs b/crates/google-workspace-cli/src/browser.rs index 28273ce6..8bdd51a3 100644 --- a/crates/google-workspace-cli/src/browser.rs +++ b/crates/google-workspace-cli/src/browser.rs @@ -23,15 +23,19 @@ fn opener_for_os(os: &str, url: &str) -> Option<(&'static str, Vec)> { } /// Validates that `url` is a plain https URL that is safe to hand to an -/// external opener program. Rejects control characters, whitespace, and +/// external opener program. Rejects control characters, whitespace, /// dangerous Unicode (zero-width chars, bidi overrides) that -/// `char::is_control()` does not cover (`Cf` category). +/// `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() || google_workspace::validate::is_dangerous_unicode(c) + || matches!(c, '"' | '\'' | '\\' | ';' | '$' | '|' | '`' | '<' | '>') }) } From f14a78a1a16b4a6757f4f2478913e01b9c582f2c Mon Sep 17 00:00:00 2001 From: "takeshi.nakata" <7553415+nktks@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:55:57 +0900 Subject: [PATCH 4/7] test(auth): cover quote and shell metacharacter rejection in URL check --- crates/google-workspace-cli/src/browser.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/google-workspace-cli/src/browser.rs b/crates/google-workspace-cli/src/browser.rs index 8bdd51a3..4c385048 100644 --- a/crates/google-workspace-cli/src/browser.rs +++ b/crates/google-workspace-cli/src/browser.rs @@ -130,6 +130,18 @@ mod tests { 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) From 739b2d76aa0e4c9dfd942fde2bfb99a4a3d0133e Mon Sep 17 00:00:00 2001 From: "takeshi.nakata" <7553415+nktks@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:03:13 +0900 Subject: [PATCH 5/7] refactor(auth): reference is_dangerous_unicode via crate::validate re-export The CLI crate consistently uses the crate::validate shim (39 call sites) rather than the google_workspace library path. --- crates/google-workspace-cli/src/browser.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/google-workspace-cli/src/browser.rs b/crates/google-workspace-cli/src/browser.rs index 4c385048..c2707cd0 100644 --- a/crates/google-workspace-cli/src/browser.rs +++ b/crates/google-workspace-cli/src/browser.rs @@ -34,7 +34,7 @@ fn is_openable_url(url: &str) -> bool { && !url.chars().any(|c| { c.is_control() || c.is_whitespace() - || google_workspace::validate::is_dangerous_unicode(c) + || crate::validate::is_dangerous_unicode(c) || matches!(c, '"' | '\'' | '\\' | ';' | '$' | '|' | '`' | '<' | '>') }) } From 2ad16b3026d268327b5d073f569f638859799cdd Mon Sep 17 00:00:00 2001 From: "takeshi.nakata" <7553415+nktks@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:11:18 +0900 Subject: [PATCH 6/7] fix(auth): use explorer instead of rundll32 to open URLs on Windows rundll32 url.dll,FileProtocolHandler is a well-known LOLBIN technique that EDR agents and WDAC policies commonly block or flag, which would make the login flow fail silently in enterprise environments. Spawning explorer.exe directly keeps the no-cmd-reparsing property while using a binary that cannot be blocked without breaking the OS GUI. --- .changeset/auth-login-open-browser.md | 2 +- crates/google-workspace-cli/src/browser.rs | 22 ++++++++-------------- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/.changeset/auth-login-open-browser.md b/.changeset/auth-login-open-browser.md index 1251b215..c841d610 100644 --- a/.changeset/auth-login-open-browser.md +++ b/.changeset/auth-login-open-browser.md @@ -2,4 +2,4 @@ "@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, `rundll32` on Windows), falling back to the existing copy-paste flow when no opener is available. +`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/browser.rs b/crates/google-workspace-cli/src/browser.rs index c2707cd0..dd93eb43 100644 --- a/crates/google-workspace-cli/src/browser.rs +++ b/crates/google-workspace-cli/src/browser.rs @@ -12,12 +12,12 @@ 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, so - // use rundll32 which receives the URL as a plain argument instead. - "windows" => Some(( - "rundll32", - vec!["url.dll,FileProtocolHandler".to_string(), 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, } } @@ -95,14 +95,8 @@ mod tests { #[test] fn opener_for_windows_uses_rundll32() { let (program, args) = opener_for_os("windows", "https://example.com").unwrap(); - assert_eq!(program, "rundll32"); - assert_eq!( - args, - vec![ - "url.dll,FileProtocolHandler".to_string(), - "https://example.com".to_string() - ] - ); + assert_eq!(program, "explorer"); + assert_eq!(args, vec!["https://example.com".to_string()]); } #[test] From a653b044a9a0ffeb7587f18914d9b5e1e4925bf1 Mon Sep 17 00:00:00 2001 From: "takeshi.nakata" <7553415+nktks@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:11:38 +0900 Subject: [PATCH 7/7] test(auth): rename Windows opener test to match explorer behavior --- crates/google-workspace-cli/src/browser.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/google-workspace-cli/src/browser.rs b/crates/google-workspace-cli/src/browser.rs index dd93eb43..8a146c45 100644 --- a/crates/google-workspace-cli/src/browser.rs +++ b/crates/google-workspace-cli/src/browser.rs @@ -93,7 +93,7 @@ mod tests { } #[test] - fn opener_for_windows_uses_rundll32() { + 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()]);