Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
---

Open OAuth login URLs in the browser with a copy-paste fallback
7 changes: 5 additions & 2 deletions crates/google-workspace-cli/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -763,8 +763,11 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
let enc_path = dir.path().join("credentials.enc");

// Isolate global config dir to prevent races with other tests
std::env::set_var("GOOGLE_WORKSPACE_CLI_CONFIG_DIR", dir.path());
// Isolate global config dir to prevent races with other tests. The
// guard restores the variable on drop; a bare set_var leaked it into
// every later test, and config_dir_returns_gws_subdir then read a
// dropped tempdir path instead of the real default.
let _config_dir = EnvVarGuard::set("GOOGLE_WORKSPACE_CLI_CONFIG_DIR", dir.path());

// Encrypt and write
let encrypted = crate::credential_store::encrypt(json.as_bytes()).unwrap();
Expand Down
126 changes: 126 additions & 0 deletions crates/google-workspace-cli/src/auth_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@
// limitations under the License.

use std::collections::HashSet;
use std::ffi::OsString;
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};

use serde::Deserialize;
use serde_json::json;
Expand Down Expand Up @@ -88,6 +90,62 @@ fn build_proxy_auth_url(client_id: &str, redirect_uri: &str, scopes: &[String])
)
}

fn browser_command(browser: Option<OsString>, os: &str) -> Option<OsString> {
browser.or_else(|| match os {
"linux" => Some(OsString::from("xdg-open")),
"macos" => Some(OsString::from("open")),
// `explorer` avoids cmd.exe URL parsing and the EDR-sensitive rundll32 opener.
"windows" => Some(OsString::from("explorer")),
_ => None,
})
}

fn is_openable_url(url: &str) -> bool {
url.starts_with("https://")
&& !url.chars().any(|c| {
c.is_control()
|| c.is_whitespace()
|| crate::output::is_dangerous_unicode(c)
|| matches!(c, '"' | '\'' | '\\' | ';' | '$' | '|' | '`' | '<' | '>')
})
}

/// Attempt to open an OAuth URL without blocking or affecting the login flow.
///
/// Returns whether an opener was actually spawned, so the caller only claims
/// a browser is opening when one is. The child is reaped in a background
/// thread, so a slow platform opener cannot block the callback server.
fn try_open_browser(url: &str) -> bool {
if !is_openable_url(url) {
return false;
}

let Some(program) = browser_command(std::env::var_os("BROWSER"), std::env::consts::OS) else {
return false;
};

match Command::new(program)
.arg(url)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
{
Ok(mut child) => {
// Builder::spawn returns Err instead of panicking when the OS
// refuses a thread; the unreaped child then lingers only until
// this short-lived CLI exits, and the login is unaffected.
let _ = std::thread::Builder::new()
.name("gws-browser-reaper".to_string())
.spawn(move || {
let _ = child.wait();
});
true
}
Err(_) => false,
}
}

fn extract_authorization_code(request_line: &str) -> Result<String, GwsError> {
let path = request_line
.split_whitespace()
Expand Down Expand Up @@ -126,6 +184,9 @@ async fn login_with_proxy_support(

let auth_url = build_proxy_auth_url(client_id, &redirect_uri, scopes);

if try_open_browser(&auth_url) {
println!("Opening in your browser...");
}
println!("Open this URL in your browser to authenticate:\n");
println!(" {}\n", auth_url);

Expand Down Expand Up @@ -565,6 +626,9 @@ impl yup_oauth2::authenticator_delegate::InstalledFlowDelegate for CliFlowDelega
} else {
url.to_string()
};
if try_open_browser(&display_url) {
eprintln!("Opening in your browser...");
}
eprintln!("Open this URL in your browser to authenticate:\n");
eprintln!(" {display_url}\n");
Ok(String::new())
Expand Down Expand Up @@ -2481,6 +2545,68 @@ mod tests {
assert!(result.is_empty());
}

#[test]
fn browser_command_prefers_browser_env() {
// $BROWSER is a single command, not a shell line; arguments are not split.
let browser = std::ffi::OsString::from("/opt/bin/my-browser");

let command = browser_command(Some(browser.clone()), "linux");

assert_eq!(command, Some(browser));
}

#[test]
fn browser_command_uses_xdg_open_on_linux() {
let command = browser_command(None, "linux");

assert_eq!(command, Some(std::ffi::OsString::from("xdg-open")));
}

#[test]
fn browser_command_uses_open_on_macos() {
let command = browser_command(None, "macos");

assert_eq!(command, Some(std::ffi::OsString::from("open")));
}

#[test]
fn browser_command_uses_explorer_on_windows() {
let command = browser_command(None, "windows");

assert_eq!(command, Some(std::ffi::OsString::from("explorer")));
}

#[test]
fn browser_command_rejects_unknown_platform() {
let command = browser_command(None, "freebsd");

assert_eq!(command, None);
}

#[test]
fn openable_url_accepts_https_url() {
assert!(is_openable_url(
"https://accounts.google.com/o/oauth2/auth?scope=openid"
));
}

#[test]
fn openable_url_rejects_control_whitespace_and_dangerous_unicode() {
assert!(!is_openable_url("https://example.com/with space"));
assert!(!is_openable_url("https://example.com/with\nnewline"));
assert!(!is_openable_url("https://example.com/\u{202E}override"));
assert!(!is_openable_url("https://example.com/zero\u{200B}width"));
}

#[test]
fn openable_url_rejects_quotes_backslashes_and_shell_characters() {
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)"));
}

#[test]
fn build_proxy_auth_url_encodes_scope_and_redirect_uri() {
let scopes = vec![
Expand Down
8 changes: 1 addition & 7 deletions crates/google-workspace-cli/src/helpers/script.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,13 +169,7 @@ fn process_file(path: &Path) -> Result<Option<serde_json::Value>, GwsError> {
filename.trim_end_matches(".js").trim_end_matches(".gs"),
),
"html" => ("HTML", filename.trim_end_matches(".html")),
"json" => {
if filename == "appsscript.json" {
("JSON", "appsscript")
} else {
return Ok(None);
}
}
"json" if filename == "appsscript.json" => ("JSON", "appsscript"),
_ => return Ok(None),
};

Expand Down
Loading