diff --git a/.changeset/oauth-redirect-override.md b/.changeset/oauth-redirect-override.md new file mode 100644 index 000000000..57ce16cbc --- /dev/null +++ b/.changeset/oauth-redirect-override.md @@ -0,0 +1,5 @@ +--- +"@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 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 f503815d0..88d671ffb 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,12 @@ # GOOGLE_WORKSPACE_CLI_CLIENT_ID= # GOOGLE_WORKSPACE_CLI_CLIENT_SECRET= +# ── OAuth callback customization (for remote-host logins) ───────── +# 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= + # ── Configuration ───────────────────────────────────────────────── # Override the config directory (default: ~/.config/gws) # GOOGLE_WORKSPACE_CLI_CONFIG_DIR= diff --git a/AGENTS.md b/AGENTS.md index 722112264..fd7fc55e4 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 (for remote-host logins) | +| `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 04c532d0a..419399e83 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 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"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 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) Point to your key file; no login needed. @@ -381,6 +398,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 (for remote-host logins) | +| `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 | | `GOOGLE_WORKSPACE_CLI_SANITIZE_MODE` | `warn` (default) or `block` | @@ -457,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 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 d7571e747..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}; @@ -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,28 +90,96 @@ 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 vars for customizing the OAuth login callback (see `gws --help`). +const ENV_OAUTH_REDIRECT_URI: &str = "GOOGLE_WORKSPACE_CLI_OAUTH_REDIRECT_URI"; +const ENV_OAUTH_STATE: &str = "GOOGLE_WORKSPACE_CLI_OAUTH_STATE"; +const ENV_OAUTH_PORT: &str = "GOOGLE_WORKSPACE_CLI_OAUTH_PORT"; + +fn get_non_empty_env_var(key: &str) -> Option { + std::env::var(key).ok().filter(|v| !v.is_empty()) +} + +fn has_oauth_callback_override() -> bool { + [ENV_OAUTH_REDIRECT_URI, ENV_OAUTH_STATE, ENV_OAUTH_PORT] + .iter() + .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()))?; + query_param(request_line, "code") + .ok_or_else(|| GwsError::Auth("No authorization code in callback".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().map(|value| value.to_string()) - } else { - None - } - }) +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}")) +} + +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}"))) }) - .ok_or_else(|| GwsError::Auth("No authorization code in callback".to_string())) + .transpose() + .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 @@ -116,15 +189,16 @@ async fn login_with_proxy_support( scopes: &[String], ) -> Result<(String, String), GwsError> { // Start local server to receive OAuth callback - let listener = TcpListener::bind("127.0.0.1:0") + 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 = format!("http://localhost:{}", 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); + 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); @@ -134,12 +208,10 @@ 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)?; let code = extract_authorization_code(&request_line)?; // Send success response to browser @@ -618,13 +690,14 @@ 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) + // 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() { - login_with_proxy_support(&client_id, &client_secret, &scopes).await? - } else { - login_with_yup_oauth(&config, &client_id, &client_secret, &scopes).await? - }; + let (access_token, refresh_token) = + 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? + }; // Build credentials in the standard authorized_user format let creds_json = json!({ @@ -1722,6 +1795,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 { @@ -2487,7 +2561,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 +2574,34 @@ 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://example.com/callback", + &scopes, + Some("eyJub25jZSI6eyJyZWRpcmVjdFVybCI6Ii4uLiJ9fQ"), + ); + + assert!(url.contains("state=eyJub25jZSI6eyJyZWRpcmVjdFVybCI6Ii4uLiJ9fQ")); + } + + #[test] + #[serial_test::serial] + 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 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] @@ -2505,12 +2612,97 @@ mod tests { assert_eq!(code, "4/test-code"); } + #[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(); + 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(); 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] + 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() { + 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 41dcc1e1f..d95122df3 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; @@ -481,6 +483,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 (for remote-host logins)" + ); + println!( + " 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)" + ); println!( " GOOGLE_WORKSPACE_CLI_CONFIG_DIR Override config directory (default: ~/.config/gws)" ); 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), + } + } +}