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..feac04d6 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,14 +175,17 @@ 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() { - request = request.header("x-goog-user-project", quota_project); + // Only send quota project for service-account auth; OAuth users may not be + // IAM members of the project, so the header would trigger 403 errors. + if *auth_method == AuthMethod::ServiceAccount { + if let Some(quota_project) = crate::auth::get_quota_project() { + request = request.header("x-goog-user-project", quota_project); + } } let mut all_query_params = input.query_params.clone(); @@ -2399,3 +2395,42 @@ async fn test_get_does_not_set_content_length_zero() { "GET with no body should not have Content-Length header" ); } + +#[tokio::test] +async fn test_oauth_auth_does_not_set_quota_project_header() { + // Arrange: even if get_quota_project() would return a value, OAuth requests + // must NOT send x-goog-user-project because OAuth users are not necessarily + // IAM members of the project and the header would trigger 403 errors. + 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" + ); +} 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.