From c98b9bf6da84e96d6d04a8d38bd8d511c7dc87b7 Mon Sep 17 00:00:00 2001 From: priorwave <99269148+priorwave@users.noreply.github.com> Date: Thu, 5 Mar 2026 19:25:16 +0000 Subject: [PATCH 1/4] fix: select broadest scope instead of all method scopes Discovery Documents list method scopes as alternatives (any one grants access), but passing all of them to yup_oauth2 caused Google to include restrictive scopes like gmail.metadata in the token. The API then enforced that scope's restrictions, blocking query parameters like `q`. Select only the first (broadest) scope from the method's scope list. --- .changeset/fix-scope-selection.md | 5 ++++ src/main.rs | 45 +++++++++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-scope-selection.md diff --git a/.changeset/fix-scope-selection.md b/.changeset/fix-scope-selection.md new file mode 100644 index 0000000..68fa5d7 --- /dev/null +++ b/.changeset/fix-scope-selection.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": patch +--- + +Fix scope selection to use first (broadest) scope instead of all method scopes, preventing gmail.metadata restrictions from blocking query parameters diff --git a/src/main.rs b/src/main.rs index 4497731..f6e0e85 100644 --- a/src/main.rs +++ b/src/main.rs @@ -232,8 +232,12 @@ async fn run() -> Result<(), GwsError> { // Build pagination config from flags let pagination = parse_pagination_config(matched_args); - // Get scopes from the method - let scopes: Vec<&str> = method.scopes.iter().map(|s| s.as_str()).collect(); + // Select the best scope for the method. Discovery Documents list scopes as + // alternatives (any one grants access). We pick the first (broadest) scope + // to avoid restrictive scopes like gmail.metadata that block query parameters. + let scopes: Vec<&str> = select_scope(&method.scopes) + .into_iter() + .collect(); // Authenticate: try OAuth, otherwise proceed unauthenticated let (token, auth_method) = match auth::get_token(&scopes, account.as_deref()).await { @@ -262,6 +266,17 @@ async fn run() -> Result<(), GwsError> { .map(|_| ()) } +/// Select the best scope from a method's scope list. +/// +/// Discovery Documents list method scopes as alternatives — any single scope +/// grants access. The first scope is typically the broadest. Using all scopes +/// causes issues when restrictive scopes (e.g., `gmail.metadata`) are included, +/// as the API enforces that scope's restrictions even when broader scopes are +/// also present. +fn select_scope(scopes: &[String]) -> Option<&str> { + scopes.first().map(|s| s.as_str()) +} + fn parse_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { executor::PaginationConfig { page_all: matches.get_flag("page-all"), @@ -772,4 +787,30 @@ mod tests { assert!(!filtered.iter().any(|a| a.contains("account"))); assert_eq!(filtered, vec!["gws", "files", "list"]); } + + #[test] + fn test_select_scope_picks_first() { + let scopes = vec![ + "https://mail.google.com/".to_string(), + "https://www.googleapis.com/auth/gmail.metadata".to_string(), + "https://www.googleapis.com/auth/gmail.modify".to_string(), + "https://www.googleapis.com/auth/gmail.readonly".to_string(), + ]; + assert_eq!(select_scope(&scopes), Some("https://mail.google.com/")); + } + + #[test] + fn test_select_scope_single() { + let scopes = vec!["https://www.googleapis.com/auth/drive".to_string()]; + assert_eq!( + select_scope(&scopes), + Some("https://www.googleapis.com/auth/drive") + ); + } + + #[test] + fn test_select_scope_empty() { + let scopes: Vec = vec![]; + assert_eq!(select_scope(&scopes), None); + } } From 889dc7efd74f232135cb668942ebf89cf959beb8 Mon Sep 17 00:00:00 2001 From: priorwave <99269148+priorwave@users.noreply.github.com> Date: Thu, 5 Mar 2026 20:24:45 +0000 Subject: [PATCH 2/4] fix: filter gmail.metadata from login scopes and remove token cache superset fallback gmail.metadata restricts API behavior (blocks `q` parameter) even when broader scopes are present in the token. Filter it out during login when broader Gmail scopes like gmail.modify or mail.google.com are selected. Also remove the superset fallback in token storage to prevent stale all-scopes tokens from being reused when a narrower scope is requested. --- src/auth_commands.rs | 73 ++++++++++++++++++++++++++++++++++++++++++++ src/token_storage.rs | 9 ------ 2 files changed, 73 insertions(+), 9 deletions(-) diff --git a/src/auth_commands.rs b/src/auth_commands.rs index b5a5bb8..200aa87 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -273,6 +273,12 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { ) .await; + // Remove restrictive scopes when broader alternatives are present. + // gmail.metadata blocks query parameters like `q`, and is redundant + // when broader scopes (gmail.modify, gmail.readonly, mail.google.com) + // are already included. + let scopes = filter_redundant_restrictive_scopes(scopes); + let secret = yup_oauth2::ApplicationSecret { client_id: client_id.clone(), client_secret: client_secret.clone(), @@ -604,6 +610,41 @@ fn scope_matches_service(scope_url: &str, services: &HashSet) -> bool { }) } +/// Remove restrictive scopes that are redundant when broader alternatives +/// are present. For example, `gmail.metadata` restricts query parameters +/// and is unnecessary when `gmail.modify`, `gmail.readonly`, or the full +/// `https://mail.google.com/` scope is already included. +/// +/// This prevents Google from enforcing the restrictive scope's limitations +/// on the access token even though broader access was granted. +fn filter_redundant_restrictive_scopes(scopes: Vec) -> Vec { + // Scopes that restrict API behavior when present in a token, even alongside + // broader scopes. Each entry maps a restrictive scope to the broader scopes + // that make it redundant. The restrictive scope is removed only if at least + // one of its broader alternatives is already in the list. + const RESTRICTIVE_SCOPES: &[(&str, &[&str])] = &[( + "https://www.googleapis.com/auth/gmail.metadata", + &[ + "https://mail.google.com/", + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/gmail.readonly", + ], + )]; + + let scope_set: std::collections::HashSet = + scopes.iter().cloned().collect(); + + scopes + .into_iter() + .filter(|scope| { + !RESTRICTIVE_SCOPES.iter().any(|(restrictive, broader)| { + scope.as_str() == *restrictive + && broader.iter().any(|b| scope_set.contains(*b)) + }) + }) + .collect() +} + /// Filter a list of scope URLs to only those matching the given services. /// If no filter is provided, returns all scopes unchanged. fn filter_scopes_by_services( @@ -2085,4 +2126,36 @@ mod tests { let result = filter_scopes_by_services(scopes.clone(), Some(&empty)); assert_eq!(result, scopes); } + + #[test] + fn filter_restrictive_removes_metadata_when_broader_present() { + let scopes = vec![ + "https://www.googleapis.com/auth/gmail.modify".to_string(), + "https://www.googleapis.com/auth/gmail.metadata".to_string(), + "https://www.googleapis.com/auth/drive".to_string(), + ]; + let result = filter_redundant_restrictive_scopes(scopes); + assert!(!result.iter().any(|s| s.contains("gmail.metadata"))); + assert_eq!(result.len(), 2); + } + + #[test] + fn filter_restrictive_removes_metadata_when_full_gmail_present() { + let scopes = vec![ + "https://mail.google.com/".to_string(), + "https://www.googleapis.com/auth/gmail.metadata".to_string(), + ]; + let result = filter_redundant_restrictive_scopes(scopes); + assert_eq!(result, vec!["https://mail.google.com/"]); + } + + #[test] + fn filter_restrictive_keeps_metadata_when_only_scope() { + let scopes = vec![ + "https://www.googleapis.com/auth/gmail.metadata".to_string(), + "https://www.googleapis.com/auth/drive".to_string(), + ]; + let result = filter_redundant_restrictive_scopes(scopes.clone()); + assert_eq!(result, scopes); + } } diff --git a/src/token_storage.rs b/src/token_storage.rs index 4b24ec4..938458e 100644 --- a/src/token_storage.rs +++ b/src/token_storage.rs @@ -103,19 +103,10 @@ impl TokenStorage for EncryptedTokenStorage { } if let Some(map) = map_lock.as_ref() { - // First look for exact match let key = Self::cache_key(scopes); if let Some(token) = map.get(&key) { return Some(token.clone()); } - - // Fallback: check if we have a superset of the scopes (simplistic check compared to yup-oauth2 internal, but functional for this CLI) - for (cached_key, token) in map.iter() { - let cached_scopes: Vec<&str> = cached_key.split(' ').collect(); - if scopes.iter().all(|s| cached_scopes.contains(s)) { - return Some(token.clone()); - } - } } None From 6245a3d832be0d4d4460e6ef609070e62ecd0251 Mon Sep 17 00:00:00 2001 From: priorwave <99269148+priorwave@users.noreply.github.com> Date: Thu, 5 Mar 2026 22:18:00 +0000 Subject: [PATCH 3/4] fix: apply select_scope to MCP server code path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP server had the same bug as the CLI — passing all method scopes to get_token. Use select_scope to pick only the broadest scope. --- src/main.rs | 2 +- src/mcp_server.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index f6e0e85..8d73fad 100644 --- a/src/main.rs +++ b/src/main.rs @@ -273,7 +273,7 @@ async fn run() -> Result<(), GwsError> { /// causes issues when restrictive scopes (e.g., `gmail.metadata`) are included, /// as the API enforces that scope's restrictions even when broader scopes are /// also present. -fn select_scope(scopes: &[String]) -> Option<&str> { +pub(crate) fn select_scope(scopes: &[String]) -> Option<&str> { scopes.first().map(|s| s.as_str()) } diff --git a/src/mcp_server.rs b/src/mcp_server.rs index 331e70d..485d9b4 100644 --- a/src/mcp_server.rs +++ b/src/mcp_server.rs @@ -786,7 +786,7 @@ async fn execute_mcp_method( page_delay_ms: 100, }; - let scopes: Vec<&str> = method.scopes.iter().map(|s| s.as_str()).collect(); + let scopes: Vec<&str> = crate::select_scope(&method.scopes).into_iter().collect(); let (token, auth_method) = match crate::auth::get_token(&scopes, None).await { Ok(t) => (Some(t), crate::executor::AuthMethod::OAuth), Err(e) => { From 82d3329977a8ab0228e2f8a5a4229a1c5d8f663d Mon Sep 17 00:00:00 2001 From: priorwave <99269148+priorwave@users.noreply.github.com> Date: Thu, 5 Mar 2026 22:58:14 +0000 Subject: [PATCH 4/4] style: fix cargo fmt formatting issues --- src/auth_commands.rs | 6 ++---- src/main.rs | 4 +--- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/auth_commands.rs b/src/auth_commands.rs index 200aa87..f7231c4 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -631,15 +631,13 @@ fn filter_redundant_restrictive_scopes(scopes: Vec) -> Vec { ], )]; - let scope_set: std::collections::HashSet = - scopes.iter().cloned().collect(); + let scope_set: std::collections::HashSet = scopes.iter().cloned().collect(); scopes .into_iter() .filter(|scope| { !RESTRICTIVE_SCOPES.iter().any(|(restrictive, broader)| { - scope.as_str() == *restrictive - && broader.iter().any(|b| scope_set.contains(*b)) + scope.as_str() == *restrictive && broader.iter().any(|b| scope_set.contains(*b)) }) }) .collect() diff --git a/src/main.rs b/src/main.rs index 8d73fad..995d0a3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -235,9 +235,7 @@ async fn run() -> Result<(), GwsError> { // Select the best scope for the method. Discovery Documents list scopes as // alternatives (any one grants access). We pick the first (broadest) scope // to avoid restrictive scopes like gmail.metadata that block query parameters. - let scopes: Vec<&str> = select_scope(&method.scopes) - .into_iter() - .collect(); + let scopes: Vec<&str> = select_scope(&method.scopes).into_iter().collect(); // Authenticate: try OAuth, otherwise proceed unauthenticated let (token, auth_method) = match auth::get_token(&scopes, account.as_deref()).await {