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:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@googleworkspace/cli": patch
|
||||
---
|
||||
|
||||
fix(auth): dynamically fetch scopes from Discovery docs when `-s` specifies services not in static scope lists
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@googleworkspace/cli": patch
|
||||
---
|
||||
|
||||
fix(auth): format extract_scopes_from_doc and deduplicate dynamic scopes
|
||||
+217
-11
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user