From ad4b93abf6c88f8db09d5cfc83764e90e0bde173 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 07:57:36 +0000 Subject: [PATCH 1/8] Initial plan From c0cc7e603ebb73a0e97dc598b0d5b59d8548fc28 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 08:13:53 +0000 Subject: [PATCH 2/8] feat: add --callback-host and --callback-port to gws auth login Agent-Logs-Url: https://github.com/ilteoood/gws-cli/sessions/a3a0d939-aa16-4501-995a-73774619e4e9 Co-authored-by: ilteoood <6383527+ilteoood@users.noreply.github.com> --- .changeset/oauth-callback-host-port.md | 5 + AGENTS.md | 2 + crates/google-workspace-cli/Cargo.toml | 2 +- .../google-workspace-cli/src/auth_commands.rs | 157 ++++++++++++++++-- 4 files changed, 148 insertions(+), 18 deletions(-) create mode 100644 .changeset/oauth-callback-host-port.md diff --git a/.changeset/oauth-callback-host-port.md b/.changeset/oauth-callback-host-port.md new file mode 100644 index 00000000..bc07774b --- /dev/null +++ b/.changeset/oauth-callback-host-port.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": minor +--- + +Add `--callback-host` and `--callback-port` flags to `gws auth login` so users can configure the OAuth callback server host and port. Both flags also read from environment variables `GOOGLE_WORKSPACE_CLI_CALLBACK_HOST` and `GOOGLE_WORKSPACE_CLI_CALLBACK_PORT` respectively (CLI flags take precedence). This is useful when the OAuth app is registered with a fixed redirect URI or when running in Docker/CI with port-forwarding. diff --git a/AGENTS.md b/AGENTS.md index 72211226..43b961fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -215,6 +215,8 @@ See [`src/helpers/README.md`](crates/google-workspace-cli/src/helpers/README.md) |---|---| | `GOOGLE_WORKSPACE_CLI_CLIENT_ID` | OAuth client ID (for `gws auth login` when no `client_secret.json` is saved) | | `GOOGLE_WORKSPACE_CLI_CLIENT_SECRET` | OAuth client secret (paired with `CLIENT_ID` above) | +| `GOOGLE_WORKSPACE_CLI_CALLBACK_HOST` | Hostname used in the OAuth redirect URI during `gws auth login` (default: `localhost`; overridden by `--callback-host`) | +| `GOOGLE_WORKSPACE_CLI_CALLBACK_PORT` | Port for the local OAuth callback server during `gws auth login` (default: `0` = OS-assigned; overridden by `--callback-port`) | ### Sanitization (Model Armor) diff --git a/crates/google-workspace-cli/Cargo.toml b/crates/google-workspace-cli/Cargo.toml index 058b109e..a89a737a 100644 --- a/crates/google-workspace-cli/Cargo.toml +++ b/crates/google-workspace-cli/Cargo.toml @@ -34,7 +34,7 @@ google-workspace = { version = "0.22.5", path = "../google-workspace" } tempfile = "3" aes-gcm = "0.10" anyhow = "1" -clap = { version = "4", features = ["derive", "string"] } +clap = { version = "4", features = ["derive", "string", "env"] } dirs = "5" dotenvy = "0.15" hostname = "0.4" diff --git a/crates/google-workspace-cli/src/auth_commands.rs b/crates/google-workspace-cli/src/auth_commands.rs index d7571e74..2d72097e 100644 --- a/crates/google-workspace-cli/src/auth_commands.rs +++ b/crates/google-workspace-cli/src/auth_commands.rs @@ -114,15 +114,24 @@ async fn login_with_proxy_support( client_id: &str, client_secret: &str, scopes: &[String], + callback_host: &str, + callback_port: u16, ) -> Result<(String, String), GwsError> { - // Start local server to receive OAuth callback - let listener = TcpListener::bind("127.0.0.1:0") + // Start local server to receive OAuth callback. + // Bind to loopback for local hostnames, or all interfaces for custom hosts + // (e.g. Docker/CI where the callback arrives via port-forwarding). + let bind_addr = if callback_host == "localhost" || callback_host == "127.0.0.1" { + format!("127.0.0.1:{}", callback_port) + } else { + format!("0.0.0.0:{}", callback_port) + }; + let listener = TcpListener::bind(&bind_addr) .map_err(|e| GwsError::Auth(format!("Failed to start local server: {e}")))?; let port = listener .local_addr() .map_err(|e| GwsError::Auth(format!("Failed to inspect local server: {e}")))? .port(); - let redirect_uri = format!("http://localhost:{}", port); + let redirect_uri = format!("http://{}:{}", callback_host, port); let auth_url = build_proxy_auth_url(client_id, &redirect_uri, scopes); @@ -392,6 +401,23 @@ fn build_login_subcommand() -> clap::Command { ) .value_name("services"), ) + .arg( + clap::Arg::new("callback-host") + .long("callback-host") + .env("GOOGLE_WORKSPACE_CLI_CALLBACK_HOST") + .help("Hostname used in the OAuth redirect URI (default: localhost)") + .value_name("HOST") + .default_value("localhost"), + ) + .arg( + clap::Arg::new("callback-port") + .long("callback-port") + .env("GOOGLE_WORKSPACE_CLI_CALLBACK_PORT") + .help("Port for the local OAuth callback server (0 = OS-assigned)") + .value_name("PORT") + .value_parser(clap::value_parser!(u16)) + .default_value("0"), + ) } /// Build the clap Command for `gws auth`. @@ -448,9 +474,10 @@ pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> { match matches.subcommand() { Some(("login", sub_m)) => { - let (scope_mode, services_filter) = parse_login_args(sub_m); + let (scope_mode, services_filter, callback_host, callback_port) = + parse_login_args(sub_m); - handle_login_inner(scope_mode, services_filter).await + handle_login_inner(scope_mode, services_filter, callback_host, callback_port).await } Some(("setup", sub_m)) => { // Collect remaining args and delegate to setup's own clap parser. @@ -482,8 +509,10 @@ fn login_command() -> clap::Command { build_login_subcommand() } -/// Extract `ScopeMode` and optional services filter from parsed login args. -fn parse_login_args(matches: &clap::ArgMatches) -> (ScopeMode, Option>) { +/// Extract `ScopeMode`, optional services filter, and OAuth callback config from parsed login args. +fn parse_login_args( + matches: &clap::ArgMatches, +) -> (ScopeMode, Option>, String, u16) { let scope_mode = if let Some(scopes_str) = matches.get_one::("scopes") { ScopeMode::Custom( scopes_str @@ -508,7 +537,16 @@ fn parse_login_args(matches: &clap::ArgMatches) -> (ScopeMode, Option("callback-host") + .expect("callback-host has a default_value and is always present") + .clone(); + + let callback_port = *matches + .get_one::("callback-port") + .expect("callback-port has a default_value and is always present"); + + (scope_mode, services_filter, callback_host, callback_port) } /// Run the `auth login` flow. @@ -532,9 +570,9 @@ pub async fn run_login(args: &[String]) -> Result<(), GwsError> { Err(e) => return Err(GwsError::Validation(e.to_string())), }; - let (scope_mode, services_filter) = parse_login_args(&matches); + let (scope_mode, services_filter, callback_host, callback_port) = parse_login_args(&matches); - handle_login_inner(scope_mode, services_filter).await + handle_login_inner(scope_mode, services_filter, callback_host, callback_port).await } /// Custom delegate that prints the OAuth URL on its own line for easy copying. /// Optionally includes `login_hint` in the URL for account pre-selection. @@ -576,6 +614,8 @@ impl yup_oauth2::authenticator_delegate::InstalledFlowDelegate for CliFlowDelega async fn handle_login_inner( scope_mode: ScopeMode, services_filter: Option>, + callback_host: String, + callback_port: u16, ) -> Result<(), GwsError> { // Resolve client_id and client_secret: // 1. Env vars (highest priority) @@ -618,13 +658,22 @@ async fn handle_login_inner( std::fs::create_dir_all(&config) .map_err(|e| GwsError::Validation(format!("Failed to create config directory: {e}")))?; - // If proxy env vars are set, use proxy-aware OAuth flow (reqwest) - // Otherwise use yup-oauth2 (faster, but doesn't support proxy) - let (access_token, refresh_token) = if crate::auth::has_proxy_env() { - login_with_proxy_support(&client_id, &client_secret, &scopes).await? - } else { - login_with_yup_oauth(&config, &client_id, &client_secret, &scopes).await? - }; + // If proxy env vars are set, or a custom callback host/port is requested, + // use proxy-aware OAuth flow (reqwest). Otherwise use yup-oauth2 (faster, + // but doesn't support proxy or custom callback configuration). + let (access_token, refresh_token) = + if crate::auth::has_proxy_env() || callback_port != 0 || callback_host != "localhost" { + login_with_proxy_support( + &client_id, + &client_secret, + &scopes, + &callback_host, + callback_port, + ) + .await? + } else { + login_with_yup_oauth(&config, &client_id, &client_secret, &scopes).await? + }; // Build credentials in the standard authorized_user format let creds_json = json!({ @@ -2532,4 +2581,78 @@ mod tests { let err = read_refresh_token_from_cache(file.path()).unwrap_err(); assert!(err.to_string().contains("no refresh token was returned")); } + + #[test] + #[serial_test::serial] + fn parse_login_args_defaults_callback_host_and_port() { + unsafe { + std::env::remove_var("GOOGLE_WORKSPACE_CLI_CALLBACK_HOST"); + std::env::remove_var("GOOGLE_WORKSPACE_CLI_CALLBACK_PORT"); + } + let matches = build_login_subcommand() + .try_get_matches_from(["login"]) + .unwrap(); + let (_, _, callback_host, callback_port) = parse_login_args(&matches); + assert_eq!(callback_host, "localhost"); + assert_eq!(callback_port, 0); + } + + #[test] + fn parse_login_args_custom_callback_host_and_port() { + let matches = build_login_subcommand() + .try_get_matches_from(["login", "--callback-host", "127.0.0.1", "--callback-port", "9090"]) + .unwrap(); + let (_, _, callback_host, callback_port) = parse_login_args(&matches); + assert_eq!(callback_host, "127.0.0.1"); + assert_eq!(callback_port, 9090u16); + } + + #[test] + #[serial_test::serial] + fn parse_login_args_callback_host_from_env() { + unsafe { + std::env::set_var("GOOGLE_WORKSPACE_CLI_CALLBACK_HOST", "myhost.local"); + } + let matches = build_login_subcommand() + .try_get_matches_from(["login"]) + .unwrap(); + let (_, _, callback_host, _) = parse_login_args(&matches); + unsafe { + std::env::remove_var("GOOGLE_WORKSPACE_CLI_CALLBACK_HOST"); + } + assert_eq!(callback_host, "myhost.local"); + } + + #[test] + #[serial_test::serial] + fn parse_login_args_callback_port_from_env() { + unsafe { + std::env::set_var("GOOGLE_WORKSPACE_CLI_CALLBACK_PORT", "8888"); + } + let matches = build_login_subcommand() + .try_get_matches_from(["login"]) + .unwrap(); + let (_, _, _, callback_port) = parse_login_args(&matches); + unsafe { + std::env::remove_var("GOOGLE_WORKSPACE_CLI_CALLBACK_PORT"); + } + assert_eq!(callback_port, 8888u16); + } + + #[test] + #[serial_test::serial] + fn parse_login_args_cli_arg_overrides_env_for_callback() { + // CLI arg takes precedence even when env var is set + unsafe { + std::env::set_var("GOOGLE_WORKSPACE_CLI_CALLBACK_PORT", "7777"); + } + let matches = build_login_subcommand() + .try_get_matches_from(["login", "--callback-port", "5555"]) + .unwrap(); + let (_, _, _, callback_port) = parse_login_args(&matches); + unsafe { + std::env::remove_var("GOOGLE_WORKSPACE_CLI_CALLBACK_PORT"); + } + assert_eq!(callback_port, 5555u16); + } } From 0cf842823b9808fba1b46d3cac1fe6da382f54e2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 08:28:38 +0000 Subject: [PATCH 3/8] fix: wrap IPv6 callback host in brackets in redirect_uri Agent-Logs-Url: https://github.com/ilteoood/gws-cli/sessions/4f0920fe-4e99-4de9-90c1-1c0a65dc3ab5 Co-authored-by: ilteoood <6383527+ilteoood@users.noreply.github.com> --- .../google-workspace-cli/src/auth_commands.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/google-workspace-cli/src/auth_commands.rs b/crates/google-workspace-cli/src/auth_commands.rs index 2d72097e..4ea3f8d9 100644 --- a/crates/google-workspace-cli/src/auth_commands.rs +++ b/crates/google-workspace-cli/src/auth_commands.rs @@ -131,7 +131,11 @@ async fn login_with_proxy_support( .local_addr() .map_err(|e| GwsError::Auth(format!("Failed to inspect local server: {e}")))? .port(); - let redirect_uri = format!("http://{}:{}", callback_host, port); + let redirect_uri = if callback_host.contains(':') && !callback_host.starts_with('[') { + format!("http://[{}]:{}", callback_host, port) + } else { + format!("http://{}:{}", callback_host, port) + }; let auth_url = build_proxy_auth_url(client_id, &redirect_uri, scopes); @@ -2655,4 +2659,17 @@ mod tests { } assert_eq!(callback_port, 5555u16); } + + #[test] + fn build_proxy_auth_url_ipv6_host_brackets_redirect_uri() { + // Verify that IPv6 addresses are wrapped in brackets in the redirect URI + let scopes = vec!["openid".to_string()]; + let redirect_uri = if "::1".contains(':') && !"::1".starts_with('[') { + format!("http://[::1]:{}", 8080) + } else { + format!("http://::1:{}", 8080) + }; + let url = build_proxy_auth_url("client-id", &redirect_uri, &scopes); + assert!(url.contains("redirect_uri=http%3A%2F%2F%5B%3A%3A1%5D%3A8080")); + } } From 9a3c821327806003a0c82551b6bffa4268819abe Mon Sep 17 00:00:00 2001 From: Matteo Pietro Dazzi Date: Mon, 6 Apr 2026 11:35:48 +0200 Subject: [PATCH 4/8] Update crates/google-workspace-cli/src/auth_commands.rs Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- crates/google-workspace-cli/src/auth_commands.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/google-workspace-cli/src/auth_commands.rs b/crates/google-workspace-cli/src/auth_commands.rs index 4ea3f8d9..f5590396 100644 --- a/crates/google-workspace-cli/src/auth_commands.rs +++ b/crates/google-workspace-cli/src/auth_commands.rs @@ -122,6 +122,8 @@ async fn login_with_proxy_support( // (e.g. Docker/CI where the callback arrives via port-forwarding). let bind_addr = if callback_host == "localhost" || callback_host == "127.0.0.1" { format!("127.0.0.1:{}", callback_port) + } else if callback_host == "::1" || callback_host == "[::1]" { + format!("[::1]:{}", callback_port) } else { format!("0.0.0.0:{}", callback_port) }; From 6cb94259002ba38493dab86035c6159c9b3baca3 Mon Sep 17 00:00:00 2001 From: Matteo Pietro Dazzi Date: Mon, 6 Apr 2026 11:41:19 +0200 Subject: [PATCH 5/8] Update crates/google-workspace-cli/src/auth_commands.rs Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- crates/google-workspace-cli/src/auth_commands.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/google-workspace-cli/src/auth_commands.rs b/crates/google-workspace-cli/src/auth_commands.rs index f5590396..9cc34b0f 100644 --- a/crates/google-workspace-cli/src/auth_commands.rs +++ b/crates/google-workspace-cli/src/auth_commands.rs @@ -124,6 +124,8 @@ async fn login_with_proxy_support( format!("127.0.0.1:{}", callback_port) } else if callback_host == "::1" || callback_host == "[::1]" { format!("[::1]:{}", callback_port) + } else if callback_host.contains(':') { + format!("[::]:{}", callback_port) } else { format!("0.0.0.0:{}", callback_port) }; From 7c58be78ca000deb5a25e574d58ae6fb5fe6b742 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 09:55:01 +0000 Subject: [PATCH 6/8] refactor: remove --callback-host, always bind to 0.0.0.0, keep port logic Agent-Logs-Url: https://github.com/ilteoood/gws-cli/sessions/603b728d-ac26-4682-ab45-3e6118d0aa91 Co-authored-by: ilteoood <6383527+ilteoood@users.noreply.github.com> --- AGENTS.md | 1 - .../google-workspace-cli/src/auth_commands.rs | 100 ++++-------------- 2 files changed, 19 insertions(+), 82 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 43b961fa..3e7af77d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -215,7 +215,6 @@ See [`src/helpers/README.md`](crates/google-workspace-cli/src/helpers/README.md) |---|---| | `GOOGLE_WORKSPACE_CLI_CLIENT_ID` | OAuth client ID (for `gws auth login` when no `client_secret.json` is saved) | | `GOOGLE_WORKSPACE_CLI_CLIENT_SECRET` | OAuth client secret (paired with `CLIENT_ID` above) | -| `GOOGLE_WORKSPACE_CLI_CALLBACK_HOST` | Hostname used in the OAuth redirect URI during `gws auth login` (default: `localhost`; overridden by `--callback-host`) | | `GOOGLE_WORKSPACE_CLI_CALLBACK_PORT` | Port for the local OAuth callback server during `gws auth login` (default: `0` = OS-assigned; overridden by `--callback-port`) | ### Sanitization (Model Armor) diff --git a/crates/google-workspace-cli/src/auth_commands.rs b/crates/google-workspace-cli/src/auth_commands.rs index 9cc34b0f..37d44a9e 100644 --- a/crates/google-workspace-cli/src/auth_commands.rs +++ b/crates/google-workspace-cli/src/auth_commands.rs @@ -114,32 +114,17 @@ async fn login_with_proxy_support( client_id: &str, client_secret: &str, scopes: &[String], - callback_host: &str, callback_port: u16, ) -> Result<(String, String), GwsError> { // Start local server to receive OAuth callback. - // Bind to loopback for local hostnames, or all interfaces for custom hosts - // (e.g. Docker/CI where the callback arrives via port-forwarding). - let bind_addr = if callback_host == "localhost" || callback_host == "127.0.0.1" { - format!("127.0.0.1:{}", callback_port) - } else if callback_host == "::1" || callback_host == "[::1]" { - format!("[::1]:{}", callback_port) - } else if callback_host.contains(':') { - format!("[::]:{}", callback_port) - } else { - format!("0.0.0.0:{}", callback_port) - }; - let listener = TcpListener::bind(&bind_addr) + // Bind to all interfaces so port-forwarding works in Docker/CI environments. + let listener = TcpListener::bind(format!("0.0.0.0:{}", callback_port)) .map_err(|e| GwsError::Auth(format!("Failed to start local server: {e}")))?; let port = listener .local_addr() .map_err(|e| GwsError::Auth(format!("Failed to inspect local server: {e}")))? .port(); - let redirect_uri = if callback_host.contains(':') && !callback_host.starts_with('[') { - format!("http://[{}]:{}", callback_host, port) - } else { - format!("http://{}:{}", callback_host, port) - }; + let redirect_uri = format!("http://localhost:{}", port); let auth_url = build_proxy_auth_url(client_id, &redirect_uri, scopes); @@ -409,14 +394,6 @@ fn build_login_subcommand() -> clap::Command { ) .value_name("services"), ) - .arg( - clap::Arg::new("callback-host") - .long("callback-host") - .env("GOOGLE_WORKSPACE_CLI_CALLBACK_HOST") - .help("Hostname used in the OAuth redirect URI (default: localhost)") - .value_name("HOST") - .default_value("localhost"), - ) .arg( clap::Arg::new("callback-port") .long("callback-port") @@ -482,10 +459,10 @@ pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> { match matches.subcommand() { Some(("login", sub_m)) => { - let (scope_mode, services_filter, callback_host, callback_port) = + let (scope_mode, services_filter, callback_port) = parse_login_args(sub_m); - handle_login_inner(scope_mode, services_filter, callback_host, callback_port).await + handle_login_inner(scope_mode, services_filter, callback_port).await } Some(("setup", sub_m)) => { // Collect remaining args and delegate to setup's own clap parser. @@ -517,10 +494,10 @@ fn login_command() -> clap::Command { build_login_subcommand() } -/// Extract `ScopeMode`, optional services filter, and OAuth callback config from parsed login args. +/// Extract `ScopeMode`, optional services filter, and OAuth callback port from parsed login args. fn parse_login_args( matches: &clap::ArgMatches, -) -> (ScopeMode, Option>, String, u16) { +) -> (ScopeMode, Option>, u16) { let scope_mode = if let Some(scopes_str) = matches.get_one::("scopes") { ScopeMode::Custom( scopes_str @@ -545,16 +522,11 @@ fn parse_login_args( .collect() }); - let callback_host = matches - .get_one::("callback-host") - .expect("callback-host has a default_value and is always present") - .clone(); - let callback_port = *matches .get_one::("callback-port") .expect("callback-port has a default_value and is always present"); - (scope_mode, services_filter, callback_host, callback_port) + (scope_mode, services_filter, callback_port) } /// Run the `auth login` flow. @@ -578,9 +550,9 @@ pub async fn run_login(args: &[String]) -> Result<(), GwsError> { Err(e) => return Err(GwsError::Validation(e.to_string())), }; - let (scope_mode, services_filter, callback_host, callback_port) = parse_login_args(&matches); + let (scope_mode, services_filter, callback_port) = parse_login_args(&matches); - handle_login_inner(scope_mode, services_filter, callback_host, callback_port).await + handle_login_inner(scope_mode, services_filter, callback_port).await } /// Custom delegate that prints the OAuth URL on its own line for easy copying. /// Optionally includes `login_hint` in the URL for account pre-selection. @@ -622,7 +594,6 @@ impl yup_oauth2::authenticator_delegate::InstalledFlowDelegate for CliFlowDelega async fn handle_login_inner( scope_mode: ScopeMode, services_filter: Option>, - callback_host: String, callback_port: u16, ) -> Result<(), GwsError> { // Resolve client_id and client_secret: @@ -666,16 +637,15 @@ async fn handle_login_inner( std::fs::create_dir_all(&config) .map_err(|e| GwsError::Validation(format!("Failed to create config directory: {e}")))?; - // If proxy env vars are set, or a custom callback host/port is requested, + // If proxy env vars are set, or a custom callback port is requested, // use proxy-aware OAuth flow (reqwest). Otherwise use yup-oauth2 (faster, // but doesn't support proxy or custom callback configuration). let (access_token, refresh_token) = - if crate::auth::has_proxy_env() || callback_port != 0 || callback_host != "localhost" { + if crate::auth::has_proxy_env() || callback_port != 0 { login_with_proxy_support( &client_id, &client_secret, &scopes, - &callback_host, callback_port, ) .await? @@ -2592,45 +2562,26 @@ mod tests { #[test] #[serial_test::serial] - fn parse_login_args_defaults_callback_host_and_port() { + fn parse_login_args_defaults_callback_port() { unsafe { - std::env::remove_var("GOOGLE_WORKSPACE_CLI_CALLBACK_HOST"); std::env::remove_var("GOOGLE_WORKSPACE_CLI_CALLBACK_PORT"); } let matches = build_login_subcommand() .try_get_matches_from(["login"]) .unwrap(); - let (_, _, callback_host, callback_port) = parse_login_args(&matches); - assert_eq!(callback_host, "localhost"); + let (_, _, callback_port) = parse_login_args(&matches); assert_eq!(callback_port, 0); } #[test] - fn parse_login_args_custom_callback_host_and_port() { + fn parse_login_args_custom_callback_port() { let matches = build_login_subcommand() - .try_get_matches_from(["login", "--callback-host", "127.0.0.1", "--callback-port", "9090"]) + .try_get_matches_from(["login", "--callback-port", "9090"]) .unwrap(); - let (_, _, callback_host, callback_port) = parse_login_args(&matches); - assert_eq!(callback_host, "127.0.0.1"); + let (_, _, callback_port) = parse_login_args(&matches); assert_eq!(callback_port, 9090u16); } - #[test] - #[serial_test::serial] - fn parse_login_args_callback_host_from_env() { - unsafe { - std::env::set_var("GOOGLE_WORKSPACE_CLI_CALLBACK_HOST", "myhost.local"); - } - let matches = build_login_subcommand() - .try_get_matches_from(["login"]) - .unwrap(); - let (_, _, callback_host, _) = parse_login_args(&matches); - unsafe { - std::env::remove_var("GOOGLE_WORKSPACE_CLI_CALLBACK_HOST"); - } - assert_eq!(callback_host, "myhost.local"); - } - #[test] #[serial_test::serial] fn parse_login_args_callback_port_from_env() { @@ -2640,7 +2591,7 @@ mod tests { let matches = build_login_subcommand() .try_get_matches_from(["login"]) .unwrap(); - let (_, _, _, callback_port) = parse_login_args(&matches); + let (_, _, callback_port) = parse_login_args(&matches); unsafe { std::env::remove_var("GOOGLE_WORKSPACE_CLI_CALLBACK_PORT"); } @@ -2657,23 +2608,10 @@ mod tests { let matches = build_login_subcommand() .try_get_matches_from(["login", "--callback-port", "5555"]) .unwrap(); - let (_, _, _, callback_port) = parse_login_args(&matches); + let (_, _, callback_port) = parse_login_args(&matches); unsafe { std::env::remove_var("GOOGLE_WORKSPACE_CLI_CALLBACK_PORT"); } assert_eq!(callback_port, 5555u16); } - - #[test] - fn build_proxy_auth_url_ipv6_host_brackets_redirect_uri() { - // Verify that IPv6 addresses are wrapped in brackets in the redirect URI - let scopes = vec!["openid".to_string()]; - let redirect_uri = if "::1".contains(':') && !"::1".starts_with('[') { - format!("http://[::1]:{}", 8080) - } else { - format!("http://::1:{}", 8080) - }; - let url = build_proxy_auth_url("client-id", &redirect_uri, &scopes); - assert!(url.contains("redirect_uri=http%3A%2F%2F%5B%3A%3A1%5D%3A8080")); - } } From b42a31365b91186f6987fb715ba23290ce426367 Mon Sep 17 00:00:00 2001 From: Matteo Pietro Dazzi Date: Mon, 6 Apr 2026 11:56:10 +0200 Subject: [PATCH 7/8] Apply suggestion from @ilteoood --- .changeset/oauth-callback-host-port.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/oauth-callback-host-port.md b/.changeset/oauth-callback-host-port.md index bc07774b..0ea5d19c 100644 --- a/.changeset/oauth-callback-host-port.md +++ b/.changeset/oauth-callback-host-port.md @@ -2,4 +2,4 @@ "@googleworkspace/cli": minor --- -Add `--callback-host` and `--callback-port` flags to `gws auth login` so users can configure the OAuth callback server host and port. Both flags also read from environment variables `GOOGLE_WORKSPACE_CLI_CALLBACK_HOST` and `GOOGLE_WORKSPACE_CLI_CALLBACK_PORT` respectively (CLI flags take precedence). This is useful when the OAuth app is registered with a fixed redirect URI or when running in Docker/CI with port-forwarding. +Add `--callback-port` flag to `gws auth login` so users can configure the OAuth callback server host and port. Both flags also read from environment variable `GOOGLE_WORKSPACE_CLI_CALLBACK_PORT` respectively (CLI flags take precedence). This is useful when the OAuth app is registered with a fixed redirect URI or when running in Docker/CI with port-forwarding. From e472f0b380fb63a66901cca8705c19978faaf693 Mon Sep 17 00:00:00 2001 From: Matteo Pietro Dazzi Date: Mon, 6 Apr 2026 12:26:17 +0200 Subject: [PATCH 8/8] Update crates/google-workspace-cli/src/auth_commands.rs Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- crates/google-workspace-cli/src/auth_commands.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/google-workspace-cli/src/auth_commands.rs b/crates/google-workspace-cli/src/auth_commands.rs index 37d44a9e..274436e2 100644 --- a/crates/google-workspace-cli/src/auth_commands.rs +++ b/crates/google-workspace-cli/src/auth_commands.rs @@ -118,7 +118,8 @@ async fn login_with_proxy_support( ) -> Result<(String, String), GwsError> { // Start local server to receive OAuth callback. // Bind to all interfaces so port-forwarding works in Docker/CI environments. - let listener = TcpListener::bind(format!("0.0.0.0:{}", callback_port)) + let host = if callback_port == 0 { "127.0.0.1" } else { "0.0.0.0" }; + let listener = TcpListener::bind(format!("{host}:{callback_port}")) .map_err(|e| GwsError::Auth(format!("Failed to start local server: {e}")))?; let port = listener .local_addr()