diff --git a/.changeset/auth-profiles.md b/.changeset/auth-profiles.md new file mode 100644 index 00000000..ff95f924 --- /dev/null +++ b/.changeset/auth-profiles.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": minor +--- + +Add named auth profiles with isolated credentials and token caches. diff --git a/crates/google-workspace-cli/src/auth.rs b/crates/google-workspace-cli/src/auth.rs index 9d8847e4..3f793f2e 100644 --- a/crates/google-workspace-cli/src/auth.rs +++ b/crates/google-workspace-cli/src/auth.rs @@ -220,10 +220,9 @@ pub async fn get_token(scopes: &[&str]) -> anyhow::Result { } let creds_file = std::env::var("GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE").ok(); - let config_dir = crate::auth_commands::config_dir(); 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 default_path = crate::auth_commands::plain_credentials_path(); + let token_cache = crate::auth_commands::token_cache_path(); let creds = load_credentials_inner(creds_file.as_deref(), &enc_path, &default_path).await?; get_token_inner(scopes, creds, &token_cache).await diff --git a/crates/google-workspace-cli/src/auth_commands.rs b/crates/google-workspace-cli/src/auth_commands.rs index d7571e74..cd86e5af 100644 --- a/crates/google-workspace-cli/src/auth_commands.rs +++ b/crates/google-workspace-cli/src/auth_commands.rs @@ -16,6 +16,7 @@ use std::collections::HashSet; use std::io::{BufRead, BufReader, Write}; use std::net::TcpListener; use std::path::{Path, PathBuf}; +use std::sync::OnceLock; use serde::Deserialize; use serde_json::json; @@ -334,15 +335,63 @@ pub fn config_dir() -> PathBuf { primary } -fn plain_credentials_path() -> PathBuf { +static ACTIVE_PROFILE: OnceLock = OnceLock::new(); + +pub(crate) const DEFAULT_PROFILE: &str = "default"; + +pub(crate) fn validate_profile_name(name: &str) -> Result<(), GwsError> { + if name.is_empty() + || name.starts_with('-') + || !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + return Err(GwsError::Validation( + "Profile names must start with a letter, number, or '_' and may only contain letters, numbers, '-' and '_'".to_string(), + )); + } + Ok(()) +} + +pub(crate) fn set_active_profile(profile: Option) -> Result<(), GwsError> { + if let Some(profile) = profile { + validate_profile_name(&profile)?; + let _ = ACTIVE_PROFILE.set(profile); + } + Ok(()) +} + +pub(crate) fn active_profile() -> String { + ACTIVE_PROFILE + .get() + .cloned() + .or_else(|| std::env::var("GOOGLE_WORKSPACE_CLI_PROFILE").ok()) + .filter(|p| validate_profile_name(p).is_ok()) + .unwrap_or_else(|| DEFAULT_PROFILE.to_string()) +} + +pub(crate) fn profile_dir() -> PathBuf { + let profile = active_profile(); + if profile == DEFAULT_PROFILE { + config_dir() + } else { + config_dir().join("profiles").join(profile) + } +} + +pub(crate) fn plain_credentials_path() -> PathBuf { if let Ok(path) = std::env::var("GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE") { return PathBuf::from(path); } - config_dir().join("credentials.json") + profile_dir().join("credentials.json") } -fn token_cache_path() -> PathBuf { - config_dir().join("token_cache.json") +pub(crate) fn token_cache_path() -> PathBuf { + profile_dir().join("token_cache.json") +} + +pub(crate) fn service_account_token_cache_path() -> PathBuf { + profile_dir().join("sa_token_cache.json") } /// Which scope set to use for login. @@ -644,9 +693,15 @@ async fn handle_login_inner( let enc_path = credential_store::save_encrypted(&creds_str) .map_err(|e| GwsError::Auth(format!("Failed to encrypt credentials: {e}")))?; + for path in [token_cache_path(), service_account_token_cache_path()] { + let _ = std::fs::remove_file(path); + } + crate::timezone::invalidate_cache(); + let output = json!({ "status": "success", "message": "Authentication successful. Encrypted credentials saved.", + "profile": active_profile(), "account": actual_email.as_deref().unwrap_or("(unknown)"), "credentials_file": enc_path.display().to_string(), "encryption": "AES-256-GCM (key in OS keyring or local `.encryption_key`; set GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND=file for headless)", @@ -1223,6 +1278,8 @@ async fn handle_status() -> Result<(), GwsError> { }; let mut output = json!({ + "profile": active_profile(), + "profile_dir": profile_dir().display().to_string(), "auth_method": auth_method, "storage": storage, "keyring_backend": credential_store::active_backend_name(), @@ -1457,7 +1514,7 @@ 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"); + let sa_token_cache = service_account_token_cache_path(); let mut removed = Vec::new(); @@ -1476,11 +1533,13 @@ fn handle_logout() -> Result<(), GwsError> { let output = if removed.is_empty() { json!({ "status": "success", + "profile": active_profile(), "message": "No credentials found to remove.", }) } else { json!({ "status": "success", + "profile": active_profile(), "message": "Logged out. All credentials and token caches removed.", "removed": removed, }) @@ -1900,6 +1959,43 @@ mod tests { assert!(path.starts_with(config_dir())); } + #[test] + #[serial_test::serial] + fn profile_dir_defaults_to_config_dir() { + unsafe { + std::env::remove_var("GOOGLE_WORKSPACE_CLI_PROFILE"); + } + assert_eq!(active_profile(), DEFAULT_PROFILE); + assert_eq!(profile_dir(), config_dir()); + } + + #[test] + #[serial_test::serial] + fn named_profile_uses_isolated_paths() { + unsafe { + std::env::set_var("GOOGLE_WORKSPACE_CLI_PROFILE", "work"); + } + + let dir = profile_dir(); + assert!(dir.ends_with("profiles/work") || dir.ends_with(r"profiles\work")); + assert!(plain_credentials_path().starts_with(&dir)); + assert!(token_cache_path().starts_with(&dir)); + assert!(service_account_token_cache_path().starts_with(&dir)); + + unsafe { + std::env::remove_var("GOOGLE_WORKSPACE_CLI_PROFILE"); + } + } + + #[test] + fn validate_profile_name_rejects_traversal() { + assert!(validate_profile_name("work").is_ok()); + assert!(validate_profile_name("../work").is_err()); + assert!(validate_profile_name("work.profile").is_err()); + assert!(validate_profile_name("--help").is_err()); + assert!(validate_profile_name("").is_err()); + } + #[tokio::test] async fn handle_auth_command_empty_args_prints_usage() { let args: Vec = vec![]; diff --git a/crates/google-workspace-cli/src/credential_store.rs b/crates/google-workspace-cli/src/credential_store.rs index ffc6db25..dbe428e4 100644 --- a/crates/google-workspace-cli/src/credential_store.rs +++ b/crates/google-workspace-cli/src/credential_store.rs @@ -424,7 +424,7 @@ pub fn active_backend_name() -> &'static str { /// Returns the path for encrypted credentials. pub fn encrypted_credentials_path() -> PathBuf { - crate::auth_commands::config_dir().join("credentials.enc") + crate::auth_commands::profile_dir().join("credentials.enc") } /// Saves credentials JSON to an encrypted file. diff --git a/crates/google-workspace-cli/src/main.rs b/crates/google-workspace-cli/src/main.rs index 41dcc1e1..be19db87 100644 --- a/crates/google-workspace-cli/src/main.rs +++ b/crates/google-workspace-cli/src/main.rs @@ -61,6 +61,7 @@ async fn main() { async fn run() -> Result<(), GwsError> { let args: Vec = std::env::args().collect(); + auth_commands::set_active_profile(extract_profile_arg(&args)?)?; if args.len() < 2 { print_usage(); @@ -70,29 +71,8 @@ async fn run() -> Result<(), GwsError> { )); } - // Find the first non-flag arg (skip --api-version and its value) - let mut first_arg: Option = None; - { - let mut skip_next = false; - for a in args.iter().skip(1) { - if skip_next { - skip_next = false; - continue; - } - if a == "--api-version" { - skip_next = true; - continue; - } - if a.starts_with("--api-version=") { - continue; - } - if !a.starts_with("--") || a.as_str() == "--help" || a.as_str() == "--version" { - first_arg = Some(a.clone()); - break; - } - } - } - let first_arg = first_arg.ok_or_else(|| { + // Find the first non-flag arg, skipping top-level flags and their values. + let (first_arg, first_arg_index) = find_first_command_arg(&args).ok_or_else(|| { GwsError::Validation( "No service specified. Usage: gws [sub-resource] [flags]" .to_string(), @@ -113,28 +93,34 @@ async fn run() -> Result<(), GwsError> { // Handle the `schema` command if first_arg == "schema" { - if args.len() < 3 { + let schema_args = strip_global_flags(&args[first_arg_index + 1..]); + if schema_args.is_empty() { return Err(GwsError::Validation( "Usage: gws schema (e.g., gws schema drive.files.list) [--resolve-refs]" .to_string(), )); } - let resolve_refs = args.iter().any(|arg| arg == "--resolve-refs"); - // Remove the flag if it exists so it doesn't mess up path parsing, or just pass the path - // The path is args[2], flags might follow. - let path = &args[2]; + let resolve_refs = schema_args.iter().any(|arg| arg == "--resolve-refs"); + let path = schema_args + .iter() + .find(|arg| arg.as_str() != "--resolve-refs") + .ok_or_else(|| { + GwsError::Validation( + "Usage: gws schema [--resolve-refs]".to_string(), + ) + })?; return schema::handle_schema_command(path, resolve_refs).await; } // Handle the `generate-skills` command if first_arg == "generate-skills" { - let gen_args: Vec = args.iter().skip(2).cloned().collect(); + let gen_args = strip_global_flags(&args[first_arg_index + 1..]); return generate_skills::handle_generate_skills(&gen_args).await; } // Handle the `auth` command if first_arg == "auth" { - let auth_args: Vec = args.iter().skip(2).cloned().collect(); + let auth_args = strip_global_flags(&args[first_arg_index + 1..]); return auth_commands::handle_auth_command(&auth_args).await; } @@ -318,6 +304,74 @@ fn parse_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationCo } } +fn is_global_value_flag(arg: &str) -> bool { + matches!(arg, "--api-version" | "--profile") +} + +fn is_global_equals_flag(arg: &str) -> bool { + arg.starts_with("--api-version=") || arg.starts_with("--profile=") +} + +fn find_first_command_arg(args: &[String]) -> Option<(String, usize)> { + let mut skip_next = false; + for (idx, a) in args.iter().enumerate().skip(1) { + if skip_next { + skip_next = false; + continue; + } + if is_global_value_flag(a) { + skip_next = true; + continue; + } + if is_global_equals_flag(a) { + continue; + } + if !a.starts_with("--") || is_help_flag(a) || is_version_flag(a) { + return Some((a.clone(), idx)); + } + } + None +} + +fn strip_global_flags(args: &[String]) -> Vec { + let mut out = Vec::new(); + let mut skip_next = false; + for arg in args { + if skip_next { + skip_next = false; + continue; + } + if is_global_value_flag(arg) { + skip_next = true; + continue; + } + if is_global_equals_flag(arg) { + continue; + } + out.push(arg.clone()); + } + out +} + +fn extract_profile_arg(args: &[String]) -> Result, GwsError> { + let mut profile = std::env::var("GOOGLE_WORKSPACE_CLI_PROFILE").ok(); + let mut iter = args.iter().skip(1); + while let Some(arg) = iter.next() { + if arg == "--profile" { + let value = iter.next().ok_or_else(|| { + GwsError::Validation("--profile requires a profile name".to_string()) + })?; + profile = Some(value.clone()); + } else if let Some(value) = arg.strip_prefix("--profile=") { + profile = Some(value.to_string()); + } + } + if let Some(ref value) = profile { + auth_commands::validate_profile_name(value)?; + } + Ok(profile) +} + pub fn parse_service_and_version( args: &[String], first_arg: &str, @@ -354,11 +408,11 @@ pub fn filter_args_for_subcommand(args: &[String], service_name: &str) -> Vec Output file path for binary responses"); println!(" --format Output format: json (default), table, yaml, csv"); println!(" --api-version Override the API version (e.g., v2, v3)"); + println!(" --profile Use a named auth profile (default: default)"); println!(" --page-all Auto-paginate, one JSON line per page (NDJSON)"); println!(" --page-limit Max pages to fetch with --page-all (default: 10)"); println!(" --page-delay Delay between pages in ms (default: 100)"); @@ -477,6 +532,7 @@ fn print_usage() { println!("ENVIRONMENT:"); println!(" GOOGLE_WORKSPACE_CLI_TOKEN Pre-obtained OAuth2 access token (highest priority)"); println!(" GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE Path to OAuth credentials JSON file"); + println!(" GOOGLE_WORKSPACE_CLI_PROFILE Named auth profile to use"); println!(" GOOGLE_WORKSPACE_CLI_CLIENT_ID OAuth client ID (for gws auth login)"); println!( " GOOGLE_WORKSPACE_CLI_CLIENT_SECRET OAuth client secret (for gws auth login)" @@ -696,6 +752,60 @@ mod tests { assert_eq!(filtered, vec!["gws", "files", "list"]); } + #[test] + fn test_filter_args_strips_profile() { + let args: Vec = vec![ + "gws".into(), + "--profile".into(), + "work".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_first_command_skips_profile() { + let args: Vec = vec![ + "gws".into(), + "--profile".into(), + "work".into(), + "auth".into(), + "status".into(), + ]; + assert_eq!(find_first_command_arg(&args), Some(("auth".to_string(), 3))); + } + + #[test] + fn test_strip_global_flags_after_command() { + let args: Vec = vec![ + "status".into(), + "--profile".into(), + "work".into(), + "--format".into(), + "json".into(), + ]; + assert_eq!( + strip_global_flags(&args), + vec!["status", "--format", "json"] + ); + } + + #[test] + #[serial_test::serial] + fn test_extract_profile_arg() { + unsafe { + std::env::remove_var("GOOGLE_WORKSPACE_CLI_PROFILE"); + } + let args: Vec = vec!["gws".into(), "--profile=work".into(), "auth".into()]; + assert_eq!( + extract_profile_arg(&args).unwrap(), + Some("work".to_string()) + ); + } + #[test] fn test_filter_args_no_special_flags() { let args: Vec = vec![ diff --git a/crates/google-workspace-cli/src/timezone.rs b/crates/google-workspace-cli/src/timezone.rs index b7cd6577..8977da70 100644 --- a/crates/google-workspace-cli/src/timezone.rs +++ b/crates/google-workspace-cli/src/timezone.rs @@ -32,7 +32,7 @@ const CACHE_TTL_SECS: u64 = 86400; /// Returns the path to the timezone cache file. fn cache_path() -> PathBuf { - crate::auth_commands::config_dir().join(CACHE_FILENAME) + crate::auth_commands::profile_dir().join(CACHE_FILENAME) } /// Remove the cached timezone file. Called on auth login/logout to