From 7ce29aac961404d3fc85b171685c8e6d44d402b3 Mon Sep 17 00:00:00 2001 From: jpoehnelt-bot Date: Thu, 5 Mar 2026 20:32:21 -0700 Subject: [PATCH 1/3] feat!: remove multi-account, DWD, and impersonation support BREAKING CHANGE: Remove domain-wide delegation, multi-account support, and impersonation from the CLI authentication flow. Removed: - `gws auth list` and `gws auth default` commands - `--account` flag from `gws auth login` and `gws auth logout` - `GOOGLE_WORKSPACE_CLI_ACCOUNT` env var - `GOOGLE_WORKSPACE_CLI_IMPERSONATED_USER` env var - Per-account credential storage (accounts.json registry) - Service account impersonation (subject/DWD) Preserved: - `GOOGLE_WORKSPACE_CLI_TOKEN` (raw access token) - `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` (SA key path) - `GOOGLE_WORKSPACE_CLI_CLIENT_ID` / `CLIENT_SECRET` (OAuth config) - `GOOGLE_WORKSPACE_CLI_CONFIG_DIR` (config dir override) --- src/accounts.rs | 259 --------------------------- src/auth.rs | 102 +---------- src/auth_commands.rs | 303 ++++---------------------------- src/credential_store.rs | 145 ++------------- src/helpers/calendar.rs | 4 +- src/helpers/chat.rs | 2 +- src/helpers/docs.rs | 2 +- src/helpers/drive.rs | 2 +- src/helpers/events/renew.rs | 2 +- src/helpers/events/subscribe.rs | 4 +- src/helpers/gmail/send.rs | 2 +- src/helpers/gmail/triage.rs | 2 +- src/helpers/gmail/watch.rs | 4 +- src/helpers/modelarmor.rs | 4 +- src/helpers/script.rs | 2 +- src/helpers/sheets.rs | 4 +- src/helpers/workflows.rs | 10 +- src/main.rs | 197 ++------------------- src/mcp_server.rs | 3 +- 19 files changed, 88 insertions(+), 965 deletions(-) delete mode 100644 src/accounts.rs diff --git a/src/accounts.rs b/src/accounts.rs deleted file mode 100644 index a569bb6..0000000 --- a/src/accounts.rs +++ /dev/null @@ -1,259 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! Multi-account registry for `gws`. -//! -//! Manages `~/.config/gws/accounts.json` which maps email addresses to -//! credential files and tracks the default account. - -use std::collections::BTreeMap; -use std::path::PathBuf; - -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use base64::Engine; -use serde::{Deserialize, Serialize}; - -/// On-disk representation of `accounts.json`. -#[derive(Debug, Serialize, Deserialize, Default)] -pub struct AccountsRegistry { - /// Email of the default account, or `None` if no default is set. - pub default: Option, - /// Map from normalised email → account metadata. - pub accounts: BTreeMap, -} - -/// Per-account metadata stored in the registry. -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct AccountMeta { - /// ISO-8601 timestamp of when this account was added. - pub added: String, -} - -// --------------------------------------------------------------------------- -// Email normalisation & base64 helpers -// --------------------------------------------------------------------------- - -/// Normalise an email address: trim whitespace and lowercase. -/// -/// Google treats email addresses as case-insensitive, so -/// `User@Gmail.COM` and `user@gmail.com` must map to the same -/// credential file and registry entry. -pub fn normalize_email(email: &str) -> String { - email.trim().to_lowercase() -} - -/// Encode a normalised email to a URL-safe Base64 string (no padding). -/// -/// This is used as the unique key in credential/token-cache filenames -/// (e.g. `credentials..enc`) to avoid filesystem issues with `@` -/// and `.` characters across operating systems. -pub fn email_to_b64(email: &str) -> String { - URL_SAFE_NO_PAD.encode(email.as_bytes()) -} - -// --------------------------------------------------------------------------- -// Registry I/O -// --------------------------------------------------------------------------- - -/// Path to `accounts.json` inside the config directory. -pub fn accounts_path() -> PathBuf { - crate::auth_commands::config_dir().join("accounts.json") -} - -/// Load the accounts registry from disk. Returns `None` if the file does not -/// exist, and an error if it exists but cannot be parsed. -pub fn load_accounts() -> anyhow::Result> { - let path = accounts_path(); - if !path.exists() { - return Ok(None); - } - let data = std::fs::read_to_string(&path)?; - let registry: AccountsRegistry = serde_json::from_str(&data)?; - Ok(Some(registry)) -} - -/// Persist the accounts registry to disk with `0o600` permissions. -pub fn save_accounts(registry: &AccountsRegistry) -> anyhow::Result<()> { - let path = accounts_path(); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if let Err(e) = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)) - { - eprintln!( - "Warning: failed to set directory permissions on {}: {e}", - parent.display() - ); - } - } - } - - let json = serde_json::to_string_pretty(registry)?; - crate::fs_util::atomic_write(&path, json.as_bytes()) - .map_err(|e| anyhow::anyhow!("Failed to write accounts.json: {e}"))?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if let Err(e) = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) { - eprintln!( - "Warning: failed to set file permissions on {}: {e}", - path.display() - ); - } - } - - Ok(()) -} - -// --------------------------------------------------------------------------- -// Registry mutations -// --------------------------------------------------------------------------- - -/// Return the default account email, if one is set. -pub fn get_default(registry: &AccountsRegistry) -> Option<&str> { - registry.default.as_deref() -} - -/// Set the default account. Returns an error if the email is not registered. -pub fn set_default(registry: &mut AccountsRegistry, email: &str) -> anyhow::Result<()> { - let normalised = normalize_email(email); - if !registry.accounts.contains_key(&normalised) { - anyhow::bail!( - "Account '{}' not found. Run 'gws auth login' to add it.", - normalised - ); - } - registry.default = Some(normalised); - Ok(()) -} - -/// Register a new account (or update its metadata if it already exists). -/// If this is the first account, it becomes the default automatically. -pub fn add_account(registry: &mut AccountsRegistry, email: &str) { - let normalised = normalize_email(email); - let meta = AccountMeta { - added: chrono::Utc::now().to_rfc3339(), - }; - registry.accounts.insert(normalised.clone(), meta); - if registry.default.is_none() || registry.accounts.len() == 1 { - registry.default = Some(normalised); - } -} - -/// Remove an account from the registry. -/// -/// If the removed account was the default, the default is auto-promoted to -/// the next available account (or set to `None` if no accounts remain). -pub fn remove_account(registry: &mut AccountsRegistry, email: &str) { - let normalised = normalize_email(email); - registry.accounts.remove(&normalised); - - // Handle dangling default - if registry.default.as_deref() == Some(&normalised) { - registry.default = registry.accounts.keys().next().cloned(); - } -} - -/// List all registered account emails. -pub fn list_accounts(registry: &AccountsRegistry) -> Vec<&str> { - registry.accounts.keys().map(|s| s.as_str()).collect() -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_email_normalization() { - assert_eq!(normalize_email(" User@Gmail.COM "), "user@gmail.com"); - assert_eq!(normalize_email("WORK@Corp.com"), "work@corp.com"); - assert_eq!(normalize_email("simple@example.com"), "simple@example.com"); - } - - #[test] - fn test_email_to_b64_no_pad() { - let encoded = email_to_b64("user@gmail.com"); - // Must not contain +, /, or = - assert!(!encoded.contains('+')); - assert!(!encoded.contains('/')); - assert!(!encoded.contains('=')); - // Must be non-empty and deterministic - assert!(!encoded.is_empty()); - assert_eq!(encoded, email_to_b64("user@gmail.com")); - } - - #[test] - fn test_email_case_produces_same_b64() { - let a = email_to_b64(&normalize_email("User@Gmail.COM")); - let b = email_to_b64(&normalize_email("user@gmail.com")); - assert_eq!( - a, b, - "Case-different emails should produce the same b64 after normalization" - ); - } - - #[test] - fn test_accounts_json_round_trip() { - let mut registry = AccountsRegistry::default(); - assert!(registry.accounts.is_empty()); - assert!(registry.default.is_none()); - - // Add first account → auto-default - add_account(&mut registry, "first@example.com"); - assert_eq!(registry.default.as_deref(), Some("first@example.com")); - assert_eq!(list_accounts(®istry), vec!["first@example.com"]); - - // Add second account → default unchanged - add_account(&mut registry, "second@example.com"); - assert_eq!(registry.default.as_deref(), Some("first@example.com")); - assert_eq!(list_accounts(®istry).len(), 2); - - // Set default - set_default(&mut registry, "second@example.com").unwrap(); - assert_eq!(registry.default.as_deref(), Some("second@example.com")); - - // Set default to unknown → error - let err = set_default(&mut registry, "unknown@example.com"); - assert!(err.is_err()); - - // Remove default account → auto-promote - remove_account(&mut registry, "second@example.com"); - assert!(registry.default.is_some()); // promoted to first - assert_eq!(list_accounts(®istry), vec!["first@example.com"]); - - // Remove last account → default is None - remove_account(&mut registry, "first@example.com"); - assert!(registry.default.is_none()); - assert!(registry.accounts.is_empty()); - } - - #[test] - fn test_serde_round_trip() { - let mut registry = AccountsRegistry::default(); - add_account(&mut registry, "test@example.com"); - - let json = serde_json::to_string_pretty(®istry).unwrap(); - let parsed: AccountsRegistry = serde_json::from_str(&json).unwrap(); - - assert_eq!(parsed.default.as_deref(), Some("test@example.com")); - assert!(parsed.accounts.contains_key("test@example.com")); - } -} diff --git a/src/auth.rs b/src/auth.rs index 3c3ff98..ec57ac2 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -63,16 +63,13 @@ enum Credential { /// Tries credentials in order: /// 0. `GOOGLE_WORKSPACE_CLI_TOKEN` env var (raw access token, highest priority) /// 1. `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` env var (plaintext JSON, can be User or Service Account) -/// 2. Per-account encrypted credentials via `accounts.json` registry +/// 2. Encrypted credentials at `~/.config/gws/credentials.enc` /// 3. Plaintext credentials at `~/.config/gws/credentials.json` (User only) /// 4. Application Default Credentials (ADC): /// - `GOOGLE_APPLICATION_CREDENTIALS` env var (path to a JSON credentials file), then /// - Well-known ADC path: `~/.config/gcloud/application_default_credentials.json` /// (populated by `gcloud auth application-default login`) -/// -/// When `account` is `Some`, a specific registered account is used. -/// When `account` is `None`, the default account from `accounts.json` is used. -pub async fn get_token(scopes: &[&str], account: Option<&str>) -> anyhow::Result { +pub async fn get_token(scopes: &[&str]) -> anyhow::Result { // 0. Direct token from env var (highest priority, bypasses all credential loading) if let Ok(token) = std::env::var("GOOGLE_WORKSPACE_CLI_TOKEN") { if !token.is_empty() { @@ -82,85 +79,12 @@ pub async fn get_token(scopes: &[&str], account: Option<&str>) -> anyhow::Result let creds_file = std::env::var("GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE").ok(); let config_dir = crate::auth_commands::config_dir(); - - // If env var credentials are specified, skip account resolution entirely - if creds_file.is_some() { - let enc_path = credential_store::encrypted_credentials_path(); - let default_path = config_dir.join("credentials.json"); - let token_cache = config_dir.join("token_cache.json"); - let creds = load_credentials_inner(creds_file.as_deref(), &enc_path, &default_path).await?; - return get_token_inner(scopes, creds, &token_cache).await; - } - - // Resolve account from registry - let resolved_account = resolve_account(account)?; - - let enc_path = match &resolved_account { - Some(email) => credential_store::encrypted_credentials_path_for(email), - None => credential_store::encrypted_credentials_path(), - }; - - // Per-account token cache: token_cache..json - let token_cache_name = resolved_account - .as_ref() - .map(|email| { - let b64 = crate::accounts::email_to_b64(&crate::accounts::normalize_email(email)); - format!("token_cache.{b64}.json") - }) - .unwrap_or_else(|| "token_cache.json".to_string()); - let token_cache_path = config_dir.join(token_cache_name); - + let enc_path = credential_store::encrypted_credentials_path(); let default_path = config_dir.join("credentials.json"); - let creds = load_credentials_inner(None, &enc_path, &default_path).await?; - get_token_inner(scopes, creds, &token_cache_path).await -} + let token_cache = config_dir.join("token_cache.json"); -/// Resolve which account to use: -/// 1. Explicit `account` parameter takes priority. -/// 2. Fall back to `accounts.json` default. -/// 3. If no registry exists, return None to allow legacy `credentials.enc` fallthrough. -fn resolve_account(account: Option<&str>) -> anyhow::Result> { - let registry = crate::accounts::load_accounts()?; - - match (account, ®istry) { - // Explicit account requested — validate it exists in registry - (Some(email), Some(reg)) => { - let normalised = crate::accounts::normalize_email(email); - if !reg.accounts.contains_key(&normalised) { - anyhow::bail!( - "Account '{}' not found. Run 'gws auth login' to add it.", - normalised - ); - } - Ok(Some(normalised)) - } - // Explicit account but no registry - (Some(email), None) => { - anyhow::bail!( - "Account '{}' not found. No accounts registered. Run 'gws auth login'.", - crate::accounts::normalize_email(email) - ); - } - // No explicit account — use default from registry - (None, Some(reg)) => { - if let Some(default) = crate::accounts::get_default(reg) { - Ok(Some(default.to_string())) - } else if reg.accounts.len() == 1 { - // Auto-select the only account - Ok(reg.accounts.keys().next().cloned()) - } else { - anyhow::bail!( - "No default account set. Use --account or run 'gws auth default '." - ); - } - } - // No account, no registry — use legacy credentials if they exist - (None, None) => { - // Fall through to standard credential loading which will pick up - // the legacy credentials.enc file if it exists. - Ok(None) - } - } + let creds = load_credentials_inner(creds_file.as_deref(), &enc_path, &default_path).await?; + get_token_inner(scopes, creds, &token_cache).await } async fn get_token_inner( @@ -595,24 +519,12 @@ mod tests { async fn test_get_token_from_env_var() { let _token_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_CLI_TOKEN", "my-test-token"); - let result = get_token(&["https://www.googleapis.com/auth/drive"], None).await; + let result = get_token(&["https://www.googleapis.com/auth/drive"]).await; assert!(result.is_ok()); assert_eq!(result.unwrap(), "my-test-token"); } - #[test] - fn test_resolve_account_no_registry_no_account_returns_none() { - // When there is no accounts.json and no explicit account, - // resolve_account should return Ok(None) to allow legacy - // credentials.enc to be picked up by load_credentials_inner. - let result = resolve_account(None); - // This will return Ok(None) if accounts.json doesn't exist, - // or Ok(Some(...)) if it does with a default. Either way, it - // should NOT error for the no-registry case. - assert!(result.is_ok()); - } - #[tokio::test] async fn test_load_credentials_encrypted_file() { // Simulate an encrypted credentials file diff --git a/src/auth_commands.rs b/src/auth_commands.rs index 37de109..47d2d4e 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -131,9 +131,8 @@ fn token_cache_path() -> PathBuf { /// Handle `gws auth `. pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> { const USAGE: &str = concat!( - "Usage: gws auth [options]\n\n", + "Usage: gws auth [options]\n\n", " login Authenticate via OAuth2 (opens browser)\n", - " --account EMAIL Associate credentials with a specific account\n", " --readonly Request read-only scopes\n", " --full Request all scopes incl. pubsub + cloud-platform\n", " (may trigger restricted_client for unverified apps)\n", @@ -144,11 +143,7 @@ pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> { " --project Use a specific GCP project\n", " status Show current authentication state\n", " export Print decrypted credentials to stdout\n", - " logout Clear saved credentials and token cache\n", - " --account EMAIL Logout a specific account (otherwise: all)\n", - " list List all registered accounts\n", - " default Set the default account\n", - " --account EMAIL Account to set as default", + " logout Clear saved credentials and token cache", ); // Honour --help / -h before treating the first arg as a subcommand. @@ -165,11 +160,9 @@ pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> { let unmasked = args.len() > 1 && args[1] == "--unmasked"; handle_export(unmasked).await } - "logout" => handle_logout(&args[1..]), - "list" => handle_list(), - "default" => handle_default(&args[1..]), + "logout" => handle_logout(), other => Err(GwsError::Validation(format!( - "Unknown auth subcommand: '{other}'. Use: login, setup, status, export, logout, list, default" + "Unknown auth subcommand: '{other}'. Use: login, setup, status, export, logout" ))), } } @@ -210,8 +203,7 @@ impl yup_oauth2::authenticator_delegate::InstalledFlowDelegate for CliFlowDelega } async fn handle_login(args: &[String]) -> Result<(), GwsError> { - // Extract --account and -s/--services from args - let mut account_email: Option = None; + // Extract -s/--services from args let mut services_filter: Option> = None; let mut filtered_args: Vec = Vec::new(); let mut skip_next = false; @@ -220,15 +212,6 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { skip_next = false; continue; } - if args[i] == "--account" && i + 1 < args.len() { - account_email = Some(args[i + 1].clone()); - skip_next = true; - continue; - } - if let Some(value) = args[i].strip_prefix("--account=") { - 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()) @@ -273,9 +256,6 @@ 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 mut scopes = filter_redundant_restrictive_scopes(scopes); let secret = yup_oauth2::ApplicationSecret { @@ -300,8 +280,6 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { let temp_path = config_dir().join("credentials.tmp"); // Always start fresh — delete any stale temp cache from prior login attempts. - // Without this, yup-oauth2 finds a cached access token and skips the browser flow, - // which means no refresh_token is returned. let _ = std::fs::remove_file(&temp_path); // Ensure config directory exists @@ -318,9 +296,7 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { temp_path.clone(), ))) .force_account_selection(true) // Adds prompt=consent so Google always returns a refresh_token - .flow_delegate(Box::new(CliFlowDelegate { - login_hint: account_email.clone(), - })) + .flow_delegate(Box::new(CliFlowDelegate { login_hint: None })) .build() .await .map_err(|e| GwsError::Auth(format!("Failed to build authenticator: {e}")))?; @@ -359,63 +335,13 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { let creds_str = serde_json::to_string_pretty(&creds_json) .map_err(|e| GwsError::Validation(format!("Failed to serialize credentials: {e}")))?; - // Fetch the user's email from Google userinfo to validate and register + // Fetch the user's email from Google userinfo let access_token = token.token().unwrap_or_default(); let actual_email = fetch_userinfo_email(access_token).await; - // If --account was specified, validate the email matches - if let Some(ref requested) = account_email { - if let Some(ref actual) = actual_email { - let normalized_requested = crate::accounts::normalize_email(requested); - let normalized_actual = crate::accounts::normalize_email(actual); - if normalized_requested != normalized_actual { - // Clean up temp file - let _ = std::fs::remove_file(&temp_path); - return Err(GwsError::Auth(format!( - "Login account mismatch: requested '{}' but authenticated as '{}'. \ - Please try again and select the correct account in the browser.", - requested, actual - ))); - } - } - } - - // Determine which email to use for the account - let resolved_email = account_email.or(actual_email); - // Save encrypted credentials - let enc_path = if let Some(ref email) = resolved_email { - // Per-account save - credential_store::save_encrypted_for(&creds_str, email) - .map_err(|e| GwsError::Auth(format!("Failed to encrypt credentials: {e}")))?; - - // Register in accounts.json - let mut registry = crate::accounts::load_accounts() - .map_err(|e| GwsError::Auth(format!("Failed to load accounts: {e}")))? - .unwrap_or_default(); - crate::accounts::add_account(&mut registry, email); - // If this is the first account, set it as default - if registry.default.is_none() || registry.accounts.len() == 1 { - crate::accounts::set_default(&mut registry, email) - .map_err(|e| GwsError::Auth(format!("Failed to set default: {e}")))?; - } - crate::accounts::save_accounts(®istry) - .map_err(|e| GwsError::Auth(format!("Failed to save accounts: {e}")))?; - - credential_store::encrypted_credentials_path_for(email) - } else { - // Legacy single-account save (no email available) - credential_store::save_encrypted(&creds_str) - .map_err(|e| GwsError::Auth(format!("Failed to encrypt credentials: {e}")))? - }; - - // Clean up old legacy credentials.enc if we now have an account-keyed one - if resolved_email.is_some() { - let legacy = credential_store::encrypted_credentials_path(); - if legacy.exists() && legacy != enc_path { - let _ = std::fs::remove_file(&legacy); - } - } + let enc_path = credential_store::save_encrypted(&creds_str) + .map_err(|e| GwsError::Auth(format!("Failed to encrypt credentials: {e}")))?; // Clean up temp file let _ = std::fs::remove_file(&temp_path); @@ -423,7 +349,7 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { let output = json!({ "status": "success", "message": "Authentication successful. Encrypted credentials saved.", - "account": resolved_email.as_deref().unwrap_or("(unknown)"), + "account": actual_email.as_deref().unwrap_or("(unknown)"), "credentials_file": enc_path.display().to_string(), "encryption": "AES-256-GCM (key secured by OS Keyring or local `.encryption_key`)", "scopes": scopes, @@ -1222,198 +1148,35 @@ async fn handle_status() -> Result<(), GwsError> { Ok(()) } -fn handle_logout(args: &[String]) -> Result<(), GwsError> { - // Extract --account from args - let mut account_email: Option = None; - for i in 0..args.len() { - if args[i] == "--account" && i + 1 < args.len() { - account_email = Some(args[i + 1].clone()); - } else if let Some(value) = args[i].strip_prefix("--account=") { - account_email = Some(value.to_string()); - } - } - - if let Some(ref email) = account_email { - // Per-account logout: remove credentials and token caches - let enc_path = credential_store::encrypted_credentials_path_for(email); - let b64 = crate::accounts::email_to_b64(&crate::accounts::normalize_email(email)); - let config = config_dir(); - let token_cache = config.join(format!("token_cache.{b64}.json")); - let sa_token_cache = config.join(format!("sa_token_cache.{b64}.json")); - let mut removed = Vec::new(); - - for path in [&enc_path, &token_cache, &sa_token_cache] { - if path.exists() { - std::fs::remove_file(path).map_err(|e| { - GwsError::Validation(format!("Failed to remove {}: {e}", path.display())) - })?; - removed.push(path.display().to_string()); - } - } - - // Remove from accounts.json registry - let mut registry = crate::accounts::load_accounts() - .map_err(|e| GwsError::Auth(format!("Failed to load accounts: {e}")))? - .unwrap_or_default(); - crate::accounts::remove_account(&mut registry, email); - crate::accounts::save_accounts(®istry) - .map_err(|e| GwsError::Auth(format!("Failed to save accounts: {e}")))?; - - let output = if removed.is_empty() { - json!({ - "status": "success", - "message": format!("No credentials found for account '{email}'."), - }) - } else { - json!({ - "status": "success", - "message": format!("Logged out account '{email}'. Credentials removed."), - "removed": removed, - }) - }; - - println!( - "{}", - serde_json::to_string_pretty(&output).unwrap_or_default() - ); - } else { - // Full logout: remove all credentials - let plain_path = plain_credentials_path(); - let enc_path = credential_store::encrypted_credentials_path(); - let token_cache = token_cache_path(); - let accounts_path = crate::accounts::accounts_path(); - - let mut removed = Vec::new(); - - // Load accounts BEFORE deleting accounts.json so we can clean up per-account files - let registry = crate::accounts::load_accounts() - .map_err(|e| GwsError::Auth(format!("Failed to load accounts: {e}")))? - .unwrap_or_default(); +fn handle_logout() -> Result<(), GwsError> { + let plain_path = plain_credentials_path(); + let enc_path = credential_store::encrypted_credentials_path(); + let token_cache = token_cache_path(); + let sa_token_cache = config_dir().join("sa_token_cache.json"); - for path in [&enc_path, &plain_path, &token_cache, &accounts_path] { - if path.exists() { - std::fs::remove_file(path).map_err(|e| { - GwsError::Validation(format!("Failed to remove {}: {e}", path.display())) - })?; - removed.push(path.display().to_string()); - } - } + let mut removed = Vec::new(); - // Also remove any per-account credential and token cache files - for email in registry.accounts.keys() { - let b64 = crate::accounts::email_to_b64(&crate::accounts::normalize_email(email)); - let cred_path = credential_store::encrypted_credentials_path_for(email); - let tc_path = config_dir().join(format!("token_cache.{b64}.json")); - let sa_tc_path = config_dir().join(format!("sa_token_cache.{b64}.json")); - for path in [&cred_path, &tc_path, &sa_tc_path] { - if path.exists() { - std::fs::remove_file(path).map_err(|e| { - GwsError::Validation(format!("Failed to remove {}: {e}", path.display())) - })?; - removed.push(path.display().to_string()); - } - } + for path in [&enc_path, &plain_path, &token_cache, &sa_token_cache] { + if path.exists() { + std::fs::remove_file(path).map_err(|e| { + GwsError::Validation(format!("Failed to remove {}: {e}", path.display())) + })?; + removed.push(path.display().to_string()); } - - let output = if removed.is_empty() { - json!({ - "status": "success", - "message": "No credentials found to remove.", - }) - } else { - json!({ - "status": "success", - "message": "Logged out. All credentials and token caches removed.", - "removed": removed, - }) - }; - - println!( - "{}", - serde_json::to_string_pretty(&output).unwrap_or_default() - ); } - Ok(()) -} -/// List all registered accounts. -fn handle_list() -> Result<(), GwsError> { - let registry = crate::accounts::load_accounts() - .map_err(|e| GwsError::Auth(format!("Failed to load accounts: {e}")))? - .unwrap_or_default(); - let account_emails = crate::accounts::list_accounts(®istry); - let accounts: Vec = account_emails - .iter() - .map(|email| { - let meta = registry.accounts.get(*email); - json!({ - "email": email, - "is_default": registry.default.as_deref() == Some(*email), - "added": meta.map(|m| m.added.as_str()).unwrap_or(""), - }) + let output = if removed.is_empty() { + json!({ + "status": "success", + "message": "No credentials found to remove.", }) - .collect(); - - let output = json!({ - "accounts": accounts, - "default": registry.default.unwrap_or_default(), - "count": accounts.len(), - }); - - println!( - "{}", - serde_json::to_string_pretty(&output).unwrap_or_default() - ); - Ok(()) -} - -/// Set the default account. -fn handle_default(args: &[String]) -> Result<(), GwsError> { - // Extract --account from args - let mut account_email: Option = None; - for i in 0..args.len() { - if args[i] == "--account" && i + 1 < args.len() { - account_email = Some(args[i + 1].clone()); - } else if let Some(value) = args[i].strip_prefix("--account=") { - account_email = Some(value.to_string()); - } - } - - // If no --account flag, check if the first arg is the email directly - let email = account_email - .or_else(|| args.first().filter(|a| !a.starts_with('-')).cloned()) - .ok_or_else(|| { - GwsError::Validation( - "Usage: gws auth default or gws auth default --account ".to_string(), - ) - })?; - - let mut registry = crate::accounts::load_accounts() - .map_err(|e| GwsError::Auth(format!("Failed to load accounts: {e}")))? - .unwrap_or_default(); - - // Verify the account exists - if !registry - .accounts - .keys() - .any(|k| crate::accounts::normalize_email(k) == crate::accounts::normalize_email(&email)) - { - return Err(GwsError::Validation(format!( - "Account '{}' not found. Run `gws auth list` to see registered accounts.", - email - ))); - } - - crate::accounts::set_default(&mut registry, &email) - .map_err(|e| GwsError::Auth(format!("Failed to set default: {e}")))?; - crate::accounts::save_accounts(®istry) - .map_err(|e| GwsError::Auth(format!("Failed to save accounts: {e}")))?; - - let output = json!({ - "status": "success", - "message": format!("Default account set to '{email}'."), - "default": email, - }); + } else { + json!({ + "status": "success", + "message": "Logged out. All credentials and token caches removed.", + "removed": removed, + }) + }; println!( "{}", diff --git a/src/credential_store.rs b/src/credential_store.rs index 7b9bd73..867d5bc 100644 --- a/src/credential_store.rs +++ b/src/credential_store.rs @@ -273,67 +273,29 @@ pub fn load_encrypted() -> anyhow::Result { load_encrypted_from_path(&encrypted_credentials_path()) } -/// Returns the path for per-account encrypted credentials. -/// -/// The filename is `credentials..enc` where `` is the -/// URL-safe, no-pad base64 encoding of the normalised email address. -pub fn encrypted_credentials_path_for(account: &str) -> PathBuf { - let normalised = crate::accounts::normalize_email(account); - let b64 = crate::accounts::email_to_b64(&normalised); - crate::auth_commands::config_dir().join(format!("credentials.{b64}.enc")) -} +#[cfg(test)] +mod tests { + use super::*; -/// Saves credentials JSON to a per-account encrypted file. -pub fn save_encrypted_for(json: &str, account: &str) -> anyhow::Result { - let path = encrypted_credentials_path_for(account); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if let Err(e) = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)) - { - eprintln!( - "Warning: failed to set directory permissions on {}: {e}", - parent.display() - ); - } - } + #[test] + fn get_or_create_key_is_deterministic() { + let key1 = get_or_create_key().unwrap(); + let key2 = get_or_create_key().unwrap(); + assert_eq!(key1, key2); } - let encrypted = encrypt(json.as_bytes())?; - crate::fs_util::atomic_write(&path, &encrypted) - .map_err(|e| anyhow::anyhow!("Failed to write credentials for {account}: {e}"))?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if let Err(e) = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) { - eprintln!( - "Warning: failed to set file permissions on {}: {e}", - path.display() - ); - } + #[test] + fn get_or_create_key_produces_256_bits() { + let key = get_or_create_key().unwrap(); + assert_eq!(key.len(), 32); } - Ok(path) -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] fn encrypt_decrypt_round_trip() { let plaintext = b"hello, world!"; let encrypted = encrypt(plaintext).expect("encryption should succeed"); - - // Encrypted data should be different from plaintext assert_ne!(&encrypted, plaintext); - - // Should be nonce (12) + ciphertext (plaintext + 16 byte tag) assert_eq!(encrypted.len(), 12 + plaintext.len() + 16); - let decrypted = decrypt(&encrypted).expect("decryption should succeed"); assert_eq!(decrypted, plaintext); } @@ -346,22 +308,6 @@ mod tests { assert_eq!(decrypted, plaintext); } - #[test] - fn encrypt_decrypt_json_credentials() { - let json = r#"{"type":"authorized_user","client_id":"test.apps.googleusercontent.com","client_secret":"secret","refresh_token":"1//token"}"#; - let encrypted = encrypt(json.as_bytes()).expect("encryption should succeed"); - let decrypted = decrypt(&encrypted).expect("decryption should succeed"); - assert_eq!(String::from_utf8(decrypted).unwrap(), json); - } - - #[test] - fn encrypt_decrypt_large_payload() { - let plaintext: Vec = (0..10_000).map(|i| (i % 256) as u8).collect(); - let encrypted = encrypt(&plaintext).expect("encryption should succeed"); - let decrypted = decrypt(&encrypted).expect("decryption should succeed"); - assert_eq!(decrypted, plaintext); - } - #[test] fn decrypt_rejects_short_data() { let result = decrypt(&[0u8; 11]); @@ -372,28 +318,10 @@ mod tests { #[test] fn decrypt_rejects_tampered_ciphertext() { let encrypted = encrypt(b"secret data").expect("encryption should succeed"); - - // Tamper with the ciphertext (after the 12-byte nonce) let mut tampered = encrypted.clone(); if tampered.len() > 12 { tampered[12] ^= 0xFF; } - - let result = decrypt(&tampered); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("Decryption failed")); - } - - #[test] - fn decrypt_rejects_tampered_nonce() { - let encrypted = encrypt(b"secret data").expect("encryption should succeed"); - - let mut tampered = encrypted.clone(); - tampered[0] ^= 0xFF; - let result = decrypt(&tampered); assert!(result.is_err()); } @@ -403,59 +331,10 @@ mod tests { let plaintext = b"same input"; let enc1 = encrypt(plaintext).expect("encryption should succeed"); let enc2 = encrypt(plaintext).expect("encryption should succeed"); - - // Different nonces should produce different ciphertext assert_ne!(enc1, enc2); - - // But both should decrypt to the same plaintext let dec1 = decrypt(&enc1).unwrap(); let dec2 = decrypt(&enc2).unwrap(); assert_eq!(dec1, dec2); assert_eq!(dec1, plaintext); } - - #[test] - fn get_or_create_key_is_deterministic() { - let key1 = get_or_create_key().unwrap(); - let key2 = get_or_create_key().unwrap(); - assert_eq!(key1, key2); - } - - #[test] - fn get_or_create_key_produces_256_bits() { - let key = get_or_create_key().unwrap(); - assert_eq!(key.len(), 32); - } - - #[test] - fn test_encrypted_credentials_path_for_uses_b64() { - let path = encrypted_credentials_path_for("user@gmail.com"); - let filename = path.file_name().unwrap().to_str().unwrap(); - // Should start with "credentials." and end with ".enc" - assert!(filename.starts_with("credentials.")); - assert!(filename.ends_with(".enc")); - // Should NOT contain the raw email - assert!(!filename.contains('@')); - assert!(!filename.contains("user@gmail.com")); - } - - #[test] - fn test_encrypted_credentials_path_for_case_insensitive() { - let path1 = encrypted_credentials_path_for("User@Gmail.COM"); - let path2 = encrypted_credentials_path_for("user@gmail.com"); - assert_eq!( - path1, path2, - "Case-different emails should map to same path" - ); - } - - #[test] - fn test_encrypted_credentials_path_for_different_emails_differ() { - let path1 = encrypted_credentials_path_for("alice@example.com"); - let path2 = encrypted_credentials_path_for("bob@example.com"); - assert_ne!( - path1, path2, - "Different emails should map to different paths" - ); - } } diff --git a/src/helpers/calendar.rs b/src/helpers/calendar.rs index f9bd4af..e300592 100644 --- a/src/helpers/calendar.rs +++ b/src/helpers/calendar.rs @@ -149,7 +149,7 @@ TIPS: let (params_str, body_str, scopes) = build_insert_request(matches, doc)?; let scopes_str: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); - let (token, auth_method) = match auth::get_token(&scopes_str, None).await { + let (token, auth_method) = match auth::get_token(&scopes_str).await { Ok(t) => (Some(t), executor::AuthMethod::OAuth), Err(_) => (None, executor::AuthMethod::None), }; @@ -191,7 +191,7 @@ TIPS: } async fn handle_agenda(matches: &ArgMatches) -> Result<(), GwsError> { let cal_scope = "https://www.googleapis.com/auth/calendar.readonly"; - let token = auth::get_token(&[cal_scope], None) + let token = auth::get_token(&[cal_scope]) .await .map_err(|e| GwsError::Auth(format!("Calendar auth failed: {e}")))?; diff --git a/src/helpers/chat.rs b/src/helpers/chat.rs index 4362d6e..fce51ed 100644 --- a/src/helpers/chat.rs +++ b/src/helpers/chat.rs @@ -78,7 +78,7 @@ TIPS: let (params_str, body_str, scopes) = build_send_request(&config, doc)?; let scope_strs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); - let (token, auth_method) = match auth::get_token(&scope_strs, None).await { + let (token, auth_method) = match auth::get_token(&scope_strs).await { Ok(t) => (Some(t), executor::AuthMethod::OAuth), Err(_) => (None, executor::AuthMethod::None), }; diff --git a/src/helpers/docs.rs b/src/helpers/docs.rs index d21b9c4..e847dfb 100644 --- a/src/helpers/docs.rs +++ b/src/helpers/docs.rs @@ -70,7 +70,7 @@ TIPS: let (params_str, body_str, scopes) = build_write_request(matches, doc)?; let scope_strs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); - let (token, auth_method) = match auth::get_token(&scope_strs, None).await { + let (token, auth_method) = match auth::get_token(&scope_strs).await { Ok(t) => (Some(t), executor::AuthMethod::OAuth), Err(_) => (None, executor::AuthMethod::None), }; diff --git a/src/helpers/drive.rs b/src/helpers/drive.rs index edeae67..f23aa55 100644 --- a/src/helpers/drive.rs +++ b/src/helpers/drive.rs @@ -96,7 +96,7 @@ TIPS: let body_str = metadata.to_string(); let scopes: Vec<&str> = create_method.scopes.iter().map(|s| s.as_str()).collect(); - let (token, auth_method) = match auth::get_token(&scopes, None).await { + let (token, auth_method) = match auth::get_token(&scopes).await { Ok(t) => (Some(t), executor::AuthMethod::OAuth), Err(_) => (None, executor::AuthMethod::None), }; diff --git a/src/helpers/events/renew.rs b/src/helpers/events/renew.rs index 1d8a65f..3dd1d62 100644 --- a/src/helpers/events/renew.rs +++ b/src/helpers/events/renew.rs @@ -31,7 +31,7 @@ pub(super) async fn handle_renew( ) -> Result<(), GwsError> { let config = parse_renew_args(matches)?; let client = crate::client::build_client()?; - let ws_token = auth::get_token(&[WORKSPACE_EVENTS_SCOPE], None) + let ws_token = auth::get_token(&[WORKSPACE_EVENTS_SCOPE]) .await .map_err(|e| GwsError::Auth(format!("Failed to get token: {e}")))?; diff --git a/src/helpers/events/subscribe.rs b/src/helpers/events/subscribe.rs index 38c340a..dbe6047 100644 --- a/src/helpers/events/subscribe.rs +++ b/src/helpers/events/subscribe.rs @@ -112,7 +112,7 @@ pub(super) async fn handle_subscribe( let client = crate::client::build_client()?; // Get Pub/Sub token - let pubsub_token = auth::get_token(&[PUBSUB_SCOPE], None) + let pubsub_token = auth::get_token(&[PUBSUB_SCOPE]) .await .map_err(|e| GwsError::Auth(format!("Failed to get Pub/Sub token: {e}")))?; @@ -184,7 +184,7 @@ pub(super) async fn handle_subscribe( // 3. Create Workspace Events subscription eprintln!("Creating Workspace Events subscription..."); - let ws_token = auth::get_token(&[WORKSPACE_EVENTS_SCOPE], None) + let ws_token = auth::get_token(&[WORKSPACE_EVENTS_SCOPE]) .await .map_err(|e| { GwsError::Auth(format!("Failed to get Workspace Events token: {e}")) diff --git a/src/helpers/gmail/send.rs b/src/helpers/gmail/send.rs index 072e676..20a2605 100644 --- a/src/helpers/gmail/send.rs +++ b/src/helpers/gmail/send.rs @@ -33,7 +33,7 @@ pub(super) async fn handle_send( let params_str = params.to_string(); let scopes: Vec<&str> = send_method.scopes.iter().map(|s| s.as_str()).collect(); - let (token, auth_method) = match auth::get_token(&scopes, None).await { + let (token, auth_method) = match auth::get_token(&scopes).await { Ok(t) => (Some(t), executor::AuthMethod::OAuth), Err(_) => (None, executor::AuthMethod::None), }; diff --git a/src/helpers/gmail/triage.rs b/src/helpers/gmail/triage.rs index 459a86c..6899d4a 100644 --- a/src/helpers/gmail/triage.rs +++ b/src/helpers/gmail/triage.rs @@ -33,7 +33,7 @@ pub async fn handle_triage(matches: &ArgMatches) -> Result<(), GwsError> { .unwrap_or(crate::formatter::OutputFormat::Table); // Authenticate - let token = auth::get_token(&[GMAIL_SCOPE], None) + let token = auth::get_token(&[GMAIL_SCOPE]) .await .map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?; diff --git a/src/helpers/gmail/watch.rs b/src/helpers/gmail/watch.rs index 1c23038..8640179 100644 --- a/src/helpers/gmail/watch.rs +++ b/src/helpers/gmail/watch.rs @@ -14,10 +14,10 @@ pub(super) async fn handle_watch( let client = crate::client::build_client()?; // Get tokens - let gmail_token = auth::get_token(&[GMAIL_SCOPE], None) + let gmail_token = auth::get_token(&[GMAIL_SCOPE]) .await .context("Failed to get Gmail token")?; - let pubsub_token = auth::get_token(&[PUBSUB_SCOPE], None) + let pubsub_token = auth::get_token(&[PUBSUB_SCOPE]) .await .context("Failed to get Pub/Sub token")?; diff --git a/src/helpers/modelarmor.rs b/src/helpers/modelarmor.rs index 7890213..8ac9fc1 100644 --- a/src/helpers/modelarmor.rs +++ b/src/helpers/modelarmor.rs @@ -250,7 +250,7 @@ pub const CLOUD_PLATFORM_SCOPE: &str = "https://www.googleapis.com/auth/cloud-pl pub async fn sanitize_text(template: &str, text: &str) -> Result { let (body, url) = build_sanitize_request_data(template, text, "sanitizeUserPrompt")?; - let token = auth::get_token(&[CLOUD_PLATFORM_SCOPE], None) + let token = auth::get_token(&[CLOUD_PLATFORM_SCOPE]) .await .context("Failed to get auth token for Model Armor")?; @@ -281,7 +281,7 @@ pub async fn sanitize_text(template: &str, text: &str) -> Result Result<(), GwsError> { - let token = auth::get_token(&[CLOUD_PLATFORM_SCOPE], None) + let token = auth::get_token(&[CLOUD_PLATFORM_SCOPE]) .await .context("Failed to get auth token")?; diff --git a/src/helpers/script.rs b/src/helpers/script.rs index fbd8902..8685afb 100644 --- a/src/helpers/script.rs +++ b/src/helpers/script.rs @@ -103,7 +103,7 @@ TIPS: let body_str = body.to_string(); let scopes: Vec<&str> = update_method.scopes.iter().map(|s| s.as_str()).collect(); - let (token, auth_method) = match auth::get_token(&scopes, None).await { + let (token, auth_method) = match auth::get_token(&scopes).await { Ok(t) => (Some(t), executor::AuthMethod::OAuth), Err(_) => (None, executor::AuthMethod::None), }; diff --git a/src/helpers/sheets.rs b/src/helpers/sheets.rs index d4bf170..5c2a9e9 100644 --- a/src/helpers/sheets.rs +++ b/src/helpers/sheets.rs @@ -106,7 +106,7 @@ TIPS: let (params_str, body_str, scopes) = build_append_request(&config, doc)?; let scope_strs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); - let (token, auth_method) = match auth::get_token(&scope_strs, None).await { + let (token, auth_method) = match auth::get_token(&scope_strs).await { Ok(t) => (Some(t), executor::AuthMethod::OAuth), Err(_) => (None, executor::AuthMethod::None), }; @@ -164,7 +164,7 @@ TIPS: })?; let scope_strs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); - let (token, auth_method) = match auth::get_token(&scope_strs, None).await { + let (token, auth_method) = match auth::get_token(&scope_strs).await { Ok(t) => (Some(t), executor::AuthMethod::OAuth), Err(_) => (None, executor::AuthMethod::None), }; diff --git a/src/helpers/workflows.rs b/src/helpers/workflows.rs index 2b47087..05355d0 100644 --- a/src/helpers/workflows.rs +++ b/src/helpers/workflows.rs @@ -269,7 +269,7 @@ fn format_and_print(value: &Value, matches: &ArgMatches) { async fn handle_standup_report(matches: &ArgMatches) -> Result<(), GwsError> { let cal_scope = "https://www.googleapis.com/auth/calendar.readonly"; let tasks_scope = "https://www.googleapis.com/auth/tasks.readonly"; - let token = auth::get_token(&[cal_scope, tasks_scope], None) + let token = auth::get_token(&[cal_scope, tasks_scope]) .await .map_err(|e| GwsError::Auth(format!("Auth failed: {e}")))?; @@ -364,7 +364,7 @@ async fn handle_standup_report(matches: &ArgMatches) -> Result<(), GwsError> { async fn handle_meeting_prep(matches: &ArgMatches) -> Result<(), GwsError> { let cal_scope = "https://www.googleapis.com/auth/calendar.readonly"; - let token = auth::get_token(&[cal_scope], None) + let token = auth::get_token(&[cal_scope]) .await .map_err(|e| GwsError::Auth(format!("Auth failed: {e}")))?; @@ -446,7 +446,7 @@ async fn handle_meeting_prep(matches: &ArgMatches) -> Result<(), GwsError> { async fn handle_email_to_task(matches: &ArgMatches) -> Result<(), GwsError> { let gmail_scope = "https://www.googleapis.com/auth/gmail.readonly"; let tasks_scope = "https://www.googleapis.com/auth/tasks"; - let token = auth::get_token(&[gmail_scope, tasks_scope], None) + let token = auth::get_token(&[gmail_scope, tasks_scope]) .await .map_err(|e| GwsError::Auth(format!("Auth failed: {e}")))?; @@ -536,7 +536,7 @@ async fn handle_email_to_task(matches: &ArgMatches) -> Result<(), GwsError> { async fn handle_weekly_digest(matches: &ArgMatches) -> Result<(), GwsError> { let cal_scope = "https://www.googleapis.com/auth/calendar.readonly"; let gmail_scope = "https://www.googleapis.com/auth/gmail.readonly"; - let token = auth::get_token(&[cal_scope, gmail_scope], None) + let token = auth::get_token(&[cal_scope, gmail_scope]) .await .map_err(|e| GwsError::Auth(format!("Auth failed: {e}")))?; @@ -618,7 +618,7 @@ async fn handle_weekly_digest(matches: &ArgMatches) -> Result<(), GwsError> { async fn handle_file_announce(matches: &ArgMatches) -> Result<(), GwsError> { let drive_scope = "https://www.googleapis.com/auth/drive.readonly"; let chat_scope = "https://www.googleapis.com/auth/chat.messages.create"; - let token = auth::get_token(&[drive_scope, chat_scope], None) + let token = auth::get_token(&[drive_scope, chat_scope]) .await .map_err(|e| GwsError::Auth(format!("Auth failed: {e}")))?; diff --git a/src/main.rs b/src/main.rs index 98c392b..a448fec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,7 +19,6 @@ //! It supports deep schema validation, OAuth / Service Account authentication, //! interactive prompts, and integration with Model Armor. -mod accounts; mod auth; pub(crate) mod auth_commands; mod client; @@ -58,20 +57,15 @@ async fn main() { async fn run() -> Result<(), GwsError> { let args: Vec = std::env::args().collect(); - // Extract --account flag from anywhere in args (global flag) - // Priority: --account flag > GOOGLE_WORKSPACE_CLI_ACCOUNT env var - let account = extract_global_account(&args) - .or_else(|| std::env::var("GOOGLE_WORKSPACE_CLI_ACCOUNT").ok()); - if args.len() < 2 { print_usage(); return Err(GwsError::Validation( - "No service specified. Usage: gws [--account EMAIL] [sub-resource] [flags]" + "No service specified. Usage: gws [sub-resource] [flags]" .to_string(), )); } - // Find the first non-flag arg (skip --account/--api-version and their values) + // Find the first non-flag arg (skip --api-version and its value) let mut first_arg: Option = None; { let mut skip_next = false; @@ -80,11 +74,11 @@ async fn run() -> Result<(), GwsError> { skip_next = false; continue; } - if a == "--account" || a == "--api-version" { + if a == "--api-version" { skip_next = true; continue; } - if a.starts_with("--account=") || a.starts_with("--api-version=") { + if a.starts_with("--api-version=") { continue; } if !a.starts_with("--") || a.as_str() == "--help" || a.as_str() == "--version" { @@ -93,10 +87,12 @@ async fn run() -> Result<(), GwsError> { } } } - let first_arg = first_arg.ok_or_else(|| GwsError::Validation( - "No service specified. Usage: gws [--account EMAIL] [sub-resource] [flags]" - .to_string(), - ))?; + let first_arg = first_arg.ok_or_else(|| { + GwsError::Validation( + "No service specified. Usage: gws [sub-resource] [flags]" + .to_string(), + ) + })?; // Handle --help and --version at top level if is_help_flag(&first_arg) { @@ -238,7 +234,7 @@ async fn run() -> Result<(), GwsError> { 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 { + let (token, auth_method) = match auth::get_token(&scopes).await { Ok(t) => (Some(t), executor::AuthMethod::OAuth), Err(e) => { // If credentials were found but failed (e.g. decryption error, invalid token), @@ -330,11 +326,11 @@ pub fn filter_args_for_subcommand(args: &[String], service_name: &str) -> Vec Option { - for i in 0..args.len() { - if args[i] == "--account" && i + 1 < args.len() { - return Some(args[i + 1].clone()); - } - if let Some(value) = args[i].strip_prefix("--account=") { - return Some(value.to_string()); - } - } - None -} - fn is_help_flag(arg: &str) -> bool { matches!(arg, "--help" | "-h") } @@ -662,50 +641,6 @@ mod tests { assert_eq!(method.id.as_deref(), Some("drive.files.permissions.get")); } - #[test] - fn test_extract_global_account_present() { - let args = vec![ - "gws".into(), - "--account".into(), - "user@corp.com".into(), - "drive".into(), - "files".into(), - "list".into(), - ]; - assert_eq!( - extract_global_account(&args), - Some("user@corp.com".to_string()) - ); - } - - #[test] - fn test_extract_global_account_absent() { - let args = vec!["gws".into(), "drive".into(), "files".into(), "list".into()]; - assert_eq!(extract_global_account(&args), None); - } - - #[test] - fn test_extract_global_account_at_end_missing_value() { - let args = vec!["gws".into(), "drive".into(), "--account".into()]; - assert_eq!(extract_global_account(&args), None); - } - - #[test] - fn test_filter_args_strips_account() { - let args: Vec = vec![ - "gws".into(), - "drive".into(), - "--account".into(), - "user@corp.com".into(), - "files".into(), - "list".into(), - ]; - let filtered = filter_args_for_subcommand(&args, "drive"); - assert_eq!(filtered, vec!["gws", "files", "list"]); - assert!(!filtered.contains(&"--account".to_string())); - assert!(!filtered.contains(&"user@corp.com".to_string())); - } - #[test] fn test_filter_args_strips_api_version() { let args: Vec = vec![ @@ -720,22 +655,6 @@ mod tests { assert_eq!(filtered, vec!["gws", "files", "list"]); } - #[test] - fn test_filter_args_strips_both_account_and_api_version() { - let args: Vec = vec![ - "gws".into(), - "drive".into(), - "--account".into(), - "a@b.com".into(), - "--api-version".into(), - "v2".into(), - "files".into(), - "list".into(), - ]; - let filtered = filter_args_for_subcommand(&args, "drive"); - assert_eq!(filtered, vec!["gws", "files", "list"]); - } - #[test] fn test_filter_args_no_special_flags() { let args: Vec = vec![ @@ -750,96 +669,6 @@ mod tests { assert_eq!(filtered, vec!["gws", "files", "list", "--format", "table"]); } - #[test] - fn test_extract_global_account_before_service() { - // --account appears before the service name — email should be extracted, not treated as service - let args = vec![ - "gws".into(), - "--account".into(), - "work@corp.com".into(), - "drive".into(), - "files".into(), - "list".into(), - ]; - assert_eq!( - extract_global_account(&args), - Some("work@corp.com".to_string()) - ); - } - - #[test] - fn test_filter_args_account_after_service() { - // --account appears AFTER the service name (the normal position for global flags - // that weren't consumed by extract_global_account) - let args: Vec = vec![ - "gws".into(), - "drive".into(), - "--account".into(), - "work@corp.com".into(), - "files".into(), - "list".into(), - ]; - let filtered = filter_args_for_subcommand(&args, "drive"); - assert!(!filtered.contains(&"--account".to_string())); - assert!(!filtered.contains(&"work@corp.com".to_string())); - assert!(filtered.contains(&"files".to_string())); - } - - #[test] - fn test_extract_global_account_equals_syntax() { - let args = vec![ - "gws".into(), - "--account=work@corp.com".into(), - "drive".into(), - ]; - assert_eq!( - extract_global_account(&args), - Some("work@corp.com".to_string()) - ); - } - - #[test] - fn test_filter_args_account_before_service() { - // --account appears BEFORE the service name (issue #181) - let args: Vec = vec![ - "gws".into(), - "--account".into(), - "work@corp.com".into(), - "drive".into(), - "files".into(), - "list".into(), - ]; - let filtered = filter_args_for_subcommand(&args, "drive"); - assert_eq!(filtered, vec!["gws", "files", "list"]); - } - - #[test] - fn test_filter_args_account_equals_before_service() { - let args: Vec = vec![ - "gws".into(), - "--account=work@corp.com".into(), - "drive".into(), - "files".into(), - "list".into(), - ]; - let filtered = filter_args_for_subcommand(&args, "drive"); - assert_eq!(filtered, vec!["gws", "files", "list"]); - } - - #[test] - fn test_filter_args_strips_account_equals() { - let args: Vec = vec![ - "gws".into(), - "drive".into(), - "--account=a@b.com".into(), - "files".into(), - "list".into(), - ]; - let filtered = filter_args_for_subcommand(&args, "drive"); - 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![ diff --git a/src/mcp_server.rs b/src/mcp_server.rs index 4fb8e3e..acc3c29 100644 --- a/src/mcp_server.rs +++ b/src/mcp_server.rs @@ -814,8 +814,7 @@ async fn execute_mcp_method( }; let scopes: Vec<&str> = crate::select_scope(&method.scopes).into_iter().collect(); - let account = std::env::var("GOOGLE_WORKSPACE_CLI_ACCOUNT").ok(); - let (token, auth_method) = match crate::auth::get_token(&scopes, account.as_deref()).await { + let (token, auth_method) = match crate::auth::get_token(&scopes).await { Ok(t) => (Some(t), crate::executor::AuthMethod::OAuth), Err(e) => { eprintln!( From b601ecaf42ad398592c582269ba3e5d16ee6df92 Mon Sep 17 00:00:00 2001 From: jpoehnelt-bot Date: Thu, 5 Mar 2026 20:36:21 -0700 Subject: [PATCH 2/3] chore: update changeset description --- .changeset/remove-dwd.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/remove-dwd.md b/.changeset/remove-dwd.md index 694464d..6233c9a 100644 --- a/.changeset/remove-dwd.md +++ b/.changeset/remove-dwd.md @@ -2,4 +2,4 @@ "@googleworkspace/cli": minor --- -Remove domain wide delegation support +Remove multi-account, domain-wide delegation, and impersonation support. Removes `gws auth list`, `gws auth default`, `--account` flag, `GOOGLE_WORKSPACE_CLI_ACCOUNT` and `GOOGLE_WORKSPACE_CLI_IMPERSONATED_USER` env vars. From dfa4fb3326dc872594867a740bfe815655464309 Mon Sep 17 00:00:00 2001 From: jpoehnelt-bot Date: Thu, 5 Mar 2026 20:38:03 -0700 Subject: [PATCH 3/3] docs: remove multi-account and DWD references from docs --- .env.example | 6 ------ AGENTS.md | 11 +++++------ README.md | 32 +++++++------------------------- 3 files changed, 12 insertions(+), 37 deletions(-) diff --git a/.env.example b/.env.example index 89dda10..f503815 100644 --- a/.env.example +++ b/.env.example @@ -9,12 +9,6 @@ # Path to OAuth credentials JSON (user or service account) # GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE= -# Default account email for multi-account usage (overridden by --account flag) -# GOOGLE_WORKSPACE_CLI_ACCOUNT= - -# Email of user to impersonate via Domain-Wide Delegation (service accounts only) -# GOOGLE_WORKSPACE_CLI_IMPERSONATED_USER= - # ── OAuth Client ────────────────────────────────────────────────── # OAuth client ID and secret (alternative to saving client_secret.json) # GOOGLE_WORKSPACE_CLI_CLIENT_ID= diff --git a/AGENTS.md b/AGENTS.md index f8b7612..1fe2d65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,13 +46,12 @@ The CLI uses a **two-phase argument parsing** strategy: | File | Purpose | | ------------------------- | ----------------------------------------------------------------------------------------- | -| `src/main.rs` | Entrypoint, two-phase CLI parsing, `--account` global flag extraction, method resolution | +| `src/main.rs` | Entrypoint, two-phase CLI parsing, method resolution | | `src/discovery.rs` | Serde models for Discovery Document + fetch/cache | | `src/services.rs` | Service alias → Discovery API name/version mapping | -| `src/auth.rs` | OAuth2 token acquisition with multi-account support via `accounts.json` registry | -| `src/accounts.rs` | Multi-account registry (`accounts.json`), email normalisation, base64 encoding | -| `src/credential_store.rs` | AES-256-GCM encryption/decryption, per-account credential file paths | -| `src/auth_commands.rs` | `gws auth` subcommands: `login`, `logout`, `list`, `default`, `setup`, `status`, `export` | +| `src/auth.rs` | OAuth2 token acquisition via env vars, encrypted credentials, or ADC | +| `src/credential_store.rs` | AES-256-GCM encryption/decryption of credential files | +| `src/auth_commands.rs` | `gws auth` subcommands: `login`, `logout`, `setup`, `status`, `export` | | `src/commands.rs` | Recursive `clap::Command` builder from Discovery resources | | `src/executor.rs` | HTTP request construction, response handling, schema validation | | `src/schema.rs` | `gws schema` command — introspect API method schemas | @@ -174,7 +173,7 @@ Use these labels to categorize pull requests and issues: |---|---| | `GOOGLE_WORKSPACE_CLI_TOKEN` | Pre-obtained OAuth2 access token (highest priority; bypasses all credential file loading) | | `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` | Path to OAuth credentials JSON (no default; if unset, falls back to credentials secured by the OS Keyring and encrypted in `~/.config/gws/`) | -| `GOOGLE_WORKSPACE_CLI_ACCOUNT` | Default account email for multi-account usage (overridden by `--account` flag) | + | `GOOGLE_APPLICATION_CREDENTIALS` | Standard Google ADC path; used as fallback when no gws-specific credentials are configured | ### Configuration diff --git a/README.md b/README.md index 19ab605..4f2489a 100644 --- a/README.md +++ b/README.md @@ -137,22 +137,6 @@ gws auth login # subsequent scope selection and login > gws auth login -s drive,gmail,sheets > ``` -### Multiple accounts - -You can authenticate with more than one Google account and switch between them: - -```bash -gws auth login --account work@corp.com # login and register an account -gws auth login --account personal@gmail.com - -gws auth list # list registered accounts -gws auth default work@corp.com. # set the default - -gws --account personal@gmail.com drive files list # one-off override -export GOOGLE_WORKSPACE_CLI_ACCOUNT=personal@gmail.com # env var override -``` - -Credentials are stored per-account as `credentials..enc` in `~/.config/gws/`, with an `accounts.json` registry tracking defaults. ### Manual OAuth setup (Google Cloud Console) @@ -222,14 +206,12 @@ export GOOGLE_WORKSPACE_CLI_TOKEN=$(gcloud auth print-access-token) ### Precedence -| Priority | Source | Set via | -| -------- | --------------------------------- | --------------------------------------- | -| 1 | Access token | `GOOGLE_WORKSPACE_CLI_TOKEN` | -| 2 | Credentials file | `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` | -| 3 | Per-account encrypted credentials | `gws auth login --account EMAIL` | -| 4 | Plaintext credentials | `~/.config/gws/credentials.json` | - -Account resolution: `--account` flag > `GOOGLE_WORKSPACE_CLI_ACCOUNT` env var > default in `accounts.json`. +| Priority | Source | Set via | +| -------- | ---------------------- | --------------------------------------- | +| 1 | Access token | `GOOGLE_WORKSPACE_CLI_TOKEN` | +| 2 | Credentials file | `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` | +| 3 | Encrypted credentials | `gws auth login` | +| 4 | Plaintext credentials | `~/.config/gws/credentials.json` | Environment variables can also live in a `.env` file. @@ -362,7 +344,7 @@ All variables are optional. See [`.env.example`](.env.example) for a copy-paste |---|---| | `GOOGLE_WORKSPACE_CLI_TOKEN` | Pre-obtained OAuth2 access token (highest priority) | | `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` | Path to OAuth credentials JSON (user or service account) | -| `GOOGLE_WORKSPACE_CLI_ACCOUNT` | Default account email (overridden by `--account` flag) | + | `GOOGLE_WORKSPACE_CLI_CLIENT_ID` | OAuth client ID (alternative to `client_secret.json`) | | `GOOGLE_WORKSPACE_CLI_CLIENT_SECRET` | OAuth client secret (paired with `CLIENT_ID`) | | `GOOGLE_WORKSPACE_CLI_CONFIG_DIR` | Override config directory (default: `~/.config/gws`) |