From e69d01753d68e365f9f7e48aa1ca05f027ae6043 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 6 Mar 2026 10:36:07 +0800 Subject: [PATCH 1/3] fix(auth): fall back to Discovery docs when `-s` specifies services not in static scope lists When `gws auth login -s chat` (or any service not in the 7 static scope lists) is used, the static filter returns no matching scopes. Add a dynamic fallback that detects unmatched services and fetches their OAuth scopes from the Google Discovery API. This leverages the existing `fetch_discovery_document` with 24h caching. Fixes #236 --- .changeset/fix-236-scope-fallback.md | 5 + src/auth_commands.rs | 183 ++++++++++++++++++++++++++- 2 files changed, 184 insertions(+), 4 deletions(-) create mode 100644 .changeset/fix-236-scope-fallback.md diff --git a/.changeset/fix-236-scope-fallback.md b/.changeset/fix-236-scope-fallback.md new file mode 100644 index 0000000..9942489 --- /dev/null +++ b/.changeset/fix-236-scope-fallback.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": patch +--- + +fix(auth): dynamically fetch scopes from Discovery docs when `-s` specifies services not in static scope lists diff --git a/src/auth_commands.rs b/src/auth_commands.rs index c9b12e1..ae94ff9 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -550,13 +550,19 @@ async fn resolve_scopes( .collect(); } } - if args.iter().any(|a| a == "--readonly") { + let readonly_only = args.iter().any(|a| a == "--readonly"); + + if readonly_only { let scopes: Vec = READONLY_SCOPES.iter().map(|s| s.to_string()).collect(); - return filter_scopes_by_services(scopes, services_filter); + let mut result = filter_scopes_by_services(scopes, services_filter); + augment_with_dynamic_scopes(&mut result, services_filter, true).await; + return result; } if args.iter().any(|a| a == "--full") { let scopes: Vec = FULL_SCOPES.iter().map(|s| s.to_string()).collect(); - return filter_scopes_by_services(scopes, services_filter); + let mut result = filter_scopes_by_services(scopes, services_filter); + augment_with_dynamic_scopes(&mut result, services_filter, false).await; + return result; } // Interactive scope picker when running in a TTY @@ -582,7 +588,9 @@ async fn resolve_scopes( } let defaults: Vec = DEFAULT_SCOPES.iter().map(|s| s.to_string()).collect(); - filter_scopes_by_services(defaults, services_filter) + let mut result = filter_scopes_by_services(defaults, services_filter); + augment_with_dynamic_scopes(&mut result, services_filter, false).await; + result } /// Check if a scope URL belongs to one of the specified services. @@ -1542,6 +1550,82 @@ fn is_workspace_admin_scope(url: &str) -> bool { || short == "groups" } +/// Identify services from the filter that have no matching scopes in the result. +/// +/// `cloud-platform` is a cross-service scope and does not count as a match +/// for any specific service. +fn find_unmatched_services(scopes: &[String], services: &HashSet) -> HashSet { + services + .iter() + .filter(|svc| { + let single: HashSet = [svc.to_string()].into_iter().collect(); + !scopes.iter().any(|s| { + !s.ends_with("/cloud-platform") && scope_matches_service(s, &single) + }) + }) + .cloned() + .collect() +} + +/// Extract OAuth scope URLs from a Discovery document. +/// +/// Filters out app-only scopes (e.g. `chat.bot`, `chat.app.*`) and optionally +/// restricts to `.readonly` scopes when `readonly_only` is true. +fn extract_scopes_from_doc( + doc: &crate::discovery::RestDescription, + readonly_only: bool, +) -> Vec { + let scopes = match doc.auth.as_ref().and_then(|a| a.oauth2.as_ref()).and_then(|o| o.scopes.as_ref()) { + Some(s) => s, + None => return Vec::new(), + }; + scopes + .keys() + .filter(|url| !is_app_only_scope(url)) + .filter(|url| !readonly_only || url.ends_with(".readonly")) + .cloned() + .collect() +} + +/// Fetch scopes from Discovery docs for services that had no matching scopes +/// in the static lists. Failures are silently skipped (graceful degradation). +async fn fetch_scopes_for_unmatched_services( + services: &HashSet, + readonly_only: bool, +) -> Vec { + let mut result = Vec::new(); + for svc in services { + let (api_name, version) = match crate::services::resolve_service(svc) { + Ok(pair) => pair, + Err(_) => continue, + }; + let doc = match crate::discovery::fetch_discovery_document(&api_name, &version).await { + Ok(doc) => doc, + Err(_) => continue, + }; + result.extend(extract_scopes_from_doc(&doc, readonly_only)); + } + result.sort(); + result.dedup(); + result +} + +/// If a services filter is active and some services have no matching scopes in +/// the static result, dynamically fetch their scopes from Discovery docs. +async fn augment_with_dynamic_scopes( + result: &mut Vec, + services_filter: Option<&HashSet>, + readonly_only: bool, +) { + if let Some(services) = services_filter { + let missing = find_unmatched_services(result, services); + if !missing.is_empty() { + let dynamic = fetch_scopes_for_unmatched_services(&missing, readonly_only).await; + result.extend(dynamic); + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -2184,4 +2268,95 @@ mod tests { // Exactly 9 chars — first 4 + last 4 with "..." in between assert_eq!(mask_secret("123456789"), "1234...6789"); } + + #[test] + fn find_unmatched_services_identifies_missing() { + let scopes = vec![ + "https://www.googleapis.com/auth/drive".to_string(), + "https://www.googleapis.com/auth/cloud-platform".to_string(), + ]; + let services: HashSet = ["drive", "chat"].iter().map(|s| s.to_string()).collect(); + let missing = find_unmatched_services(&scopes, &services); + assert!(!missing.contains("drive")); + assert!(missing.contains("chat")); + } + + #[test] + fn find_unmatched_services_all_matched() { + let scopes = vec![ + "https://www.googleapis.com/auth/drive".to_string(), + "https://www.googleapis.com/auth/gmail.modify".to_string(), + ]; + let services: HashSet = ["drive", "gmail"].iter().map(|s| s.to_string()).collect(); + let missing = find_unmatched_services(&scopes, &services); + assert!(missing.is_empty()); + } + + fn make_test_discovery_doc(scope_urls: &[&str]) -> crate::discovery::RestDescription { + let mut scopes = std::collections::HashMap::new(); + for url in scope_urls { + scopes.insert( + url.to_string(), + crate::discovery::ScopeDescription { + description: Some("test".to_string()), + }, + ); + } + crate::discovery::RestDescription { + auth: Some(crate::discovery::AuthDescription { + oauth2: Some(crate::discovery::OAuth2Description { + scopes: Some(scopes), + }), + }), + ..Default::default() + } + } + + #[test] + fn extract_scopes_from_doc_filters_app_only() { + let doc = make_test_discovery_doc(&[ + "https://www.googleapis.com/auth/chat.messages", + "https://www.googleapis.com/auth/chat.bot", + "https://www.googleapis.com/auth/chat.app.spaces", + "https://www.googleapis.com/auth/chat.spaces", + ]); + let mut result = extract_scopes_from_doc(&doc, false); + result.sort(); + assert_eq!( + result, + vec![ + "https://www.googleapis.com/auth/chat.messages", + "https://www.googleapis.com/auth/chat.spaces", + ] + ); + } + + #[test] + fn extract_scopes_from_doc_readonly_filter() { + let doc = make_test_discovery_doc(&[ + "https://www.googleapis.com/auth/chat.messages", + "https://www.googleapis.com/auth/chat.messages.readonly", + "https://www.googleapis.com/auth/chat.spaces", + "https://www.googleapis.com/auth/chat.spaces.readonly", + ]); + let mut result = extract_scopes_from_doc(&doc, true); + result.sort(); + assert_eq!( + result, + vec![ + "https://www.googleapis.com/auth/chat.messages.readonly", + "https://www.googleapis.com/auth/chat.spaces.readonly", + ] + ); + } + + #[test] + fn extract_scopes_from_doc_empty_auth() { + let doc = crate::discovery::RestDescription { + auth: None, + ..Default::default() + }; + let result = extract_scopes_from_doc(&doc, false); + assert!(result.is_empty()); + } } From d01ee21cd3801ed7a16ecb7ae53d11c2f55ffe01 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 6 Mar 2026 10:50:20 +0800 Subject: [PATCH 2/3] refactor: optimize find_unmatched_services and parallelize Discovery fetches Address review feedback: - Avoid per-service HashSet allocation in find_unmatched_services by collecting matched services first then computing the difference. - Use futures_util::future::join_all to fetch Discovery docs in parallel instead of sequentially. --- src/auth_commands.rs | 67 +++++++++++++++++++++++++++++--------------- 1 file changed, 45 insertions(+), 22 deletions(-) diff --git a/src/auth_commands.rs b/src/auth_commands.rs index ae94ff9..4047f53 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -1555,16 +1555,32 @@ fn is_workspace_admin_scope(url: &str) -> bool { /// `cloud-platform` is a cross-service scope and does not count as a match /// for any specific service. fn find_unmatched_services(scopes: &[String], services: &HashSet) -> HashSet { - services - .iter() - .filter(|svc| { - let single: HashSet = [svc.to_string()].into_iter().collect(); - !scopes.iter().any(|s| { - !s.ends_with("/cloud-platform") && scope_matches_service(s, &single) - }) - }) - .cloned() - .collect() + let mut matched_services = HashSet::new(); + + for scope in scopes.iter().filter(|s| !s.ends_with("/cloud-platform")) { + let short = match scope.strip_prefix("https://www.googleapis.com/auth/") { + Some(s) => s, + None => continue, + }; + let prefix = short.split('.').next().unwrap_or(short); + + for service in services { + if matched_services.contains(service) { + continue; + } + let mapped_svc = match service.as_str() { + "sheets" => "spreadsheets", + "slides" => "presentations", + "docs" => "documents", + s => s, + }; + if prefix == mapped_svc || short.starts_with(&format!("{mapped_svc}.")) { + matched_services.insert(service.clone()); + } + } + } + + services.difference(&matched_services).cloned().collect() } /// Extract OAuth scope URLs from a Discovery document. @@ -1593,18 +1609,25 @@ async fn fetch_scopes_for_unmatched_services( services: &HashSet, readonly_only: bool, ) -> Vec { - let mut result = Vec::new(); - for svc in services { - let (api_name, version) = match crate::services::resolve_service(svc) { - Ok(pair) => pair, - Err(_) => continue, - }; - let doc = match crate::discovery::fetch_discovery_document(&api_name, &version).await { - Ok(doc) => doc, - Err(_) => continue, - }; - result.extend(extract_scopes_from_doc(&doc, readonly_only)); - } + let futures: Vec<_> = services + .iter() + .filter_map(|svc| { + let (api_name, version) = crate::services::resolve_service(svc).ok()?; + Some(async move { + crate::discovery::fetch_discovery_document(&api_name, &version) + .await + .ok() + .map(|doc| extract_scopes_from_doc(&doc, readonly_only)) + }) + }) + .collect(); + + let mut result: Vec = futures_util::future::join_all(futures) + .await + .into_iter() + .flatten() + .flatten() + .collect(); result.sort(); result.dedup(); result From 7c507f9e0daabb0b4968b30f77b80711b8697dae Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 6 Mar 2026 11:00:16 +0800 Subject: [PATCH 3/3] refactor: extract map_service_to_scope_prefix to deduplicate alias mapping Share the service-name-to-scope-prefix mapping between scope_matches_service and find_unmatched_services via a single helper. --- src/auth_commands.rs | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/auth_commands.rs b/src/auth_commands.rs index 4047f53..00f93b1 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -615,17 +615,21 @@ fn scope_matches_service(scope_url: &str, services: &HashSet) -> bool { let prefix = short.split('.').next().unwrap_or(short); services.iter().any(|svc| { - // Map common user-friendly service names to their OAuth scope prefixes - let mapped_svc = match svc.as_str() { - "sheets" => "spreadsheets", - "slides" => "presentations", - "docs" => "documents", - s => s, - }; + let mapped_svc = map_service_to_scope_prefix(svc); prefix == mapped_svc || short.starts_with(&format!("{mapped_svc}.")) }) } +/// Map user-friendly service names to their OAuth scope prefixes. +fn map_service_to_scope_prefix(service: &str) -> &str { + match service { + "sheets" => "spreadsheets", + "slides" => "presentations", + "docs" => "documents", + s => s, + } +} + /// 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 @@ -1568,12 +1572,7 @@ fn find_unmatched_services(scopes: &[String], services: &HashSet) -> Has if matched_services.contains(service) { continue; } - let mapped_svc = match service.as_str() { - "sheets" => "spreadsheets", - "slides" => "presentations", - "docs" => "documents", - s => s, - }; + let mapped_svc = map_service_to_scope_prefix(service); if prefix == mapped_svc || short.starts_with(&format!("{mapped_svc}.")) { matched_services.insert(service.clone()); }