diff --git a/.changeset/fix-oauth-quota-project-header.md b/.changeset/fix-oauth-quota-project-header.md new file mode 100644 index 00000000..604dd5ae --- /dev/null +++ b/.changeset/fix-oauth-quota-project-header.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": patch +--- + +Skip x-goog-user-project header for OAuth auth to fix 403 errors for non-project-member users diff --git a/crates/google-workspace-cli/src/auth.rs b/crates/google-workspace-cli/src/auth.rs index 9d8847e4..66f8bdf4 100644 --- a/crates/google-workspace-cli/src/auth.rs +++ b/crates/google-workspace-cli/src/auth.rs @@ -126,6 +126,17 @@ fn adc_well_known_path() -> Option { }) } +/// Tracks what authentication method was used for the request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthMethod { + /// OAuth2 bearer token from a user credential (`gws auth login`) + OAuth, + /// Bearer token from a service-account key + ServiceAccount, + /// No authentication was provided + None, +} + /// Types of credentials we support #[derive(Debug)] enum Credential { @@ -229,6 +240,30 @@ pub async fn get_token(scopes: &[&str]) -> anyhow::Result { get_token_inner(scopes, creds, &token_cache).await } +/// Like [`get_token`] but also returns the [`AuthMethod`] so callers can +/// decide whether to include the `x-goog-user-project` quota header. +pub async fn get_token_with_kind(scopes: &[&str]) -> anyhow::Result<(String, AuthMethod)> { + if let Ok(token) = std::env::var("GOOGLE_WORKSPACE_CLI_TOKEN") { + if !token.is_empty() { + return Ok((token, AuthMethod::OAuth)); + } + } + + let creds_file = std::env::var("GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE").ok(); + let config_dir = crate::auth_commands::config_dir(); + let enc_path = credential_store::encrypted_credentials_path(); + let default_path = config_dir.join("credentials.json"); + let token_cache = config_dir.join("token_cache.json"); + + let creds = load_credentials_inner(creds_file.as_deref(), &enc_path, &default_path).await?; + let kind = match &creds { + Credential::ServiceAccount(_) => AuthMethod::ServiceAccount, + Credential::AuthorizedUser(_) => AuthMethod::OAuth, + }; + let token = get_token_inner(scopes, creds, &token_cache).await?; + Ok((token, kind)) +} + /// Check if HTTP proxy environment variables are set pub(crate) fn has_proxy_env() -> bool { PROXY_ENV_VARS diff --git a/crates/google-workspace-cli/src/executor.rs b/crates/google-workspace-cli/src/executor.rs index 46f31ac4..39ff97c7 100644 --- a/crates/google-workspace-cli/src/executor.rs +++ b/crates/google-workspace-cli/src/executor.rs @@ -31,14 +31,7 @@ use crate::discovery::{RestDescription, RestMethod}; use crate::error::GwsError; use crate::output::sanitize_for_terminal; -/// Tracks what authentication method was used for the request. -#[derive(Debug, Clone, PartialEq)] -pub enum AuthMethod { - /// OAuth2 bearer token from credentials file - OAuth, - /// No authentication was provided - None, -} +pub use crate::auth::AuthMethod; /// Source for media upload content. /// @@ -182,13 +175,23 @@ async fn build_http_request( }; if let Some(token) = token { - if *auth_method == AuthMethod::OAuth { + if matches!(*auth_method, AuthMethod::OAuth | AuthMethod::ServiceAccount) { request = request.bearer_auth(token); } } - // Set quota project from ADC for billing/quota attribution - if let Some(quota_project) = crate::auth::get_quota_project() { + // For service-account auth, always forward the quota project (env var, config, or ADC). + // For OAuth, only send when GOOGLE_WORKSPACE_PROJECT_ID is explicitly set — the user + // has opted in, so we honour it even though OAuth users may not be IAM members of every + // project. Omit the header entirely when neither condition is met to avoid 403 errors. + let quota_project = match auth_method { + AuthMethod::ServiceAccount => crate::auth::get_quota_project(), + AuthMethod::OAuth => std::env::var("GOOGLE_WORKSPACE_PROJECT_ID") + .ok() + .filter(|s| !s.is_empty()), + AuthMethod::None => None, + }; + if let Some(quota_project) = quota_project { request = request.header("x-goog-user-project", quota_project); } @@ -2399,3 +2402,94 @@ async fn test_get_does_not_set_content_length_zero() { "GET with no body should not have Content-Length header" ); } + +/// Mutex to serialise tests that mutate GOOGLE_WORKSPACE_PROJECT_ID so they +/// don't race with each other when the test binary runs its threads in parallel. +#[cfg(test)] +static QUOTA_PROJECT_ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +#[tokio::test] +async fn test_oauth_auth_does_not_set_quota_project_header_by_default() { + let _guard = QUOTA_PROJECT_ENV_MUTEX.lock().unwrap(); + // Without GOOGLE_WORKSPACE_PROJECT_ID set, OAuth requests must omit x-goog-user-project + // because OAuth users are not necessarily IAM members of the project. + std::env::remove_var("GOOGLE_WORKSPACE_PROJECT_ID"); + let client = reqwest::Client::new(); + let method = RestMethod { + http_method: "GET".to_string(), + path: "files".to_string(), + ..Default::default() + }; + let input = ExecutionInput { + full_url: "https://example.com/files".to_string(), + body: None, + params: Map::new(), + query_params: Vec::new(), + is_upload: false, + }; + + let request = build_http_request( + &client, + &method, + &input, + Some("fake-token"), + &AuthMethod::OAuth, + None, + 0, + &None, + ) + .await + .unwrap(); + + let built = request.build().unwrap(); + assert!( + built.headers().get("x-goog-user-project").is_none(), + "OAuth requests must not include x-goog-user-project header when env var is not set" + ); +} + +#[tokio::test] +async fn test_oauth_auth_sends_quota_project_when_env_var_explicitly_set() { + let _guard = QUOTA_PROJECT_ENV_MUTEX.lock().unwrap(); + // When GOOGLE_WORKSPACE_PROJECT_ID is explicitly set, OAuth requests should + // honour it and send x-goog-user-project (the user opted in). + std::env::set_var("GOOGLE_WORKSPACE_PROJECT_ID", "my-explicit-project"); + let client = reqwest::Client::new(); + let method = RestMethod { + http_method: "GET".to_string(), + path: "files".to_string(), + ..Default::default() + }; + let input = ExecutionInput { + full_url: "https://example.com/files".to_string(), + body: None, + params: Map::new(), + query_params: Vec::new(), + is_upload: false, + }; + + let request = build_http_request( + &client, + &method, + &input, + Some("fake-token"), + &AuthMethod::OAuth, + None, + 0, + &None, + ) + .await + .unwrap(); + + std::env::remove_var("GOOGLE_WORKSPACE_PROJECT_ID"); + + let built = request.build().unwrap(); + assert_eq!( + built + .headers() + .get("x-goog-user-project") + .and_then(|v| v.to_str().ok()), + Some("my-explicit-project"), + "OAuth requests must include x-goog-user-project when GOOGLE_WORKSPACE_PROJECT_ID is set" + ); +} diff --git a/crates/google-workspace-cli/src/main.rs b/crates/google-workspace-cli/src/main.rs index 41dcc1e1..5e9fb10c 100644 --- a/crates/google-workspace-cli/src/main.rs +++ b/crates/google-workspace-cli/src/main.rs @@ -262,8 +262,8 @@ async fn run() -> Result<(), GwsError> { let scopes: Vec<&str> = select_scope(&method.scopes).into_iter().collect(); // Authenticate: try OAuth, fail with error if credentials exist but are broken - let (token, auth_method) = match auth::get_token(&scopes).await { - Ok(t) => (Some(t), executor::AuthMethod::OAuth), + let (token, auth_method) = match auth::get_token_with_kind(&scopes).await { + Ok((t, method)) => (Some(t), method), Err(e) => { // If credentials were found but failed (e.g. decryption error, invalid token), // propagate the error instead of silently falling back to unauthenticated.