From ea3af71754ac48775cbd9112dba94fd11df0c46d Mon Sep 17 00:00:00 2001 From: Nikita Peshkov Date: Wed, 22 Jul 2026 13:15:24 +0200 Subject: [PATCH 01/11] add oauth customisation for non-localhost users --- .../google-workspace-cli/src/auth_commands.rs | 153 ++++++++++++++++-- 1 file changed, 138 insertions(+), 15 deletions(-) diff --git a/crates/google-workspace-cli/src/auth_commands.rs b/crates/google-workspace-cli/src/auth_commands.rs index d7571e747..f576e0f05 100644 --- a/crates/google-workspace-cli/src/auth_commands.rs +++ b/crates/google-workspace-cli/src/auth_commands.rs @@ -72,9 +72,14 @@ async fn exchange_code_with_reqwest( .map_err(|e| GwsError::Auth(format!("Failed to parse token response: {e}"))) } -fn build_proxy_auth_url(client_id: &str, redirect_uri: &str, scopes: &[String]) -> String { +fn build_proxy_auth_url( + client_id: &str, + redirect_uri: &str, + scopes: &[String], + state: Option<&str>, +) -> String { let scopes_str = scopes.join(" "); - format!( + let mut url = format!( "https://accounts.google.com/o/oauth2/auth?\ scope={}&\ access_type=offline&\ @@ -85,7 +90,45 @@ fn build_proxy_auth_url(client_id: &str, redirect_uri: &str, scopes: &[String]) urlencoding(&scopes_str), urlencoding(redirect_uri), urlencoding(client_id) - ) + ); + if let Some(state) = state { + url.push_str("&state="); + url.push_str(&urlencoding(state)); + } + url +} + +/// Env var overriding the redirect_uri Google sends the browser to after +/// auth. Defaults to `http://localhost:{port}`. Set this to route through an +/// external OAuth redirector (e.g. for a remote dev environment where the +/// browser can't reach the CLI's local callback server directly). +const ENV_OAUTH_REDIRECT_URI: &str = "GOOGLE_WORKSPACE_CLI_OAUTH_REDIRECT_URI"; +/// Env var providing a raw `state` value to attach to the auth URL. gws +/// treats it as an opaque string — whatever encodes the real callback target +/// (e.g. for an external redirector to decode) is entirely up to whoever +/// sets this. +const ENV_OAUTH_STATE: &str = "GOOGLE_WORKSPACE_CLI_OAUTH_STATE"; +/// Env var pinning the local callback server to a fixed port instead of an +/// OS-assigned ephemeral one, so external tooling can know the port in +/// advance (e.g. to embed it in `GOOGLE_WORKSPACE_CLI_OAUTH_STATE`). +const ENV_OAUTH_PORT: &str = "GOOGLE_WORKSPACE_CLI_OAUTH_PORT"; + +/// True if any of the OAuth redirect override env vars are set, meaning the +/// caller wants control over where/how the browser gets redirected. +fn has_oauth_redirect_override() -> bool { + std::env::var(ENV_OAUTH_REDIRECT_URI).is_ok() + || std::env::var(ENV_OAUTH_STATE).is_ok() + || std::env::var(ENV_OAUTH_PORT).is_ok() +} + +/// Resolves the redirect_uri Google should send the browser to, and the +/// `state` value (if any) to attach to the auth URL — both overridable via +/// env vars so external tooling can route the callback anywhere it needs to. +fn resolve_redirect_target(port: u16) -> (String, Option) { + let redirect_uri = std::env::var(ENV_OAUTH_REDIRECT_URI) + .unwrap_or_else(|_| format!("http://localhost:{port}")); + let state = std::env::var(ENV_OAUTH_STATE).ok(); + (redirect_uri, state) } fn extract_authorization_code(request_line: &str) -> Result { @@ -115,16 +158,26 @@ async fn login_with_proxy_support( client_secret: &str, scopes: &[String], ) -> 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. Binds to a fixed port + // when GOOGLE_WORKSPACE_CLI_OAUTH_PORT is set, so external tooling can + // know the port in advance; otherwise an OS-assigned ephemeral port. + let requested_port = std::env::var(ENV_OAUTH_PORT) + .ok() + .map(|p| { + p.parse::() + .map_err(|e| GwsError::Auth(format!("Invalid {ENV_OAUTH_PORT}: {e}"))) + }) + .transpose()? + .unwrap_or(0); + let listener = TcpListener::bind(("127.0.0.1", requested_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 = format!("http://localhost:{}", port); + let (redirect_uri, state) = resolve_redirect_target(port); - let auth_url = build_proxy_auth_url(client_id, &redirect_uri, scopes); + let auth_url = build_proxy_auth_url(client_id, &redirect_uri, scopes, state.as_deref()); println!("Open this URL in your browser to authenticate:\n"); println!(" {}\n", auth_url); @@ -618,13 +671,17 @@ 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? - }; + // The proxy-aware flow builds its own auth URL and redirect_uri, so it's + // also what's needed whenever a caller wants to override the redirect + // target (via GOOGLE_WORKSPACE_CLI_OAUTH_REDIRECT_URI/_STATE/_PORT) — + // yup-oauth2's InstalledFlowAuthenticator doesn't expose a way to + // override its redirect_uri or state per-request. + let (access_token, refresh_token) = + if crate::auth::has_proxy_env() || has_oauth_redirect_override() { + login_with_proxy_support(&client_id, &client_secret, &scopes).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!({ @@ -2487,7 +2544,12 @@ mod tests { "https://www.googleapis.com/auth/drive".to_string(), "openid".to_string(), ]; - let url = build_proxy_auth_url("client id", "http://localhost:8080/callback path", &scopes); + let url = build_proxy_auth_url( + "client id", + "http://localhost:8080/callback path", + &scopes, + None, + ); assert!(url.contains("client_id=client%20id")); assert!(url.contains("redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fcallback%20path")); @@ -2495,6 +2557,67 @@ mod tests { "scope={}", urlencoding("https://www.googleapis.com/auth/drive openid") ))); + assert!(!url.contains("state=")); + } + + #[test] + fn build_proxy_auth_url_includes_state_when_present() { + let scopes = vec!["openid".to_string()]; + let url = build_proxy_auth_url( + "client id", + "https://oauth-redirector.example.com", + &scopes, + Some("eyJ0YXJnZXRfdXJsIjoiZm9vIn0"), + ); + + assert!(url.contains("state=eyJ0YXJnZXRfdXJsIjoiZm9vIn0")); + } + + #[test] + #[serial_test::serial] + fn resolve_redirect_target_defaults_to_localhost() { + let keys = [ENV_OAUTH_REDIRECT_URI, ENV_OAUTH_STATE, ENV_OAUTH_PORT]; + let saved: Vec> = keys.iter().map(|k| std::env::var(k).ok()).collect(); + for k in keys { + std::env::remove_var(k); + } + + assert!(!has_oauth_redirect_override()); + let (redirect_uri, state) = resolve_redirect_target(12345); + assert_eq!(redirect_uri, "http://localhost:12345"); + assert!(state.is_none()); + + for (k, v) in keys.iter().zip(saved) { + if let Some(v) = v { + std::env::set_var(k, v); + } + } + } + + #[test] + #[serial_test::serial] + fn resolve_redirect_target_honors_env_overrides() { + let keys = [ENV_OAUTH_REDIRECT_URI, ENV_OAUTH_STATE, ENV_OAUTH_PORT]; + let saved: Vec> = keys.iter().map(|k| std::env::var(k).ok()).collect(); + + std::env::set_var( + ENV_OAUTH_REDIRECT_URI, + "https://oauth-redirector.example.com", + ); + std::env::set_var(ENV_OAUTH_STATE, "opaque-caller-supplied-state"); + std::env::remove_var(ENV_OAUTH_PORT); + + assert!(has_oauth_redirect_override()); + let (redirect_uri, state) = resolve_redirect_target(18323); + assert_eq!(redirect_uri, "https://oauth-redirector.example.com"); + assert_eq!(state, Some("opaque-caller-supplied-state".to_string())); + + for (k, v) in keys.iter().zip(saved) { + match v { + Some(v) => std::env::set_var(k, v), + None => std::env::remove_var(k), + } + } } #[test] From 5715f7e93f015e9d67a5cd898e1d87dbd1a65801 Mon Sep 17 00:00:00 2001 From: Nikita Peshkov Date: Wed, 22 Jul 2026 14:28:18 +0200 Subject: [PATCH 02/11] fix --- .../google-workspace-cli/src/auth_commands.rs | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/google-workspace-cli/src/auth_commands.rs b/crates/google-workspace-cli/src/auth_commands.rs index f576e0f05..cd438429e 100644 --- a/crates/google-workspace-cli/src/auth_commands.rs +++ b/crates/google-workspace-cli/src/auth_commands.rs @@ -143,12 +143,17 @@ fn extract_authorization_code(request_line: &str) -> Result { query.split('&').find_map(|pair| { let mut parts = pair.split('='); if parts.next() == Some("code") { - parts.next().map(|value| value.to_string()) + parts.next() } else { None } }) }) + .map(|value| { + percent_encoding::percent_decode_str(value) + .decode_utf8_lossy() + .into_owned() + }) .ok_or_else(|| GwsError::Auth("No authorization code in callback".to_string())) } @@ -193,8 +198,13 @@ async fn login_with_proxy_support( .read_line(&mut request_line) .map_err(|e| GwsError::Auth(format!("Failed to read request: {e}")))?; + eprintln!("Received callback: {}", request_line.trim()); + let code = extract_authorization_code(&request_line)?; + eprintln!("Parsed code: {code}"); + eprintln!("Using redirect_uri: {redirect_uri}"); + // Send success response to browser let response = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n\

Success!

You may now close this window.

"; @@ -2628,6 +2638,20 @@ mod tests { assert_eq!(code, "4/test-code"); } + #[test] + fn extract_authorization_code_decodes_percent_encoding() { + // A redirector hop can re-serialize the query string, percent-encoding + // characters (e.g. '/' as %2F, '+' as %2B) that Google's own raw + // redirect wouldn't have encoded. The extracted code must match what + // Google actually issued, or the token exchange fails with + // "invalid_grant: Malformed auth code". + let code = extract_authorization_code( + "GET /?state=abc&code=4%2Ftest%2Bcode&scope=openid HTTP/1.1", + ) + .unwrap(); + assert_eq!(code, "4/test+code"); + } + #[test] fn extract_authorization_code_rejects_missing_code() { let err = extract_authorization_code("GET /?state=abc HTTP/1.1").unwrap_err(); From 2a8bfe47e291ace7403fa0936ddf7c921b9153ab Mon Sep 17 00:00:00 2001 From: Nikita Peshkov Date: Wed, 22 Jul 2026 16:36:37 +0200 Subject: [PATCH 03/11] clean --- crates/google-workspace-cli/src/auth_commands.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/crates/google-workspace-cli/src/auth_commands.rs b/crates/google-workspace-cli/src/auth_commands.rs index cd438429e..44e8ae81f 100644 --- a/crates/google-workspace-cli/src/auth_commands.rs +++ b/crates/google-workspace-cli/src/auth_commands.rs @@ -198,13 +198,8 @@ async fn login_with_proxy_support( .read_line(&mut request_line) .map_err(|e| GwsError::Auth(format!("Failed to read request: {e}")))?; - eprintln!("Received callback: {}", request_line.trim()); - let code = extract_authorization_code(&request_line)?; - eprintln!("Parsed code: {code}"); - eprintln!("Using redirect_uri: {redirect_uri}"); - // Send success response to browser let response = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n\

Success!

You may now close this window.

"; From 846e1ff2d7181914b98ccc3c13f07df2b6499adc Mon Sep 17 00:00:00 2001 From: Nikita Peshkov Date: Thu, 23 Jul 2026 10:00:46 +0200 Subject: [PATCH 04/11] docs --- .changeset/oauth-redirect-override.md | 5 +++++ .env.example | 7 +++++++ AGENTS.md | 3 +++ README.md | 3 +++ crates/google-workspace-cli/src/main.rs | 9 +++++++++ 5 files changed, 27 insertions(+) create mode 100644 .changeset/oauth-redirect-override.md diff --git a/.changeset/oauth-redirect-override.md b/.changeset/oauth-redirect-override.md new file mode 100644 index 000000000..54a02b9f7 --- /dev/null +++ b/.changeset/oauth-redirect-override.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": minor +--- + +Support overriding the OAuth redirect target via `GOOGLE_WORKSPACE_CLI_OAUTH_REDIRECT_URI`, `GOOGLE_WORKSPACE_CLI_OAUTH_STATE`, and `GOOGLE_WORKSPACE_CLI_OAUTH_PORT`, so `gws auth login` can route through an external OAuth redirector (e.g. from a remote dev environment). Also percent-decode the authorization code from the callback so codes that pass through a redirector are exchanged correctly. diff --git a/.env.example b/.env.example index f503815d0..13a35f646 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,13 @@ # GOOGLE_WORKSPACE_CLI_CLIENT_ID= # GOOGLE_WORKSPACE_CLI_CLIENT_SECRET= +# ── OAuth redirect override (e.g. remote dev via a redirector) ───── +# Override the redirect_uri sent to Google, an opaque state value passed +# through to the auth URL, and/or pin the local callback port. +# GOOGLE_WORKSPACE_CLI_OAUTH_REDIRECT_URI= +# GOOGLE_WORKSPACE_CLI_OAUTH_STATE= +# GOOGLE_WORKSPACE_CLI_OAUTH_PORT= + # ── Configuration ───────────────────────────────────────────────── # Override the config directory (default: ~/.config/gws) # GOOGLE_WORKSPACE_CLI_CONFIG_DIR= diff --git a/AGENTS.md b/AGENTS.md index 722112264..af3845f67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -215,6 +215,9 @@ 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_OAUTH_REDIRECT_URI` | Override the OAuth `redirect_uri` (e.g. route through an external redirector) | +| `GOOGLE_WORKSPACE_CLI_OAUTH_STATE` | Opaque OAuth `state` value passed through to the auth URL | +| `GOOGLE_WORKSPACE_CLI_OAUTH_PORT` | Pin the local OAuth callback port (default: random) | ### Sanitization (Model Armor) diff --git a/README.md b/README.md index 04c532d0a..81323763e 100644 --- a/README.md +++ b/README.md @@ -381,6 +381,9 @@ All variables are optional. See [`.env.example`](.env.example) for a copy-paste | `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` | Path to OAuth credentials JSON (user or service account) | | `GOOGLE_WORKSPACE_CLI_CLIENT_ID` | OAuth client ID (alternative to `client_secret.json`) | | `GOOGLE_WORKSPACE_CLI_CLIENT_SECRET` | OAuth client secret (paired with `CLIENT_ID`) | +| `GOOGLE_WORKSPACE_CLI_OAUTH_REDIRECT_URI` | Override the OAuth `redirect_uri` (e.g. route through an external redirector) | +| `GOOGLE_WORKSPACE_CLI_OAUTH_STATE` | Opaque OAuth `state` value passed through to the auth URL | +| `GOOGLE_WORKSPACE_CLI_OAUTH_PORT` | Pin the local OAuth callback port (default: random) | | `GOOGLE_WORKSPACE_CLI_CONFIG_DIR` | Override config directory (default: `~/.config/gws`) | | `GOOGLE_WORKSPACE_CLI_SANITIZE_TEMPLATE` | Default Model Armor template | | `GOOGLE_WORKSPACE_CLI_SANITIZE_MODE` | `warn` (default) or `block` | diff --git a/crates/google-workspace-cli/src/main.rs b/crates/google-workspace-cli/src/main.rs index 41dcc1e1f..8640522c5 100644 --- a/crates/google-workspace-cli/src/main.rs +++ b/crates/google-workspace-cli/src/main.rs @@ -481,6 +481,15 @@ fn print_usage() { println!( " GOOGLE_WORKSPACE_CLI_CLIENT_SECRET OAuth client secret (for gws auth login)" ); + println!( + " GOOGLE_WORKSPACE_CLI_OAUTH_REDIRECT_URI Override OAuth redirect_uri (e.g. an external redirector)" + ); + println!( + " GOOGLE_WORKSPACE_CLI_OAUTH_STATE Opaque OAuth state value passed through to the auth URL" + ); + println!( + " GOOGLE_WORKSPACE_CLI_OAUTH_PORT Pin the local OAuth callback port (default: random)" + ); println!( " GOOGLE_WORKSPACE_CLI_CONFIG_DIR Override config directory (default: ~/.config/gws)" ); From 6501e9812100bfdfca93cadc1980bf7c9b7a89bc Mon Sep 17 00:00:00 2001 From: Nikita Peshkov Date: Thu, 23 Jul 2026 10:24:51 +0200 Subject: [PATCH 05/11] improve --- .../google-workspace-cli/src/auth_commands.rs | 46 +++++++++++++++---- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/crates/google-workspace-cli/src/auth_commands.rs b/crates/google-workspace-cli/src/auth_commands.rs index 44e8ae81f..844722274 100644 --- a/crates/google-workspace-cli/src/auth_commands.rs +++ b/crates/google-workspace-cli/src/auth_commands.rs @@ -113,21 +113,28 @@ const ENV_OAUTH_STATE: &str = "GOOGLE_WORKSPACE_CLI_OAUTH_STATE"; /// advance (e.g. to embed it in `GOOGLE_WORKSPACE_CLI_OAUTH_STATE`). const ENV_OAUTH_PORT: &str = "GOOGLE_WORKSPACE_CLI_OAUTH_PORT"; +/// Read an env var, treating an empty value the same as unset — matching the +/// convention `crate::auth::has_proxy_env` uses, so `export VAR=` doesn't +/// silently count as "set". +fn env_var_nonempty(key: &str) -> Option { + std::env::var(key).ok().filter(|v| !v.is_empty()) +} + /// True if any of the OAuth redirect override env vars are set, meaning the /// caller wants control over where/how the browser gets redirected. fn has_oauth_redirect_override() -> bool { - std::env::var(ENV_OAUTH_REDIRECT_URI).is_ok() - || std::env::var(ENV_OAUTH_STATE).is_ok() - || std::env::var(ENV_OAUTH_PORT).is_ok() + [ENV_OAUTH_REDIRECT_URI, ENV_OAUTH_STATE, ENV_OAUTH_PORT] + .iter() + .any(|key| env_var_nonempty(key).is_some()) } /// Resolves the redirect_uri Google should send the browser to, and the /// `state` value (if any) to attach to the auth URL — both overridable via /// env vars so external tooling can route the callback anywhere it needs to. fn resolve_redirect_target(port: u16) -> (String, Option) { - let redirect_uri = std::env::var(ENV_OAUTH_REDIRECT_URI) - .unwrap_or_else(|_| format!("http://localhost:{port}")); - let state = std::env::var(ENV_OAUTH_STATE).ok(); + let redirect_uri = + env_var_nonempty(ENV_OAUTH_REDIRECT_URI).unwrap_or_else(|| format!("http://localhost:{port}")); + let state = env_var_nonempty(ENV_OAUTH_STATE); (redirect_uri, state) } @@ -166,8 +173,7 @@ async fn login_with_proxy_support( // Start local server to receive OAuth callback. Binds to a fixed port // when GOOGLE_WORKSPACE_CLI_OAUTH_PORT is set, so external tooling can // know the port in advance; otherwise an OS-assigned ephemeral port. - let requested_port = std::env::var(ENV_OAUTH_PORT) - .ok() + let requested_port = env_var_nonempty(ENV_OAUTH_PORT) .map(|p| { p.parse::() .map_err(|e| GwsError::Auth(format!("Invalid {ENV_OAUTH_PORT}: {e}"))) @@ -2599,6 +2605,30 @@ mod tests { } } + #[test] + #[serial_test::serial] + fn resolve_redirect_target_treats_empty_env_as_unset() { + let keys = [ENV_OAUTH_REDIRECT_URI, ENV_OAUTH_STATE, ENV_OAUTH_PORT]; + let saved: Vec> = keys.iter().map(|k| std::env::var(k).ok()).collect(); + + // Empty-but-set vars must behave like unset (matches has_proxy_env). + for k in keys { + std::env::set_var(k, ""); + } + + assert!(!has_oauth_redirect_override()); + let (redirect_uri, state) = resolve_redirect_target(12345); + assert_eq!(redirect_uri, "http://localhost:12345"); + assert!(state.is_none()); + + for (k, v) in keys.iter().zip(saved) { + match v { + Some(v) => std::env::set_var(k, v), + None => std::env::remove_var(k), + } + } + } + #[test] #[serial_test::serial] fn resolve_redirect_target_honors_env_overrides() { From 0043eddd2c8a2a831fc0cde23e809f378883dfca Mon Sep 17 00:00:00 2001 From: Nikita Peshkov Date: Thu, 23 Jul 2026 12:05:38 +0200 Subject: [PATCH 06/11] format --- crates/google-workspace-cli/src/auth_commands.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/google-workspace-cli/src/auth_commands.rs b/crates/google-workspace-cli/src/auth_commands.rs index 844722274..20866e21b 100644 --- a/crates/google-workspace-cli/src/auth_commands.rs +++ b/crates/google-workspace-cli/src/auth_commands.rs @@ -132,8 +132,8 @@ fn has_oauth_redirect_override() -> bool { /// `state` value (if any) to attach to the auth URL — both overridable via /// env vars so external tooling can route the callback anywhere it needs to. fn resolve_redirect_target(port: u16) -> (String, Option) { - let redirect_uri = - env_var_nonempty(ENV_OAUTH_REDIRECT_URI).unwrap_or_else(|| format!("http://localhost:{port}")); + let redirect_uri = env_var_nonempty(ENV_OAUTH_REDIRECT_URI) + .unwrap_or_else(|| format!("http://localhost:{port}")); let state = env_var_nonempty(ENV_OAUTH_STATE); (redirect_uri, state) } From 16901909ba41477dea6039da5a720f1b41a21be2 Mon Sep 17 00:00:00 2001 From: Nikita Peshkov Date: Thu, 23 Jul 2026 17:47:22 +0200 Subject: [PATCH 07/11] improve --- .changeset/oauth-redirect-override.md | 2 +- .env.example | 5 +- AGENTS.md | 4 +- README.md | 4 +- crates/google-workspace-cli/src/auth.rs | 39 +---- .../google-workspace-cli/src/auth_commands.rs | 162 +++++------------- crates/google-workspace-cli/src/main.rs | 6 +- .../google-workspace-cli/src/test_support.rs | 54 ++++++ 8 files changed, 106 insertions(+), 170 deletions(-) create mode 100644 crates/google-workspace-cli/src/test_support.rs diff --git a/.changeset/oauth-redirect-override.md b/.changeset/oauth-redirect-override.md index 54a02b9f7..c8c44f2a4 100644 --- a/.changeset/oauth-redirect-override.md +++ b/.changeset/oauth-redirect-override.md @@ -2,4 +2,4 @@ "@googleworkspace/cli": minor --- -Support overriding the OAuth redirect target via `GOOGLE_WORKSPACE_CLI_OAUTH_REDIRECT_URI`, `GOOGLE_WORKSPACE_CLI_OAUTH_STATE`, and `GOOGLE_WORKSPACE_CLI_OAUTH_PORT`, so `gws auth login` can route through an external OAuth redirector (e.g. from a remote dev environment). Also percent-decode the authorization code from the callback so codes that pass through a redirector are exchanged correctly. +Allow customizing the `gws auth login` OAuth callback so it works when the CLI runs on a remote host whose `localhost` the browser connot reach directly: `GOOGLE_WORKSPACE_CLI_OAUTH_REDIRECT_URI` overrides the redirect URI sent to Google, `GOOGLE_WORKSPACE_CLI_OAUTH_STATE` sets the `state` value passed through to the auth URL, and `GOOGLE_WORKSPACE_CLI_OAUTH_PORT` pins the local callback (loopback) port. diff --git a/.env.example b/.env.example index 13a35f646..6697f7e46 100644 --- a/.env.example +++ b/.env.example @@ -14,9 +14,8 @@ # GOOGLE_WORKSPACE_CLI_CLIENT_ID= # GOOGLE_WORKSPACE_CLI_CLIENT_SECRET= -# ── OAuth redirect override (e.g. remote dev via a redirector) ───── -# Override the redirect_uri sent to Google, an opaque state value passed -# through to the auth URL, and/or pin the local callback port. +# ── OAuth callback customization (for remote-host logins) ───────── +# Override the redirect URI sent to Google, set a state value passed through to the auth URL, and/or pin the local callback port. # GOOGLE_WORKSPACE_CLI_OAUTH_REDIRECT_URI= # GOOGLE_WORKSPACE_CLI_OAUTH_STATE= # GOOGLE_WORKSPACE_CLI_OAUTH_PORT= diff --git a/AGENTS.md b/AGENTS.md index af3845f67..9f582c9f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -215,8 +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_OAUTH_REDIRECT_URI` | Override the OAuth `redirect_uri` (e.g. route through an external redirector) | -| `GOOGLE_WORKSPACE_CLI_OAUTH_STATE` | Opaque OAuth `state` value passed through to the auth URL | +| `GOOGLE_WORKSPACE_CLI_OAUTH_REDIRECT_URI` | Override the OAuth redirect URI (for remote-host logins) | +| `GOOGLE_WORKSPACE_CLI_OAUTH_STATE` | OAuth `state` value, passed through to the auth URL | | `GOOGLE_WORKSPACE_CLI_OAUTH_PORT` | Pin the local OAuth callback port (default: random) | ### Sanitization (Model Armor) diff --git a/README.md b/README.md index 81323763e..afa318efd 100644 --- a/README.md +++ b/README.md @@ -381,8 +381,8 @@ All variables are optional. See [`.env.example`](.env.example) for a copy-paste | `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` | Path to OAuth credentials JSON (user or service account) | | `GOOGLE_WORKSPACE_CLI_CLIENT_ID` | OAuth client ID (alternative to `client_secret.json`) | | `GOOGLE_WORKSPACE_CLI_CLIENT_SECRET` | OAuth client secret (paired with `CLIENT_ID`) | -| `GOOGLE_WORKSPACE_CLI_OAUTH_REDIRECT_URI` | Override the OAuth `redirect_uri` (e.g. route through an external redirector) | -| `GOOGLE_WORKSPACE_CLI_OAUTH_STATE` | Opaque OAuth `state` value passed through to the auth URL | +| `GOOGLE_WORKSPACE_CLI_OAUTH_REDIRECT_URI` | Override the OAuth redirect URI (for remote-host logins) | +| `GOOGLE_WORKSPACE_CLI_OAUTH_STATE` | OAuth `state` value, passed through to the auth URL | | `GOOGLE_WORKSPACE_CLI_OAUTH_PORT` | Pin the local OAuth callback port (default: random) | | `GOOGLE_WORKSPACE_CLI_CONFIG_DIR` | Override config directory (default: `~/.config/gws`) | | `GOOGLE_WORKSPACE_CLI_SANITIZE_TEMPLATE` | Default Model Armor template | diff --git a/crates/google-workspace-cli/src/auth.rs b/crates/google-workspace-cli/src/auth.rs index 9d8847e4b..064cb0784 100644 --- a/crates/google-workspace-cli/src/auth.rs +++ b/crates/google-workspace-cli/src/auth.rs @@ -436,47 +436,10 @@ async fn load_credentials_inner( #[cfg(test)] mod tests { use super::*; + use crate::test_support::EnvVarGuard; use std::io::Write; use tempfile::NamedTempFile; - /// RAII guard that saves the current value of an environment variable and - /// restores it when dropped. This ensures cleanup even if a test panics. - struct EnvVarGuard { - name: String, - original: Option, - } - - impl EnvVarGuard { - /// Save the current value of `name`, then set it to `value`. - fn set(name: &str, value: impl AsRef) -> Self { - let original = std::env::var_os(name); - std::env::set_var(name, value); - Self { - name: name.to_string(), - original, - } - } - - /// Save the current value of `name`, then remove it. - fn remove(name: &str) -> Self { - let original = std::env::var_os(name); - std::env::remove_var(name); - Self { - name: name.to_string(), - original, - } - } - } - - impl Drop for EnvVarGuard { - fn drop(&mut self) { - match &self.original { - Some(v) => std::env::set_var(&self.name, v), - None => std::env::remove_var(&self.name), - } - } - } - fn clear_proxy_env() -> Vec { PROXY_ENV_VARS .iter() diff --git a/crates/google-workspace-cli/src/auth_commands.rs b/crates/google-workspace-cli/src/auth_commands.rs index 20866e21b..b5b6bf53e 100644 --- a/crates/google-workspace-cli/src/auth_commands.rs +++ b/crates/google-workspace-cli/src/auth_commands.rs @@ -98,44 +98,19 @@ fn build_proxy_auth_url( url } -/// Env var overriding the redirect_uri Google sends the browser to after -/// auth. Defaults to `http://localhost:{port}`. Set this to route through an -/// external OAuth redirector (e.g. for a remote dev environment where the -/// browser can't reach the CLI's local callback server directly). +// Env vars for customizing the OAuth login callback (see `gws --help`). const ENV_OAUTH_REDIRECT_URI: &str = "GOOGLE_WORKSPACE_CLI_OAUTH_REDIRECT_URI"; -/// Env var providing a raw `state` value to attach to the auth URL. gws -/// treats it as an opaque string — whatever encodes the real callback target -/// (e.g. for an external redirector to decode) is entirely up to whoever -/// sets this. const ENV_OAUTH_STATE: &str = "GOOGLE_WORKSPACE_CLI_OAUTH_STATE"; -/// Env var pinning the local callback server to a fixed port instead of an -/// OS-assigned ephemeral one, so external tooling can know the port in -/// advance (e.g. to embed it in `GOOGLE_WORKSPACE_CLI_OAUTH_STATE`). const ENV_OAUTH_PORT: &str = "GOOGLE_WORKSPACE_CLI_OAUTH_PORT"; -/// Read an env var, treating an empty value the same as unset — matching the -/// convention `crate::auth::has_proxy_env` uses, so `export VAR=` doesn't -/// silently count as "set". -fn env_var_nonempty(key: &str) -> Option { +fn get_non_empty_env_var(key: &str) -> Option { std::env::var(key).ok().filter(|v| !v.is_empty()) } -/// True if any of the OAuth redirect override env vars are set, meaning the -/// caller wants control over where/how the browser gets redirected. -fn has_oauth_redirect_override() -> bool { +fn has_oauth_callback_override() -> bool { [ENV_OAUTH_REDIRECT_URI, ENV_OAUTH_STATE, ENV_OAUTH_PORT] .iter() - .any(|key| env_var_nonempty(key).is_some()) -} - -/// Resolves the redirect_uri Google should send the browser to, and the -/// `state` value (if any) to attach to the auth URL — both overridable via -/// env vars so external tooling can route the callback anywhere it needs to. -fn resolve_redirect_target(port: u16) -> (String, Option) { - let redirect_uri = env_var_nonempty(ENV_OAUTH_REDIRECT_URI) - .unwrap_or_else(|| format!("http://localhost:{port}")); - let state = env_var_nonempty(ENV_OAUTH_STATE); - (redirect_uri, state) + .any(|key| get_non_empty_env_var(key).is_some()) } fn extract_authorization_code(request_line: &str) -> Result { @@ -164,29 +139,36 @@ fn extract_authorization_code(request_line: &str) -> Result { .ok_or_else(|| GwsError::Auth("No authorization code in callback".to_string())) } +fn oauth_redirect_uri(port: u16) -> String { + get_non_empty_env_var(ENV_OAUTH_REDIRECT_URI) + .unwrap_or_else(|| format!("http://localhost:{port}")) +} + +fn oauth_callback_port() -> Result { + get_non_empty_env_var(ENV_OAUTH_PORT) + .map(|p| { + p.parse::() + .map_err(|e| GwsError::Auth(format!("Invalid {ENV_OAUTH_PORT}: {e}"))) + }) + .transpose() + .map(|port| port.unwrap_or(0)) +} + /// Perform OAuth login flow with proxy support using reqwest for token exchange async fn login_with_proxy_support( client_id: &str, client_secret: &str, scopes: &[String], ) -> Result<(String, String), GwsError> { - // Start local server to receive OAuth callback. Binds to a fixed port - // when GOOGLE_WORKSPACE_CLI_OAUTH_PORT is set, so external tooling can - // know the port in advance; otherwise an OS-assigned ephemeral port. - let requested_port = env_var_nonempty(ENV_OAUTH_PORT) - .map(|p| { - p.parse::() - .map_err(|e| GwsError::Auth(format!("Invalid {ENV_OAUTH_PORT}: {e}"))) - }) - .transpose()? - .unwrap_or(0); - let listener = TcpListener::bind(("127.0.0.1", requested_port)) + // Start local server to receive OAuth callback + let listener = TcpListener::bind(("127.0.0.1", oauth_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, state) = resolve_redirect_target(port); + let redirect_uri = oauth_redirect_uri(port); + let state = get_non_empty_env_var(ENV_OAUTH_STATE); let auth_url = build_proxy_auth_url(client_id, &redirect_uri, scopes, state.as_deref()); @@ -682,13 +664,10 @@ async fn handle_login_inner( std::fs::create_dir_all(&config) .map_err(|e| GwsError::Validation(format!("Failed to create config directory: {e}")))?; - // The proxy-aware flow builds its own auth URL and redirect_uri, so it's - // also what's needed whenever a caller wants to override the redirect - // target (via GOOGLE_WORKSPACE_CLI_OAUTH_REDIRECT_URI/_STATE/_PORT) — - // yup-oauth2's InstalledFlowAuthenticator doesn't expose a way to - // override its redirect_uri or state per-request. + // If proxy or oauth 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() || has_oauth_redirect_override() { + if crate::auth::has_proxy_env() || has_oauth_callback_override() { login_with_proxy_support(&client_id, &client_secret, &scopes).await? } else { login_with_yup_oauth(&config, &client_id, &client_secret, &scopes).await? @@ -1790,6 +1769,7 @@ async fn augment_with_dynamic_scopes( #[cfg(test)] mod tests { use super::*; + use crate::test_support::EnvVarGuard; /// Helper to run resolve_scopes in tests (async). fn run_resolve_scopes(scope_mode: ScopeMode, project_id: Option<&str>) -> Vec { @@ -2576,105 +2556,43 @@ mod tests { let scopes = vec!["openid".to_string()]; let url = build_proxy_auth_url( "client id", - "https://oauth-redirector.example.com", + "https://example.com/callback", &scopes, - Some("eyJ0YXJnZXRfdXJsIjoiZm9vIn0"), + Some("eyJub25jZSI6eyJyZWRpcmVjdFVybCI6Ii4uLiJ9fQ"), ); - assert!(url.contains("state=eyJ0YXJnZXRfdXJsIjoiZm9vIn0")); + assert!(url.contains("state=eyJub25jZSI6eyJyZWRpcmVjdFVybCI6Ii4uLiJ9fQ")); } #[test] #[serial_test::serial] - fn resolve_redirect_target_defaults_to_localhost() { - let keys = [ENV_OAUTH_REDIRECT_URI, ENV_OAUTH_STATE, ENV_OAUTH_PORT]; - let saved: Vec> = keys.iter().map(|k| std::env::var(k).ok()).collect(); - for k in keys { - std::env::remove_var(k); - } - - assert!(!has_oauth_redirect_override()); - let (redirect_uri, state) = resolve_redirect_target(12345); - assert_eq!(redirect_uri, "http://localhost:12345"); - assert!(state.is_none()); - - for (k, v) in keys.iter().zip(saved) { - if let Some(v) = v { - std::env::set_var(k, v); - } - } - } - - #[test] - #[serial_test::serial] - fn resolve_redirect_target_treats_empty_env_as_unset() { - let keys = [ENV_OAUTH_REDIRECT_URI, ENV_OAUTH_STATE, ENV_OAUTH_PORT]; - let saved: Vec> = keys.iter().map(|k| std::env::var(k).ok()).collect(); - - // Empty-but-set vars must behave like unset (matches has_proxy_env). - for k in keys { - std::env::set_var(k, ""); - } - - assert!(!has_oauth_redirect_override()); - let (redirect_uri, state) = resolve_redirect_target(12345); - assert_eq!(redirect_uri, "http://localhost:12345"); - assert!(state.is_none()); - - for (k, v) in keys.iter().zip(saved) { - match v { - Some(v) => std::env::set_var(k, v), - None => std::env::remove_var(k), - } - } + fn oauth_redirect_uri_defaults_to_localhost() { + let _uri = EnvVarGuard::remove(ENV_OAUTH_REDIRECT_URI); + assert_eq!(oauth_redirect_uri(12345), "http://localhost:12345"); } #[test] #[serial_test::serial] - fn resolve_redirect_target_honors_env_overrides() { - let keys = [ENV_OAUTH_REDIRECT_URI, ENV_OAUTH_STATE, ENV_OAUTH_PORT]; - let saved: Vec> = keys.iter().map(|k| std::env::var(k).ok()).collect(); - - std::env::set_var( - ENV_OAUTH_REDIRECT_URI, - "https://oauth-redirector.example.com", - ); - std::env::set_var(ENV_OAUTH_STATE, "opaque-caller-supplied-state"); - std::env::remove_var(ENV_OAUTH_PORT); - - assert!(has_oauth_redirect_override()); - let (redirect_uri, state) = resolve_redirect_target(18323); - assert_eq!(redirect_uri, "https://oauth-redirector.example.com"); - assert_eq!(state, Some("opaque-caller-supplied-state".to_string())); - - for (k, v) in keys.iter().zip(saved) { - match v { - Some(v) => std::env::set_var(k, v), - None => std::env::remove_var(k), - } - } + fn oauth_redirect_uri_honors_override() { + let _uri = EnvVarGuard::set(ENV_OAUTH_REDIRECT_URI, "https://example.com/callback"); + assert_eq!(oauth_redirect_uri(12345), "https://example.com/callback"); } #[test] fn extract_authorization_code_returns_code() { let code = - extract_authorization_code("GET /?state=abc&code=4/test-code&scope=openid HTTP/1.1") + extract_authorization_code("GET /?state=abc&code=my/test-code1&scope=openid HTTP/1.1") .unwrap(); - assert_eq!(code, "4/test-code"); + assert_eq!(code, "my/test-code1"); } #[test] fn extract_authorization_code_decodes_percent_encoding() { - // A redirector hop can re-serialize the query string, percent-encoding - // characters (e.g. '/' as %2F, '+' as %2B) that Google's own raw - // redirect wouldn't have encoded. The extracted code must match what - // Google actually issued, or the token exchange fails with - // "invalid_grant: Malformed auth code". let code = extract_authorization_code( - "GET /?state=abc&code=4%2Ftest%2Bcode&scope=openid HTTP/1.1", + "GET /?state=abc&code=my%2Ftest-code1&scope=openid HTTP/1.1", ) .unwrap(); - assert_eq!(code, "4/test+code"); + assert_eq!(code, "my/test-code1"); } #[test] diff --git a/crates/google-workspace-cli/src/main.rs b/crates/google-workspace-cli/src/main.rs index 8640522c5..f594775be 100644 --- a/crates/google-workspace-cli/src/main.rs +++ b/crates/google-workspace-cli/src/main.rs @@ -38,6 +38,8 @@ mod schema; mod services; mod setup; mod setup_tui; +#[cfg(test)] +mod test_support; mod text; mod timezone; mod token_storage; @@ -482,10 +484,10 @@ fn print_usage() { " GOOGLE_WORKSPACE_CLI_CLIENT_SECRET OAuth client secret (for gws auth login)" ); println!( - " GOOGLE_WORKSPACE_CLI_OAUTH_REDIRECT_URI Override OAuth redirect_uri (e.g. an external redirector)" + " GOOGLE_WORKSPACE_CLI_OAUTH_REDIRECT_URI Override OAuth redirect URI (for remote-host logins)" ); println!( - " GOOGLE_WORKSPACE_CLI_OAUTH_STATE Opaque OAuth state value passed through to the auth URL" + " GOOGLE_WORKSPACE_CLI_OAUTH_STATE OAuth state value, passed through to the auth URL" ); println!( " GOOGLE_WORKSPACE_CLI_OAUTH_PORT Pin the local OAuth callback port (default: random)" diff --git a/crates/google-workspace-cli/src/test_support.rs b/crates/google-workspace-cli/src/test_support.rs new file mode 100644 index 000000000..62ea92eac --- /dev/null +++ b/crates/google-workspace-cli/src/test_support.rs @@ -0,0 +1,54 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Shared test-only helpers. + +/// Saves an env var's current value on construction and restores it (or unsets +/// it) on drop — including on panic. Pair with `#[serial_test::serial]`, since +/// the environment is process-global. +pub(crate) struct EnvVarGuard { + name: String, + original: Option, +} + +impl EnvVarGuard { + /// Save the current value of `name`, then set it to `value`. + pub(crate) fn set(name: &str, value: impl AsRef) -> Self { + let original = std::env::var_os(name); + std::env::set_var(name, value); + Self { + name: name.to_string(), + original, + } + } + + /// Save the current value of `name`, then remove it. + pub(crate) fn remove(name: &str) -> Self { + let original = std::env::var_os(name); + std::env::remove_var(name); + Self { + name: name.to_string(), + original, + } + } +} + +impl Drop for EnvVarGuard { + fn drop(&mut self) { + match &self.original { + Some(v) => std::env::set_var(&self.name, v), + None => std::env::remove_var(&self.name), + } + } +} From aea296e0d4f03df091d7375840ff4663c41581f4 Mon Sep 17 00:00:00 2001 From: Nikita Peshkov Date: Thu, 23 Jul 2026 18:40:53 +0200 Subject: [PATCH 08/11] expand readme --- README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/README.md b/README.md index afa318efd..5d9d0283c 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,7 @@ The CLI supports multiple auth workflows so it works on your laptop, in CI, and | A GCP project but no `gcloud` | [Manual OAuth setup](#manual-oauth-setup-google-cloud-console) | | An existing OAuth access token | [`GOOGLE_WORKSPACE_CLI_TOKEN`](#pre-obtained-access-token) | | Existing Credentials | [`GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE`](#service-account-server-to-server) | +| A remote host unreachable via `localhost` from my browser | [Remote host](#remote-host-browser-on-another-machine) | ### Interactive (local desktop) @@ -187,6 +188,22 @@ If scope checkboxes appear, select required scopes (or **Select all**) before co gws drive files list # just works ``` +### Remote host (browser on another machine) + +When the CLI runs where the browser cannot reach via `localhost` (e.g. a cloud development environment), tell the OAuth server to redirect the authorization code to the CLI's host: + +```bash +# Where Google sends the browser (must be an authorized redirect URI on the OAuth client) +export GOOGLE_WORKSPACE_CLI_OAUTH_REDIRECT_URI="https://example.com/callback" +# Optional value passed through to the listening server (see OAuth 2.0 docs) +export GOOGLE_WORKSPACE_CLI_OAUTH_STATE="$STATE" +# Optional port for the CLI's callback server +export GOOGLE_WORKSPACE_CLI_OAUTH_PORT=42067 +gws auth login +``` + +This flow requires a web application OAuth client that would allow redirects to URIs other than `http://localhost`. Provide the client's details via `GOOGLE_WORKSPACE_CLI_CLIENT_ID` and `GOOGLE_WORKSPACE_CLI_CLIENT_SECRET` environment variables. + ### Service Account (server-to-server) Point to your key file; no login needed. @@ -460,6 +477,8 @@ gws auth login --scopes drive,gmail,calendar The OAuth client was not created as a **Desktop app** type. In the [Credentials](https://console.cloud.google.com/apis/credentials) page, delete the existing client, create a new one with type **Desktop app**, and download the new JSON. +Using the [remote-host flow](#remote-host-browser-on-another-machine) instead? There it means the URL in `GOOGLE_WORKSPACE_CLI_OAUTH_REDIRECT_URI` is not registered on your **Web application** client - add it under **Authorized redirect URIs**. + ### API not enabled — `accessNotConfigured` If a required Google API is not enabled for your GCP project, you will see a From 5a9fe10082f33f5bbc97ad9285a70872ea52165d Mon Sep 17 00:00:00 2001 From: Nikita Peshkov <90259794+npeshkov@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:36:17 +0200 Subject: [PATCH 09/11] fix --- crates/google-workspace-cli/src/auth_commands.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/google-workspace-cli/src/auth_commands.rs b/crates/google-workspace-cli/src/auth_commands.rs index b5b6bf53e..1b895da24 100644 --- a/crates/google-workspace-cli/src/auth_commands.rs +++ b/crates/google-workspace-cli/src/auth_commands.rs @@ -2581,18 +2581,18 @@ mod tests { #[test] fn extract_authorization_code_returns_code() { let code = - extract_authorization_code("GET /?state=abc&code=my/test-code1&scope=openid HTTP/1.1") + extract_authorization_code("GET /?state=abc&code=4/test-code&scope=openid HTTP/1.1") .unwrap(); - assert_eq!(code, "my/test-code1"); + assert_eq!(code, "4/test-code"); } #[test] fn extract_authorization_code_decodes_percent_encoding() { let code = extract_authorization_code( - "GET /?state=abc&code=my%2Ftest-code1&scope=openid HTTP/1.1", + "GET /?state=abc&code=4%2Ftest-code&scope=openid HTTP/1.1", ) .unwrap(); - assert_eq!(code, "my/test-code1"); + assert_eq!(code, "4/test-code"); } #[test] From be354ee5dda5a463cbb1062ada0e199427388cf5 Mon Sep 17 00:00:00 2001 From: Nikita Peshkov Date: Fri, 24 Jul 2026 09:57:42 +0200 Subject: [PATCH 10/11] add state verification --- .changeset/oauth-redirect-override.md | 2 +- .env.example | 2 +- AGENTS.md | 2 +- README.md | 10 +- .../google-workspace-cli/src/auth_commands.rs | 120 ++++++++++++++---- crates/google-workspace-cli/src/main.rs | 2 +- 6 files changed, 106 insertions(+), 32 deletions(-) diff --git a/.changeset/oauth-redirect-override.md b/.changeset/oauth-redirect-override.md index c8c44f2a4..57ce16cbc 100644 --- a/.changeset/oauth-redirect-override.md +++ b/.changeset/oauth-redirect-override.md @@ -2,4 +2,4 @@ "@googleworkspace/cli": minor --- -Allow customizing the `gws auth login` OAuth callback so it works when the CLI runs on a remote host whose `localhost` the browser connot reach directly: `GOOGLE_WORKSPACE_CLI_OAUTH_REDIRECT_URI` overrides the redirect URI sent to Google, `GOOGLE_WORKSPACE_CLI_OAUTH_STATE` sets the `state` value passed through to the auth URL, and `GOOGLE_WORKSPACE_CLI_OAUTH_PORT` pins the local callback (loopback) port. +Allow customizing the `gws auth login` OAuth callback so it works when the CLI runs on a remote host whose `localhost` the browser connot reach directly: `GOOGLE_WORKSPACE_CLI_OAUTH_REDIRECT_URI` overrides the redirect URI sent to Google, `GOOGLE_WORKSPACE_CLI_OAUTH_STATE` sets the `state` value passed to the OAuth server, and `GOOGLE_WORKSPACE_CLI_OAUTH_PORT` pins the local callback port. When a custom `state` is set, the value returned on the callback is verified against it to reject forged (CSRF) responses. diff --git a/.env.example b/.env.example index 6697f7e46..88d671ffb 100644 --- a/.env.example +++ b/.env.example @@ -15,7 +15,7 @@ # GOOGLE_WORKSPACE_CLI_CLIENT_SECRET= # ── OAuth callback customization (for remote-host logins) ───────── -# Override the redirect URI sent to Google, set a state value passed through to the auth URL, and/or pin the local callback port. +# Override the redirect URI sent to Google, set a state value passed to the OAuth server (verified on the callback), and/or pin the local callback port. # GOOGLE_WORKSPACE_CLI_OAUTH_REDIRECT_URI= # GOOGLE_WORKSPACE_CLI_OAUTH_STATE= # GOOGLE_WORKSPACE_CLI_OAUTH_PORT= diff --git a/AGENTS.md b/AGENTS.md index 9f582c9f5..fd7fc55e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -216,7 +216,7 @@ 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_OAUTH_REDIRECT_URI` | Override the OAuth redirect URI (for remote-host logins) | -| `GOOGLE_WORKSPACE_CLI_OAUTH_STATE` | OAuth `state` value, passed through to the auth URL | +| `GOOGLE_WORKSPACE_CLI_OAUTH_STATE` | OAuth `state` value, passed to the OAuth server and verified on the callback | | `GOOGLE_WORKSPACE_CLI_OAUTH_PORT` | Pin the local OAuth callback port (default: random) | ### Sanitization (Model Armor) diff --git a/README.md b/README.md index 5d9d0283c..419399e83 100644 --- a/README.md +++ b/README.md @@ -190,19 +190,19 @@ If scope checkboxes appear, select required scopes (or **Select all**) before co ### Remote host (browser on another machine) -When the CLI runs where the browser cannot reach via `localhost` (e.g. a cloud development environment), tell the OAuth server to redirect the authorization code to the CLI's host: +When the CLI runs where the browser cannot reach it via `localhost` (e.g. a cloud development environment), point the OAuth redirect at an address the browser can reach, which delivers the authorization code back to the CLI: ```bash # Where Google sends the browser (must be an authorized redirect URI on the OAuth client) -export GOOGLE_WORKSPACE_CLI_OAUTH_REDIRECT_URI="https://example.com/callback" -# Optional value passed through to the listening server (see OAuth 2.0 docs) +export GOOGLE_WORKSPACE_CLI_OAUTH_REDIRECT_URI="https://example.com/callback"k +# Optional value passed to the OAuth server and verified when returned on the callback export GOOGLE_WORKSPACE_CLI_OAUTH_STATE="$STATE" # Optional port for the CLI's callback server export GOOGLE_WORKSPACE_CLI_OAUTH_PORT=42067 gws auth login ``` -This flow requires a web application OAuth client that would allow redirects to URIs other than `http://localhost`. Provide the client's details via `GOOGLE_WORKSPACE_CLI_CLIENT_ID` and `GOOGLE_WORKSPACE_CLI_CLIENT_SECRET` environment variables. +This flow requires a web application OAuth client that allows redirects to URIs other than `http://localhost`. Provide the client's details via `GOOGLE_WORKSPACE_CLI_CLIENT_ID` and `GOOGLE_WORKSPACE_CLI_CLIENT_SECRET` environment variables. ### Service Account (server-to-server) @@ -399,7 +399,7 @@ All variables are optional. See [`.env.example`](.env.example) for a copy-paste | `GOOGLE_WORKSPACE_CLI_CLIENT_ID` | OAuth client ID (alternative to `client_secret.json`) | | `GOOGLE_WORKSPACE_CLI_CLIENT_SECRET` | OAuth client secret (paired with `CLIENT_ID`) | | `GOOGLE_WORKSPACE_CLI_OAUTH_REDIRECT_URI` | Override the OAuth redirect URI (for remote-host logins) | -| `GOOGLE_WORKSPACE_CLI_OAUTH_STATE` | OAuth `state` value, passed through to the auth URL | +| `GOOGLE_WORKSPACE_CLI_OAUTH_STATE` | OAuth `state` value, passed to the OAuth server and verified on the callback | | `GOOGLE_WORKSPACE_CLI_OAUTH_PORT` | Pin the local OAuth callback port (default: random) | | `GOOGLE_WORKSPACE_CLI_CONFIG_DIR` | Override config directory (default: `~/.config/gws`) | | `GOOGLE_WORKSPACE_CLI_SANITIZE_TEMPLATE` | Default Model Armor template | diff --git a/crates/google-workspace-cli/src/auth_commands.rs b/crates/google-workspace-cli/src/auth_commands.rs index 1b895da24..6cb619a18 100644 --- a/crates/google-workspace-cli/src/auth_commands.rs +++ b/crates/google-workspace-cli/src/auth_commands.rs @@ -113,32 +113,42 @@ fn has_oauth_callback_override() -> bool { .any(|key| get_non_empty_env_var(key).is_some()) } +fn query_param(request_line: &str, name: &str) -> Option { + let query = request_line.split_whitespace().nth(1)?.split('?').nth(1)?; + query.split('&').find_map(|pair| { + let mut parts = pair.splitn(2, '='); + if parts.next() == Some(name) { + parts.next().map(|value| { + percent_encoding::percent_decode_str(value) + .decode_utf8_lossy() + .into_owned() + }) + } else { + None + } + }) +} + fn extract_authorization_code(request_line: &str) -> Result { - let path = request_line + request_line .split_whitespace() .nth(1) .ok_or_else(|| GwsError::Auth("Invalid HTTP request".to_string()))?; - - path.split('?') - .nth(1) - .and_then(|query| { - query.split('&').find_map(|pair| { - let mut parts = pair.split('='); - if parts.next() == Some("code") { - parts.next() - } else { - None - } - }) - }) - .map(|value| { - percent_encoding::percent_decode_str(value) - .decode_utf8_lossy() - .into_owned() - }) + query_param(request_line, "code") .ok_or_else(|| GwsError::Auth("No authorization code in callback".to_string())) } +fn verify_callback_state(request_line: &str) -> Result<(), GwsError> { + let Some(expected) = get_non_empty_env_var(ENV_OAUTH_STATE) else { + return Ok(()); + }; + if query_param(request_line, "state").as_deref() == Some(expected.as_str()) { + Ok(()) + } else { + Err(GwsError::Auth("OAuth state mismatch".to_string())) + } +} + fn oauth_redirect_uri(port: u16) -> String { get_non_empty_env_var(ENV_OAUTH_REDIRECT_URI) .unwrap_or_else(|| format!("http://localhost:{port}")) @@ -186,6 +196,8 @@ async fn login_with_proxy_support( .read_line(&mut request_line) .map_err(|e| GwsError::Auth(format!("Failed to read request: {e}")))?; + // Reject a forged callback before doing anything with the code + verify_callback_state(&request_line)?; let code = extract_authorization_code(&request_line)?; // Send success response to browser @@ -2588,10 +2600,9 @@ mod tests { #[test] fn extract_authorization_code_decodes_percent_encoding() { - let code = extract_authorization_code( - "GET /?state=abc&code=4%2Ftest-code&scope=openid HTTP/1.1", - ) - .unwrap(); + let code = + extract_authorization_code("GET /?state=abc&code=4%2Ftest-code&scope=openid HTTP/1.1") + .unwrap(); assert_eq!(code, "4/test-code"); } @@ -2601,6 +2612,69 @@ mod tests { assert!(err.to_string().contains("No authorization code")); } + #[test] + fn extract_authorization_code_rejects_malformed_request() { + let err = extract_authorization_code("GARBAGE").unwrap_err(); + assert!(err.to_string().contains("Invalid HTTP request")); + } + + #[test] + fn query_param_returns_decoded_value() { + let line = "GET /?state=abc&code=4%2Ftest-code&scope=openid HTTP/1.1"; + assert_eq!(query_param(line, "code").as_deref(), Some("4/test-code")); + assert_eq!(query_param(line, "state").as_deref(), Some("abc")); + assert_eq!(query_param(line, "scope").as_deref(), Some("openid")); + } + + #[test] + fn query_param_absent_or_no_query_is_none() { + assert_eq!(query_param("GET /?a=1 HTTP/1.1", "code"), None); + assert_eq!(query_param("GET / HTTP/1.1", "code"), None); + assert_eq!(query_param("GARBAGE", "code"), None); + } + + #[test] + fn query_param_matches_full_name_not_prefix() { + let line = "GET /?abcd=no&abc=yes HTTP/1.1"; + assert_eq!(query_param(line, "abc").as_deref(), Some("yes")); + } + + #[test] + fn query_param_preserves_equals_in_value() { + let line = "GET /?state=YQ==&code=x HTTP/1.1"; + assert_eq!(query_param(line, "state").as_deref(), Some("YQ==")); + } + + #[test] + #[serial_test::serial] + fn verify_callback_state_skips_when_no_state_configured() { + let _state = EnvVarGuard::remove(ENV_OAUTH_STATE); + + verify_callback_state("GET /?code=4/test-code HTTP/1.1").unwrap(); + verify_callback_state("GET /?state=whatever&code=4/test-code HTTP/1.1").unwrap(); + } + + #[test] + #[serial_test::serial] + fn verify_callback_state_accepts_matching_state() { + let _state = EnvVarGuard::set(ENV_OAUTH_STATE, "secure-state"); + + verify_callback_state("GET /?state=secure-state&code=4/test-code HTTP/1.1").unwrap(); + verify_callback_state("GET /?state=secure%2Dstate&code=4/x HTTP/1.1").unwrap(); + } + + #[test] + #[serial_test::serial] + fn verify_callback_state_rejects_mismatched_or_missing_state() { + let _state = EnvVarGuard::set(ENV_OAUTH_STATE, "secure-state"); + + let err = verify_callback_state("GET /?state=wrong&code=4/test-code HTTP/1.1").unwrap_err(); + assert!(err.to_string().contains("state mismatch")); + + let err = verify_callback_state("GET /?code=4/test-code HTTP/1.1").unwrap_err(); + assert!(err.to_string().contains("state mismatch")); + } + #[test] fn read_refresh_token_from_cache_reads_encrypted_storage() { let token_data = r#"[{"token":{"refresh_token":"1//refresh-token"}}]"#; diff --git a/crates/google-workspace-cli/src/main.rs b/crates/google-workspace-cli/src/main.rs index f594775be..d95122df3 100644 --- a/crates/google-workspace-cli/src/main.rs +++ b/crates/google-workspace-cli/src/main.rs @@ -487,7 +487,7 @@ fn print_usage() { " GOOGLE_WORKSPACE_CLI_OAUTH_REDIRECT_URI Override OAuth redirect URI (for remote-host logins)" ); println!( - " GOOGLE_WORKSPACE_CLI_OAUTH_STATE OAuth state value, passed through to the auth URL" + " GOOGLE_WORKSPACE_CLI_OAUTH_STATE OAuth state value passed to the server and verified on the callback" ); println!( " GOOGLE_WORKSPACE_CLI_OAUTH_PORT Pin the local OAuth callback port (default: random)" From d1cf429f14550a615a0f5a1f63f1e5c2e1cf816c Mon Sep 17 00:00:00 2001 From: Nikita Peshkov Date: Fri, 24 Jul 2026 16:50:46 +0200 Subject: [PATCH 11/11] limit callback request size --- .../google-workspace-cli/src/auth_commands.rs | 40 ++++++++++++++++--- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/crates/google-workspace-cli/src/auth_commands.rs b/crates/google-workspace-cli/src/auth_commands.rs index 6cb619a18..4ef488b41 100644 --- a/crates/google-workspace-cli/src/auth_commands.rs +++ b/crates/google-workspace-cli/src/auth_commands.rs @@ -13,7 +13,7 @@ // limitations under the License. use std::collections::HashSet; -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, BufReader, Read, Write}; use std::net::TcpListener; use std::path::{Path, PathBuf}; @@ -164,6 +164,24 @@ fn oauth_callback_port() -> Result { .map(|port| port.unwrap_or(0)) } +const MAX_CALLBACK_REQUEST_BYTES: u64 = 8 * 1024; + +fn read_request_line(stream: R) -> Result { + let mut reader = BufReader::new(stream.take(MAX_CALLBACK_REQUEST_BYTES)); + let mut request_line = String::new(); + let read = reader + .read_line(&mut request_line) + .map_err(|e| GwsError::Auth(format!("Failed to read request: {e}")))?; + + if read as u64 == MAX_CALLBACK_REQUEST_BYTES && !request_line.ends_with('\n') { + return Err(GwsError::Auth(format!( + "OAuth callback request line exceeded {MAX_CALLBACK_REQUEST_BYTES} bytes. \ + The request was rejected without being processed" + ))); + } + Ok(request_line) +} + /// Perform OAuth login flow with proxy support using reqwest for token exchange async fn login_with_proxy_support( client_id: &str, @@ -190,11 +208,7 @@ async fn login_with_proxy_support( .accept() .map_err(|e| GwsError::Auth(format!("Failed to accept connection: {e}")))?; - let mut reader = BufReader::new(&stream); - let mut request_line = String::new(); - reader - .read_line(&mut request_line) - .map_err(|e| GwsError::Auth(format!("Failed to read request: {e}")))?; + let request_line = read_request_line(&stream)?; // Reject a forged callback before doing anything with the code verify_callback_state(&request_line)?; @@ -2645,6 +2659,20 @@ mod tests { assert_eq!(query_param(line, "state").as_deref(), Some("YQ==")); } + #[test] + fn read_request_line_reads_normal_line() { + let input = b"GET /?code=4/x HTTP/1.1\r\n"; + let line = read_request_line(&input[..]).unwrap(); + assert!(line.starts_with("GET /?code=4/x")); + } + + #[test] + fn read_request_line_is_bounded_without_newline() { + let huge = vec![b'a'; MAX_CALLBACK_REQUEST_BYTES as usize * 4]; + let err = read_request_line(&huge[..]).unwrap_err(); + assert!(err.to_string().contains("exceeded")); + } + #[test] #[serial_test::serial] fn verify_callback_state_skips_when_no_state_configured() {