fix: prevent gmail.metadata scope from blocking query parameters (#200)

* 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.

* 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.

* fix: apply select_scope to MCP server code path

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.

* style: fix cargo fmt formatting issues

---------

Co-authored-by: Justin Poehnelt <jpoehnelt@google.com>
This commit is contained in:
Chris Wood
2026-03-05 23:31:01 +00:00
committed by GitHub
parent 2173a929d3
commit ff53538b9f
5 changed files with 118 additions and 12 deletions
+5
View File
@@ -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
+71
View File
@@ -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(),
@@ -613,6 +619,39 @@ fn scope_matches_service(scope_url: &str, services: &HashSet<String>) -> 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<String>) -> Vec<String> {
// 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<String> = 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(
@@ -2095,6 +2134,38 @@ mod tests {
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);
}
#[test]
fn mask_secret_long_string() {
let masked = mask_secret("GOCSPX-abcdefghijklmnopqrstuvwxyz");
+41 -2
View File
@@ -232,8 +232,10 @@ 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, fail with error if credentials exist but are broken
let (token, auth_method) = match auth::get_token(&scopes, account.as_deref()).await {
@@ -273,6 +275,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.
pub(crate) 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"),
@@ -783,4 +796,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<String> = vec![];
assert_eq!(select_scope(&scopes), None);
}
}
+1 -1
View File
@@ -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) => {
-9
View File
@@ -125,19 +125,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