fix: allow services flag for login and improve default list for consumer accounts (#177)

* feat: add -s/--services flag to auth login for scope picker filtering

* fix: restrict recommended scope template to minimal consumer scopes

* refactor: address PR review comments — dedup parsing, extract helpers, use is_none_or
This commit is contained in:
Justin Poehnelt
2026-03-05 09:14:54 -07:00
committed by GitHub
parent fa04502d14
commit 0a16d0bca3
3 changed files with 383 additions and 55 deletions
+7
View File
@@ -0,0 +1,7 @@
---
"@googleworkspace/cli": patch
---
Add `-s`/`--services` flag to `gws auth login` to filter the scope picker
by service name (e.g. `-s drive,gmail,sheets`). Also expands the workspace
admin scope blocklist to include `chat.admin.*` and `classroom.*` patterns.
+3 -2
View File
@@ -127,9 +127,10 @@ gws auth login # subsequent scope selection and login
> [!WARNING]
> **Scope limits in testing mode:** If your OAuth app is unverified (testing mode),
> Google limits consent to ~25 scopes. The `recommended` scope preset includes 85+
> scopes and **will fail** for unverified apps. Choose individual service scopes instead:
> scopes and **will fail** for unverified apps (especially for `@gmail.com` accounts).
> Choose individual services instead to filter the scope picker:
> ```bash
> gws auth login --scopes drive,gmail,calendar
> gws auth login -s drive,gmail,sheets
> ```
### Multiple accounts
+373 -53
View File
@@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use std::collections::HashSet;
use std::path::PathBuf;
use serde_json::json;
@@ -138,6 +139,8 @@ pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> {
" --full Request all scopes incl. pubsub + cloud-platform\n",
" (may trigger restricted_client for unverified apps)\n",
" --scopes Comma-separated custom scopes\n",
" -s, --services Comma-separated service names to limit scope picker\n",
" (e.g. -s drive,gmail,sheets)\n",
" setup Configure GCP project + OAuth client (requires gcloud)\n",
" --project Use a specific GCP project\n",
" status Show current authentication state\n",
@@ -208,8 +211,9 @@ impl yup_oauth2::authenticator_delegate::InstalledFlowDelegate for CliFlowDelega
}
async fn handle_login(args: &[String]) -> Result<(), GwsError> {
// Extract --account from args if provided
// Extract --account and -s/--services from args
let mut account_email: Option<String> = None;
let mut services_filter: Option<HashSet<String>> = None;
let mut filtered_args: Vec<String> = Vec::new();
let mut skip_next = false;
for i in 0..args.len() {
@@ -226,6 +230,23 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> {
account_email = Some(value.to_string());
continue;
}
let services_str = if (args[i] == "-s" || args[i] == "--services") && i + 1 < args.len() {
skip_next = true;
Some(args[i + 1].as_str())
} else {
args[i].strip_prefix("--services=")
};
if let Some(value) = services_str {
services_filter = Some(
value
.split(',')
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
.collect(),
);
continue;
}
filtered_args.push(args[i].clone());
}
@@ -245,7 +266,12 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> {
}
// Determine scopes: explicit flags > interactive TUI > defaults
let scopes = resolve_scopes(&filtered_args, project_id.as_deref()).await;
let scopes = resolve_scopes(
&filtered_args,
project_id.as_deref(),
services_filter.as_ref(),
)
.await;
let secret = yup_oauth2::ApplicationSecret {
client_id: client_id.clone(),
@@ -493,8 +519,15 @@ fn resolve_client_credentials() -> Result<(String, String, Option<String>), GwsE
}
/// Resolve OAuth scopes: explicit flags > interactive picker > defaults.
async fn resolve_scopes(args: &[String], project_id: Option<&str>) -> Vec<String> {
// Explicit --scopes flag takes priority
///
/// When `services_filter` is `Some`, only scopes belonging to the specified
/// services are shown in the picker (or returned in non-interactive mode).
async fn resolve_scopes(
args: &[String],
project_id: Option<&str>,
services_filter: Option<&HashSet<String>>,
) -> Vec<String> {
// Explicit --scopes flag takes priority (bypasses services filter)
for i in 0..args.len() {
if args[i] == "--scopes" && i + 1 < args.len() {
return args[i + 1]
@@ -504,10 +537,12 @@ async fn resolve_scopes(args: &[String], project_id: Option<&str>) -> Vec<String
}
}
if args.iter().any(|a| a == "--readonly") {
return READONLY_SCOPES.iter().map(|s| s.to_string()).collect();
let scopes: Vec<String> = READONLY_SCOPES.iter().map(|s| s.to_string()).collect();
return filter_scopes_by_services(scopes, services_filter);
}
if args.iter().any(|a| a == "--full") {
return FULL_SCOPES.iter().map(|s| s.to_string()).collect();
let scopes: Vec<String> = FULL_SCOPES.iter().map(|s| s.to_string()).collect();
return filter_scopes_by_services(scopes, services_filter);
}
// Interactive scope picker when running in a TTY
@@ -519,7 +554,7 @@ async fn resolve_scopes(args: &[String], project_id: Option<&str>) -> Vec<String
let api_ids: Vec<String> = enabled_apis;
let scopes = crate::setup::fetch_scopes_for_apis(&api_ids).await;
if !scopes.is_empty() {
if let Some(selected) = run_discovery_scope_picker(&scopes) {
if let Some(selected) = run_discovery_scope_picker(&scopes, services_filter) {
return selected;
}
}
@@ -527,17 +562,99 @@ async fn resolve_scopes(args: &[String], project_id: Option<&str>) -> Vec<String
}
// Fallback: simple scope picker using static SCOPE_ENTRIES
if let Some(selected) = run_simple_scope_picker() {
if let Some(selected) = run_simple_scope_picker(services_filter) {
return selected;
}
}
DEFAULT_SCOPES.iter().map(|s| s.to_string()).collect()
let defaults: Vec<String> = DEFAULT_SCOPES.iter().map(|s| s.to_string()).collect();
filter_scopes_by_services(defaults, services_filter)
}
/// Check if a scope URL belongs to one of the specified services.
///
/// Matching is done on the scope's short name (the part after
/// `https://www.googleapis.com/auth/`). A scope matches a service if its
/// short name equals the service or starts with `service.` (e.g. service
/// `drive` matches `drive`, `drive.readonly`, `drive.metadata.readonly`).
///
/// The `cloud-platform` scope always passes through since it's a
/// cross-service platform scope.
fn scope_matches_service(scope_url: &str, services: &HashSet<String>) -> bool {
let short = scope_url
.strip_prefix("https://www.googleapis.com/auth/")
.unwrap_or(scope_url);
// cloud-platform is a cross-service scope, always include
if short == "cloud-platform" {
return true;
}
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,
};
prefix == mapped_svc || short.starts_with(&format!("{mapped_svc}."))
})
}
/// 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(
scopes: Vec<String>,
services_filter: Option<&HashSet<String>>,
) -> Vec<String> {
match services_filter {
Some(services) if !services.is_empty() => scopes
.into_iter()
.filter(|s| scope_matches_service(s, services))
.collect(),
_ => scopes,
}
}
/// Check if a scope is subsumed by a broader scope in the list.
/// e.g. "drive.metadata" is subsumed by "drive", "calendar.events" by "calendar".
fn is_subsumed_scope(short: &str, all_shorts: &[&str]) -> bool {
all_shorts.iter().any(|&other| {
other != short
&& short.starts_with(other)
&& short.as_bytes().get(other.len()) == Some(&b'.')
})
}
/// Determine if a discovered scope should be included in the "Recommended" template.
///
/// When a services filter is active, recommends all top-level (non-subsumed) scopes.
/// Otherwise, recommends only the curated `MINIMAL_SCOPES` list to stay under
/// the 25-scope limit for unverified apps and @gmail.com accounts.
///
/// Always excludes admin-only and Workspace-admin scopes.
fn is_recommended_scope(
entry: &crate::setup::DiscoveredScope,
all_shorts: &[&str],
has_services_filter: bool,
) -> bool {
if entry.short.starts_with("admin.") || is_workspace_admin_scope(&entry.url) {
return false;
}
if has_services_filter {
!is_subsumed_scope(&entry.short, all_shorts)
} else {
MINIMAL_SCOPES.contains(&entry.url.as_str())
}
}
/// Run the rich discovery-based scope picker with templates.
fn run_discovery_scope_picker(
relevant_scopes: &[crate::setup::DiscoveredScope],
services_filter: Option<&HashSet<String>>,
) -> Option<Vec<String>> {
use crate::setup::{ScopeClassification, PLATFORM_SCOPE};
use crate::setup_tui::{PickerResult, SelectItem};
@@ -546,33 +663,30 @@ fn run_discovery_scope_picker(
let mut readonly_scopes = vec![];
let mut all_scopes = vec![];
// Pre-filter scopes by services if a filter is specified
let filtered_scopes: Vec<&crate::setup::DiscoveredScope> = relevant_scopes
.iter()
.filter(|e| {
services_filter.is_none_or(|services| {
services.is_empty() || scope_matches_service(&e.url, services)
})
})
.collect();
// Collect all short names for hierarchical dedup of Full Access template
let all_shorts: Vec<&str> = relevant_scopes
let all_shorts: Vec<&str> = filtered_scopes
.iter()
.filter(|e| !is_app_only_scope(&e.url))
.map(|e| e.short.as_str())
.collect();
for entry in relevant_scopes {
for entry in &filtered_scopes {
// Skip app-only scopes that can't be used with user OAuth
if is_app_only_scope(&entry.url) {
continue;
}
let is_recommended = if entry.is_readonly {
let superset = entry.url.strip_suffix(".readonly").unwrap();
let superset_is_recommended = relevant_scopes
.iter()
.any(|s| s.url == superset && s.classification != ScopeClassification::Restricted);
!superset_is_recommended
} else {
entry.classification != ScopeClassification::Restricted
};
if is_recommended
&& !entry.short.starts_with("admin.")
&& !is_workspace_admin_scope(&entry.url)
{
if is_recommended_scope(entry, &all_shorts, services_filter.is_some()) {
recommended_scopes.push(entry.short.to_string());
}
if entry.is_readonly {
@@ -580,20 +694,16 @@ fn run_discovery_scope_picker(
}
// For "Full Access": skip if a broader scope exists (hierarchical dedup)
// e.g. "drive.metadata" is subsumed by "drive", "calendar.events" by "calendar"
let is_subsumed = all_shorts.iter().any(|&other| {
other != entry.short
&& entry.short.starts_with(other)
&& entry.short.as_bytes().get(other.len()) == Some(&b'.')
});
if !is_subsumed {
if !is_subsumed_scope(&entry.short, &all_shorts) {
all_scopes.push(entry.short.to_string());
}
}
let mut items: Vec<SelectItem> = vec![
SelectItem {
label: "✨ Recommended (All Non-Restricted + Readonly)".to_string(),
description: "Selects non-restricted write scopes and all readonly scopes".to_string(),
label: "✨ Recommended (Core Consumer Scopes)".to_string(),
description: "Selects Drive, Gmail, Calendar, Docs, Sheets, Slides, and Tasks"
.to_string(),
selected: true,
is_fixed: false,
is_template: true,
@@ -619,7 +729,7 @@ fn run_discovery_scope_picker(
let template_count = items.len();
let mut valid_scope_indices: Vec<usize> = Vec::new();
for (idx, entry) in relevant_scopes.iter().enumerate() {
for (idx, entry) in filtered_scopes.iter().enumerate() {
// Skip app-only scopes from the picker entirely
if is_app_only_scope(&entry.url) {
continue;
@@ -645,7 +755,7 @@ fn run_discovery_scope_picker(
let is_recommended = if entry.is_readonly {
let superset = entry.url.strip_suffix(".readonly").unwrap();
let superset_is_recommended = relevant_scopes
let superset_is_recommended = filtered_scopes
.iter()
.any(|s| s.url == superset && s.classification != ScopeClassification::Restricted);
!superset_is_recommended
@@ -680,32 +790,24 @@ fn run_discovery_scope_picker(
if full && !recommended && !readonly {
// Full Access: include all non-app-only scopes
// (hierarchical dedup is applied in post-processing below)
for entry in relevant_scopes {
for entry in &filtered_scopes {
if is_app_only_scope(&entry.url) {
continue;
}
selected.push(entry.url.to_string());
}
} else if recommended && !full && !readonly {
// Recommended: non-restricted + readonly, but exclude admin.* and
// Workspace-admin-only scopes (require domain admin; fail for @gmail.com).
for entry in relevant_scopes {
// Recommended: consumer scopes only (or top-level scopes if filtered).
for entry in &filtered_scopes {
if is_app_only_scope(&entry.url) {
continue;
}
if entry.short.starts_with("admin.") {
continue;
}
if is_workspace_admin_scope(&entry.url) {
continue;
}
if entry.is_readonly || entry.classification != ScopeClassification::Restricted
{
if is_recommended_scope(entry, &all_shorts, services_filter.is_some()) {
selected.push(entry.url.to_string());
}
}
} else if readonly && !full && !recommended {
for entry in relevant_scopes {
for entry in &filtered_scopes {
if is_app_only_scope(&entry.url) {
continue;
}
@@ -718,7 +820,7 @@ fn run_discovery_scope_picker(
if item.selected {
let picker_idx = i - template_count;
if let Some(&scope_idx) = valid_scope_indices.get(picker_idx) {
if let Some(entry) = relevant_scopes.get(scope_idx) {
if let Some(entry) = filtered_scopes.get(scope_idx) {
selected.push(entry.url.to_string());
}
}
@@ -775,10 +877,20 @@ fn run_discovery_scope_picker(
}
/// Run the simple static scope picker (fallback when no project_id available).
fn run_simple_scope_picker() -> Option<Vec<String>> {
fn run_simple_scope_picker(services_filter: Option<&HashSet<String>>) -> Option<Vec<String>> {
use crate::setup_tui::{PickerResult, SelectItem};
let items: Vec<SelectItem> = SCOPE_ENTRIES
// Pre-filter SCOPE_ENTRIES by services if a filter is provided
let entries: Vec<&ScopeEntry> = SCOPE_ENTRIES
.iter()
.filter(|entry| {
services_filter.is_none_or(|services| {
services.is_empty() || scope_matches_service(entry.scope, services)
})
})
.collect();
let items: Vec<SelectItem> = entries
.iter()
.map(|entry| SelectItem {
label: entry.label.to_string(),
@@ -801,7 +913,7 @@ fn run_simple_scope_picker() -> Option<Vec<String>> {
.iter()
.enumerate()
.filter(|(_, item)| item.selected)
.map(|(i, _)| SCOPE_ENTRIES[i].scope.to_string())
.map(|(i, _)| entries[i].scope.to_string())
.collect();
if selected.is_empty() {
None
@@ -1376,6 +1488,8 @@ fn is_workspace_admin_scope(url: &str) -> bool {
.unwrap_or(url);
short.starts_with("apps.")
|| short.starts_with("cloud-identity.")
|| short.starts_with("chat.admin.")
|| short.starts_with("classroom.")
|| short == "ediscovery"
|| short == "directory.readonly"
|| short == "groups"
@@ -1388,7 +1502,18 @@ mod tests {
/// Helper to run resolve_scopes in tests (async).
fn run_resolve_scopes(args: &[String], project_id: Option<&str>) -> Vec<String> {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(resolve_scopes(args, project_id))
rt.block_on(resolve_scopes(args, project_id, None))
}
/// Helper to run resolve_scopes with a services filter.
fn run_resolve_scopes_with_services(
args: &[String],
project_id: Option<&str>,
services: &[&str],
) -> Vec<String> {
let filter: HashSet<String> = services.iter().map(|s| s.to_string()).collect();
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(resolve_scopes(args, project_id, Some(&filter)))
}
#[test]
@@ -1765,4 +1890,199 @@ mod tests {
"https://www.googleapis.com/auth/chat.messages"
));
}
// ── is_workspace_admin_scope new patterns ─────────────────────────
#[test]
fn is_workspace_admin_scope_chat_admin() {
assert!(is_workspace_admin_scope(
"https://www.googleapis.com/auth/chat.admin.memberships"
));
assert!(is_workspace_admin_scope(
"https://www.googleapis.com/auth/chat.admin.memberships.readonly"
));
assert!(is_workspace_admin_scope(
"https://www.googleapis.com/auth/chat.admin.spaces"
));
assert!(is_workspace_admin_scope(
"https://www.googleapis.com/auth/chat.admin.spaces.readonly"
));
}
#[test]
fn is_workspace_admin_scope_classroom() {
assert!(is_workspace_admin_scope(
"https://www.googleapis.com/auth/classroom.courses"
));
assert!(is_workspace_admin_scope(
"https://www.googleapis.com/auth/classroom.rosters"
));
}
// ── scope_matches_service tests ──────────────────────────────────────
#[test]
fn scope_matches_service_exact_match() {
let services: HashSet<String> = ["drive"].iter().map(|s| s.to_string()).collect();
assert!(scope_matches_service(
"https://www.googleapis.com/auth/drive",
&services
));
}
#[test]
fn scope_matches_service_aliases() {
let services: HashSet<String> = ["sheets", "docs", "slides"]
.iter()
.map(|s| s.to_string())
.collect();
assert!(scope_matches_service(
"https://www.googleapis.com/auth/spreadsheets",
&services
));
assert!(scope_matches_service(
"https://www.googleapis.com/auth/documents",
&services
));
assert!(scope_matches_service(
"https://www.googleapis.com/auth/presentations",
&services
));
}
#[test]
fn scope_matches_service_prefix_match() {
let services: HashSet<String> = ["drive"].iter().map(|s| s.to_string()).collect();
assert!(scope_matches_service(
"https://www.googleapis.com/auth/drive.readonly",
&services
));
assert!(scope_matches_service(
"https://www.googleapis.com/auth/drive.metadata.readonly",
&services
));
}
#[test]
fn scope_matches_service_no_match() {
let services: HashSet<String> = ["gmail"].iter().map(|s| s.to_string()).collect();
assert!(!scope_matches_service(
"https://www.googleapis.com/auth/drive",
&services
));
}
#[test]
fn scope_matches_service_cloud_platform_always_matches() {
let services: HashSet<String> = ["drive"].iter().map(|s| s.to_string()).collect();
assert!(scope_matches_service(
"https://www.googleapis.com/auth/cloud-platform",
&services
));
}
#[test]
fn scope_matches_service_no_partial_name_collision() {
// "drive" should NOT match "driveactivity" or similar
let services: HashSet<String> = ["drive"].iter().map(|s| s.to_string()).collect();
assert!(!scope_matches_service(
"https://www.googleapis.com/auth/driveactivity",
&services
));
}
// ── services filter integration tests ────────────────────────────────
#[test]
fn resolve_scopes_with_services_filter() {
let args: Vec<String> = vec![];
let scopes = run_resolve_scopes_with_services(&args, None, &["drive", "gmail"]);
assert!(!scopes.is_empty());
for scope in &scopes {
let short = scope
.strip_prefix("https://www.googleapis.com/auth/")
.unwrap_or(scope);
assert!(
short.starts_with("drive")
|| short.starts_with("gmail")
|| short == "cloud-platform",
"Unexpected scope with service filter: {scope}"
);
}
}
#[test]
fn resolve_scopes_services_filter_unknown_service_ignored() {
let args: Vec<String> = vec![];
let scopes = run_resolve_scopes_with_services(&args, None, &["drive", "nonexistent"]);
assert!(!scopes.is_empty());
// Should contain drive scope but not be affected by nonexistent
assert!(scopes.iter().any(|s| s.contains("/auth/drive")));
}
#[test]
fn resolve_scopes_services_takes_priority_with_readonly() {
let args = vec!["--readonly".to_string()];
let scopes = run_resolve_scopes_with_services(&args, None, &["drive"]);
assert!(!scopes.is_empty());
for scope in &scopes {
let short = scope
.strip_prefix("https://www.googleapis.com/auth/")
.unwrap_or(scope);
assert!(
short.starts_with("drive") || short == "cloud-platform",
"Unexpected scope with service + readonly filter: {scope}"
);
}
}
#[test]
fn resolve_scopes_services_takes_priority_with_full() {
let args = vec!["--full".to_string()];
let scopes = run_resolve_scopes_with_services(&args, None, &["gmail"]);
assert!(!scopes.is_empty());
for scope in &scopes {
let short = scope
.strip_prefix("https://www.googleapis.com/auth/")
.unwrap_or(scope);
assert!(
short.starts_with("gmail") || short == "cloud-platform",
"Unexpected scope with service + full filter: {scope}"
);
}
}
#[test]
fn resolve_scopes_explicit_scopes_bypass_services_filter() {
// --scopes should take priority over -s
let args = vec![
"--scopes".to_string(),
"https://www.googleapis.com/auth/calendar".to_string(),
];
let scopes = run_resolve_scopes_with_services(&args, None, &["drive"]);
assert_eq!(scopes.len(), 1);
assert_eq!(scopes[0], "https://www.googleapis.com/auth/calendar");
}
#[test]
fn filter_scopes_by_services_none_returns_all() {
let scopes = vec![
"https://www.googleapis.com/auth/drive".to_string(),
"https://www.googleapis.com/auth/gmail.modify".to_string(),
];
let result = filter_scopes_by_services(scopes.clone(), None);
assert_eq!(result, scopes);
}
#[test]
fn filter_scopes_by_services_empty_set_returns_all() {
let scopes = vec![
"https://www.googleapis.com/auth/drive".to_string(),
"https://www.googleapis.com/auth/gmail.modify".to_string(),
];
let empty: HashSet<String> = HashSet::new();
let result = filter_scopes_by_services(scopes.clone(), Some(&empty));
assert_eq!(result, scopes);
}
}