fix(auth): format and deduplicate dynamic scope fallback (#352)

* 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

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

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

* fix(auth): format extract_scopes_from_doc and deduplicate dynamic scopes

- Break long method chain in extract_scopes_from_doc to pass cargo fmt
- Deduplicate dynamic scopes in augment_with_dynamic_scopes to prevent
  duplicate entries when dynamic results overlap with static ones

---------

Co-authored-by: Frank <qwer4488999@gmail.com>
Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
This commit is contained in:
Justin Poehnelt
2026-03-09 17:36:49 -06:00
committed by GitHub
parent 2782cf101e
commit 06aa698e23
3 changed files with 227 additions and 11 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@googleworkspace/cli": patch
---
fix(auth): dynamically fetch scopes from Discovery docs when `-s` specifies services not in static scope lists
+5
View File
@@ -0,0 +1,5 @@
---
"@googleworkspace/cli": patch
---
fix(auth): format extract_scopes_from_doc and deduplicate dynamic scopes
+217 -11
View File
@@ -484,13 +484,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<String> = 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<String> = 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
@@ -516,7 +522,9 @@ async fn resolve_scopes(
}
let defaults: Vec<String> = 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.
@@ -541,17 +549,21 @@ fn scope_matches_service(scope_url: &str, services: &HashSet<String>) -> 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
@@ -1313,6 +1325,109 @@ 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<String>) -> HashSet<String> {
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 = map_service_to_scope_prefix(service);
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.
///
/// 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<String> {
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<String>,
readonly_only: bool,
) -> Vec<String> {
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<String> = futures_util::future::join_all(futures)
.await
.into_iter()
.flatten()
.flatten()
.collect();
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<String>,
services_filter: Option<&HashSet<String>>,
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;
for scope in dynamic {
if !result.contains(&scope) {
result.push(scope);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1955,4 +2070,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<String> = ["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<String> = ["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());
}
}