diff --git a/.changeset/add-profile-support.md b/.changeset/add-profile-support.md new file mode 100644 index 00000000..cf63f97d --- /dev/null +++ b/.changeset/add-profile-support.md @@ -0,0 +1,7 @@ +--- +"@googleworkspace/cli": minor +--- + +feat(auth): add named profile support for multiple Google accounts + +Adds `gws auth profile` subcommands (list, show, create, switch, delete) and a global `--profile` flag so users can authenticate multiple Google accounts without re-authenticating. Each profile gets its own encrypted credentials, token cache, and encryption key under `~/.config/gws/profiles//`. Existing single-account installs are automatically migrated to the `default` profile on first use. diff --git a/crates/google-workspace-cli/src/auth.rs b/crates/google-workspace-cli/src/auth.rs index b602d840..c7778579 100644 --- a/crates/google-workspace-cli/src/auth.rs +++ b/crates/google-workspace-cli/src/auth.rs @@ -149,8 +149,8 @@ impl AccessTokenProvider for FakeTokenProvider { /// 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. Encrypted credentials at `~/.config/gws/credentials.enc` -/// 3. Plaintext credentials at `~/.config/gws/credentials.json` (User only) +/// 2. Encrypted credentials in the active profile directory +/// 3. Plaintext credentials in the active profile directory (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` @@ -163,27 +163,50 @@ pub async fn get_token(scopes: &[&str]) -> anyhow::Result { } } + // Env var overrides bypass profiles entirely 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 creds = load_credentials_inner(creds_file.as_deref(), &enc_path, &default_path).await?; - get_token_inner(scopes, creds, &token_cache).await + // Resolve the active profile + let profile = crate::profile::resolve_active_profile(None, &config_dir).unwrap_or_else(|_| { + crate::profile::ProfileName::new(crate::profile::DEFAULT_PROFILE) + .expect("'default' is always a valid profile name") + }); + let profile_dir = crate::profile::profile_dir(&config_dir, &profile); + let key_file = profile_dir.join(".encryption_key"); + + let enc_path = credential_store::encrypted_credentials_path_for_profile(&config_dir, &profile); + let default_path = profile_dir.join("credentials.json"); + let token_cache = profile_dir.join("token_cache.json"); + + let creds = load_credentials_inner( + creds_file.as_deref(), + &enc_path, + &default_path, + Some((&key_file, profile.as_str())), + ) + .await?; + get_token_inner(scopes, creds, &token_cache, &key_file, profile.as_str()).await } async fn get_token_inner( scopes: &[&str], creds: Credential, token_cache_path: &std::path::Path, + key_file: &std::path::Path, + profile: &str, ) -> anyhow::Result { match creds { Credential::AuthorizedUser(secret) => { let auth = yup_oauth2::AuthorizedUserAuthenticator::builder(secret) - .with_storage(Box::new(crate::token_storage::EncryptedTokenStorage::new( - token_cache_path.to_path_buf(), - ))) + .with_storage(Box::new( + crate::token_storage::EncryptedTokenStorage::new_with_profile( + token_cache_path.to_path_buf(), + key_file.to_path_buf(), + profile.to_string(), + ), + )) .build() .await .context("Failed to build authorized user authenticator")?; @@ -200,9 +223,14 @@ async fn get_token_inner( .map(|f| f.to_string_lossy().to_string()) .unwrap_or_else(|| "token_cache.json".to_string()); let sa_cache = token_cache_path.with_file_name(format!("sa_{tc_filename}")); - let builder = yup_oauth2::ServiceAccountAuthenticator::builder(key).with_storage( - Box::new(crate::token_storage::EncryptedTokenStorage::new(sa_cache)), - ); + let builder = + yup_oauth2::ServiceAccountAuthenticator::builder(key).with_storage(Box::new( + crate::token_storage::EncryptedTokenStorage::new_with_profile( + sa_cache, + key_file.to_path_buf(), + profile.to_string(), + ), + )); let auth = builder .build() @@ -257,6 +285,7 @@ async fn load_credentials_inner( env_file: Option<&str>, enc_path: &std::path::Path, default_path: &std::path::Path, + profile_key: Option<(&std::path::Path, &str)>, ) -> anyhow::Result { // 1. Explicit env var — plaintext file (User or Service Account) if let Some(path) = env_file { @@ -274,7 +303,12 @@ async fn load_credentials_inner( // 2. Encrypted credentials if enc_path.exists() { - match credential_store::load_encrypted_from_path(enc_path) { + let load_result = if let Some((key_file, profile)) = profile_key { + credential_store::load_encrypted_for_profile(enc_path, key_file, profile) + } else { + credential_store::load_encrypted_from_path(enc_path) + }; + match load_result { Ok(json_str) => { return parse_credential_file(enc_path, &json_str).await; } @@ -411,6 +445,7 @@ mod tests { None, &PathBuf::from("/does/not/exist1"), &PathBuf::from("/does/not/exist2"), + None, ) .await; @@ -442,6 +477,7 @@ mod tests { None, &PathBuf::from("/missing/enc"), &PathBuf::from("/missing/plain"), + None, ) .await; @@ -479,6 +515,7 @@ mod tests { None, &PathBuf::from("/missing/enc"), &PathBuf::from("/missing/plain"), + None, ) .await; @@ -504,6 +541,7 @@ mod tests { None, &PathBuf::from("/missing/enc"), &PathBuf::from("/missing/plain"), + None, ) .await; @@ -521,6 +559,7 @@ mod tests { Some("/does/not/exist"), &PathBuf::from("/also/missing"), &PathBuf::from("/still/missing"), + None, ) .await; assert!(err.is_err()); @@ -542,6 +581,7 @@ mod tests { Some(file.path().to_str().unwrap()), &PathBuf::from("/also/missing"), &PathBuf::from("/still/missing"), + None, ) .await .unwrap(); @@ -574,6 +614,7 @@ mod tests { Some(file.path().to_str().unwrap()), &PathBuf::from("/also/missing"), &PathBuf::from("/still/missing"), + None, ) .await .unwrap(); @@ -597,7 +638,7 @@ mod tests { }"#; file.write_all(json.as_bytes()).unwrap(); - let res = load_credentials_inner(None, &PathBuf::from("/also/missing"), file.path()) + let res = load_credentials_inner(None, &PathBuf::from("/also/missing"), file.path(), None) .await .unwrap(); @@ -650,7 +691,7 @@ mod tests { let encrypted = crate::credential_store::encrypt(json.as_bytes()).unwrap(); std::fs::write(&enc_path, &encrypted).unwrap(); - let res = load_credentials_inner(None, &enc_path, &PathBuf::from("/does/not/exist")) + let res = load_credentials_inner(None, &enc_path, &PathBuf::from("/does/not/exist"), None) .await .unwrap(); @@ -688,7 +729,7 @@ mod tests { std::fs::write(&enc_path, &encrypted).unwrap(); std::fs::write(&plain_path, plain_json).unwrap(); - let res = load_credentials_inner(None, &enc_path, &plain_path) + let res = load_credentials_inner(None, &enc_path, &plain_path, None) .await .unwrap(); @@ -722,7 +763,7 @@ mod tests { assert!(enc_path.exists()); let result = - load_credentials_inner(None, &enc_path, &PathBuf::from("/does/not/exist")).await; + load_credentials_inner(None, &enc_path, &PathBuf::from("/does/not/exist"), None).await; // Should fall through to "No credentials found" (not a decryption error). assert!(result.is_err()); @@ -760,7 +801,7 @@ mod tests { }"#; tokio::fs::write(&plain_path, plain_json).await.unwrap(); - let res = load_credentials_inner(None, &enc_path, &plain_path) + let res = load_credentials_inner(None, &enc_path, &plain_path, None) .await .unwrap(); @@ -791,6 +832,7 @@ mod tests { None, &PathBuf::from("/does/not/exist1"), &PathBuf::from("/does/not/exist2"), + None, ) .await; diff --git a/crates/google-workspace-cli/src/auth_commands.rs b/crates/google-workspace-cli/src/auth_commands.rs index 9349eae8..1c785102 100644 --- a/crates/google-workspace-cli/src/auth_commands.rs +++ b/crates/google-workspace-cli/src/auth_commands.rs @@ -119,6 +119,7 @@ pub fn config_dir() -> PathBuf { primary } +#[allow(dead_code)] fn plain_credentials_path() -> PathBuf { if let Ok(path) = std::env::var("GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE") { return PathBuf::from(path); @@ -126,6 +127,7 @@ fn plain_credentials_path() -> PathBuf { config_dir().join("credentials.json") } +#[allow(dead_code)] fn token_cache_path() -> PathBuf { config_dir().join("token_cache.json") } @@ -211,10 +213,47 @@ fn auth_command() -> clap::Command { ), ) .subcommand(clap::Command::new("logout").about("Clear saved credentials and token cache")) + .subcommand( + clap::Command::new("profile") + .about("Manage authentication profiles for multiple accounts") + .subcommand_required(true) + .subcommand(clap::Command::new("list").about("List all profiles")) + .subcommand(clap::Command::new("show").about("Show the active profile name")) + .subcommand( + clap::Command::new("create") + .about("Create a new profile") + .arg( + clap::Arg::new("name") + .required(true) + .help("Profile name (lowercase alphanumeric, '_', '-')"), + ), + ) + .subcommand( + clap::Command::new("switch") + .about("Set the active profile") + .arg( + clap::Arg::new("name") + .required(true) + .help("Profile name to switch to"), + ), + ) + .subcommand( + clap::Command::new("delete") + .about("Delete a profile and all its credentials") + .arg( + clap::Arg::new("name") + .required(true) + .help("Profile name to delete"), + ), + ), + ) } /// Handle `gws auth `. -pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> { +pub async fn handle_auth_command( + args: &[String], + profile_override: Option<&str>, +) -> Result<(), GwsError> { let matches = match auth_command() .try_get_matches_from(std::iter::once("auth".to_string()).chain(args.iter().cloned())) { @@ -231,11 +270,20 @@ pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> { Err(e) => return Err(GwsError::Validation(e.to_string())), }; + // Resolve profile for commands that need it (login, status, logout, export). + // Profile subcommand handles its own resolution. + let base_dir = config_dir(); + + // Run migration if needed (one-time flat → profiles/ layout) + crate::profile::migrate_to_profiles(&base_dir).await?; + + let profile = crate::profile::resolve_active_profile(profile_override, &base_dir)?; + match matches.subcommand() { Some(("login", sub_m)) => { let (scope_mode, services_filter) = parse_login_args(sub_m); - handle_login_inner(scope_mode, services_filter).await + handle_login_inner(scope_mode, services_filter, &base_dir, &profile).await } Some(("setup", sub_m)) => { // Collect remaining args and delegate to setup's own clap parser. @@ -245,12 +293,13 @@ pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> { .unwrap_or_default(); crate::setup::run_setup(&setup_args).await } - Some(("status", _)) => handle_status().await, + Some(("status", _)) => handle_status(&base_dir, &profile).await, Some(("export", sub_m)) => { let unmasked = sub_m.get_flag("unmasked"); - handle_export(unmasked).await + handle_export(unmasked, &base_dir, &profile).await } - Some(("logout", _)) => handle_logout(), + Some(("logout", _)) => handle_logout(&base_dir, &profile).await, + Some(("profile", sub_m)) => handle_profile_command(sub_m).await, _ => { // No subcommand → print help auth_command() @@ -319,7 +368,12 @@ pub async fn run_login(args: &[String]) -> Result<(), GwsError> { let (scope_mode, services_filter) = parse_login_args(&matches); - handle_login_inner(scope_mode, services_filter).await + // Default profile when called from setup.rs (no --profile context) + let base_dir = config_dir(); + let profile = crate::profile::resolve_active_profile(None, &base_dir)?; + crate::profile::migrate_to_profiles(&base_dir).await?; + + handle_login_inner(scope_mode, services_filter, &base_dir, &profile).await } /// Custom delegate that prints the OAuth URL on its own line for easy copying. /// Optionally includes `login_hint` in the URL for account pre-selection. @@ -361,7 +415,12 @@ impl yup_oauth2::authenticator_delegate::InstalledFlowDelegate for CliFlowDelega async fn handle_login_inner( scope_mode: ScopeMode, services_filter: Option>, + base_dir: &std::path::Path, + profile: &crate::profile::ProfileName, ) -> Result<(), GwsError> { + // Ensure profile directory exists + let profile_dir = crate::profile::ensure_profile_dir(base_dir, profile).await?; + let key_file = profile_dir.join(".encryption_key"); // Resolve client_id and client_secret: // 1. Env vars (highest priority) // 2. Saved client_secret.json from `gws auth setup` or manual download @@ -408,24 +467,22 @@ async fn handle_login_inner( } // Use a temp file for yup-oauth2's token persistence, then encrypt it - let temp_path = config_dir().join("credentials.tmp"); + let temp_path = profile_dir.join("credentials.tmp"); // Always start fresh — delete any stale temp cache from prior login attempts. let _ = std::fs::remove_file(&temp_path); - // Ensure config directory exists - if let Some(parent) = temp_path.parent() { - std::fs::create_dir_all(parent) - .map_err(|e| GwsError::Validation(format!("Failed to create config directory: {e}")))?; - } - let auth = yup_oauth2::InstalledFlowAuthenticator::builder( secret, yup_oauth2::InstalledFlowReturnMethod::HTTPRedirect, ) - .with_storage(Box::new(crate::token_storage::EncryptedTokenStorage::new( - temp_path.clone(), - ))) + .with_storage(Box::new( + crate::token_storage::EncryptedTokenStorage::new_with_profile( + temp_path.clone(), + key_file.clone(), + profile.as_str().to_string(), + ), + )) .force_account_selection(true) // Adds prompt=consent so Google always returns a refresh_token .flow_delegate(Box::new(CliFlowDelegate { login_hint: None })) .build() @@ -444,7 +501,10 @@ async fn handle_login_inner( // EncryptedTokenStorage stores data encrypted, so we must decrypt first. let token_data = std::fs::read(&temp_path) .ok() - .and_then(|bytes| crate::credential_store::decrypt(&bytes).ok()) + .and_then(|bytes| { + crate::credential_store::decrypt_for_profile(&bytes, &key_file, profile.as_str()) + .ok() + }) .and_then(|decrypted| String::from_utf8(decrypted).ok()) .unwrap_or_default(); let refresh_token = extract_refresh_token(&token_data).ok_or_else(|| { @@ -470,9 +530,15 @@ async fn handle_login_inner( let access_token = token.token().unwrap_or_default(); let actual_email = fetch_userinfo_email(access_token).await; - // Save encrypted credentials - let enc_path = credential_store::save_encrypted(&creds_str) - .map_err(|e| GwsError::Auth(format!("Failed to encrypt credentials: {e}")))?; + // Save encrypted credentials to profile-specific path + let enc_path = credential_store::encrypted_credentials_path_for_profile(base_dir, profile); + credential_store::save_encrypted_for_profile( + &creds_str, + &enc_path, + &key_file, + profile.as_str(), + ) + .map_err(|e| GwsError::Auth(format!("Failed to encrypt credentials: {e}")))?; // Clean up temp file let _ = std::fs::remove_file(&temp_path); @@ -480,6 +546,7 @@ async fn handle_login_inner( let output = json!({ "status": "success", "message": "Authentication successful. Encrypted credentials saved.", + "profile": profile.as_str(), "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)", @@ -520,15 +587,21 @@ async fn fetch_userinfo_email(access_token: &str) -> Option { .map(|s| s.to_string()) } -async fn handle_export(unmasked: bool) -> Result<(), GwsError> { - let enc_path = credential_store::encrypted_credentials_path(); +async fn handle_export( + unmasked: bool, + base_dir: &std::path::Path, + profile: &crate::profile::ProfileName, +) -> Result<(), GwsError> { + let enc_path = credential_store::encrypted_credentials_path_for_profile(base_dir, profile); if !enc_path.exists() { - return Err(GwsError::Auth( - "No encrypted credentials found. Run 'gws auth login' first.".to_string(), - )); + return Err(GwsError::Auth(format!( + "No encrypted credentials found for profile '{}'. Run 'gws auth login' first.", + profile.as_str() + ))); } - match credential_store::load_encrypted() { + let key_file = crate::profile::profile_dir(base_dir, profile).join(".encryption_key"); + match credential_store::load_encrypted_for_profile(&enc_path, &key_file, profile.as_str()) { Ok(contents) => { if unmasked { println!("{contents}"); @@ -1039,10 +1112,15 @@ fn run_simple_scope_picker(services_filter: Option<&HashSet>) -> Option< } } -async fn handle_status() -> Result<(), GwsError> { - let plain_path = plain_credentials_path(); - let enc_path = credential_store::encrypted_credentials_path(); - let token_cache = token_cache_path(); +async fn handle_status( + base_dir: &std::path::Path, + profile: &crate::profile::ProfileName, +) -> Result<(), GwsError> { + let profile_dir = crate::profile::profile_dir(base_dir, profile); + let enc_path = credential_store::encrypted_credentials_path_for_profile(base_dir, profile); + let plain_path = profile_dir.join("credentials.json"); + let token_cache = profile_dir.join("token_cache.json"); + let key_file = profile_dir.join(".encryption_key"); let has_encrypted = enc_path.exists(); let has_plain = plain_path.exists(); @@ -1063,6 +1141,7 @@ async fn handle_status() -> Result<(), GwsError> { }; let mut output = json!({ + "profile": profile.as_str(), "auth_method": auth_method, "storage": storage, "keyring_backend": credential_store::active_backend_name(), @@ -1129,7 +1208,11 @@ async fn handle_status() -> Result<(), GwsError> { // Skip real credential/network access in test builds if !cfg!(test) { if has_encrypted { - match credential_store::load_encrypted() { + match credential_store::load_encrypted_for_profile( + &enc_path, + &key_file, + profile.as_str(), + ) { Ok(contents) => { if let Ok(creds) = serde_json::from_str::(&contents) { if let Some(client_id) = creds.get("client_id").and_then(|v| v.as_str()) { @@ -1187,7 +1270,8 @@ async fn handle_status() -> Result<(), GwsError> { // Skip all network calls and subprocess spawning in test builds if !cfg!(test) { let creds_json_str = if has_encrypted { - credential_store::load_encrypted().ok() + credential_store::load_encrypted_for_profile(&enc_path, &key_file, profile.as_str()) + .ok() } else if has_plain { tokio::fs::read_to_string(&plain_path).await.ok() } else { @@ -1290,35 +1374,44 @@ async fn handle_status() -> Result<(), GwsError> { Ok(()) } -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"); +async fn handle_logout( + base_dir: &std::path::Path, + profile: &crate::profile::ProfileName, +) -> Result<(), GwsError> { + let profile_dir = crate::profile::profile_dir(base_dir, profile); + let enc_path = credential_store::encrypted_credentials_path_for_profile(base_dir, profile); + let plain_path = profile_dir.join("credentials.json"); + let token_cache = profile_dir.join("token_cache.json"); + let sa_token_cache = profile_dir.join("sa_token_cache.json"); let mut removed = Vec::new(); 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()); + // Warn but don't fail on individual file removal errors (lesson from previous PR) + match std::fs::remove_file(path) { + Ok(()) => removed.push(path.display().to_string()), + Err(e) => { + eprintln!("Warning: failed to remove {}: {e}", path.display()); + } + } } } // Invalidate cached account timezone (may belong to old account) - crate::timezone::invalidate_cache(); + crate::timezone::invalidate_cache_for_profile(base_dir, profile); let output = if removed.is_empty() { json!({ "status": "success", - "message": "No credentials found to remove.", + "profile": profile.as_str(), + "message": format!("No credentials found to remove for profile '{}'.", profile.as_str()), }) } else { json!({ "status": "success", - "message": "Logged out. All credentials and token caches removed.", + "profile": profile.as_str(), + "message": format!("Logged out of profile '{}'. All credentials and token caches removed.", profile.as_str()), "removed": removed, }) }; @@ -1330,6 +1423,86 @@ fn handle_logout() -> Result<(), GwsError> { Ok(()) } +/// Handle `gws auth profile `. +async fn handle_profile_command(matches: &clap::ArgMatches) -> Result<(), GwsError> { + let base_dir = config_dir(); + + match matches.subcommand() { + Some(("list", _)) => { + let profiles = crate::profile::list_profiles(&base_dir).await?; + let items: Vec = profiles + .iter() + .map(|(name, active)| { + json!({ + "name": name, + "active": active, + }) + }) + .collect(); + println!( + "{}", + serde_json::to_string_pretty(&json!({ "profiles": items })).unwrap_or_default() + ); + Ok(()) + } + Some(("show", _)) => { + let profile = crate::profile::resolve_active_profile(None, &base_dir)?; + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "active_profile": profile.as_str(), + })) + .unwrap_or_default() + ); + Ok(()) + } + Some(("create", sub_m)) => { + let name = sub_m.get_one::("name").expect("name is required"); + let profile = crate::profile::ProfileName::new(name)?; + let dir = crate::profile::create_profile(&base_dir, &profile).await?; + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "status": "success", + "message": format!("Profile '{}' created.", profile.as_str()), + "path": dir.display().to_string(), + })) + .unwrap_or_default() + ); + Ok(()) + } + Some(("switch", sub_m)) => { + let name = sub_m.get_one::("name").expect("name is required"); + let profile = crate::profile::ProfileName::new(name)?; + crate::profile::set_active_profile(&base_dir, &profile).await?; + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "status": "success", + "message": format!("Switched to profile '{}'.", profile.as_str()), + })) + .unwrap_or_default() + ); + Ok(()) + } + Some(("delete", sub_m)) => { + let name = sub_m.get_one::("name").expect("name is required"); + let profile = crate::profile::ProfileName::new(name)?; + crate::profile::delete_profile(&base_dir, &profile).await?; + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "status": "success", + "message": format!("Profile '{}' deleted.", profile.as_str()), + })) + .unwrap_or_default() + ); + Ok(()) + } + _ => unreachable!("subcommand_required(true) ensures a subcommand is always present"), + } +} + /// Extract refresh_token from yup-oauth2 v12 token cache. /// /// Supports two formats: @@ -1740,7 +1913,7 @@ mod tests { #[tokio::test] async fn handle_auth_command_empty_args_prints_usage() { let args: Vec = vec![]; - let result = handle_auth_command(&args).await; + let result = handle_auth_command(&args, None).await; // Empty args now prints usage and returns Ok assert!(result.is_ok()); } @@ -1748,21 +1921,21 @@ mod tests { #[tokio::test] async fn handle_auth_command_help_flag_returns_ok() { let args = vec!["--help".to_string()]; - let result = handle_auth_command(&args).await; + let result = handle_auth_command(&args, None).await; assert!(result.is_ok()); } #[tokio::test] async fn handle_auth_command_help_short_flag_returns_ok() { let args = vec!["-h".to_string()]; - let result = handle_auth_command(&args).await; + let result = handle_auth_command(&args, None).await; assert!(result.is_ok()); } #[tokio::test] async fn handle_auth_command_invalid_subcommand() { let args = vec!["frobnicate".to_string()]; - let result = handle_auth_command(&args).await; + let result = handle_auth_command(&args, None).await; assert!(result.is_err()); match result.unwrap_err() { GwsError::Validation(msg) => assert!(msg.contains("frobnicate")), @@ -1815,7 +1988,7 @@ mod tests { async fn handle_status_succeeds_without_credentials() { // status should always succeed and report "none" let args = vec!["status".to_string()]; - let result = handle_auth_command(&args).await; + let result = handle_auth_command(&args, None).await; assert!(result.is_ok()); } diff --git a/crates/google-workspace-cli/src/credential_store.rs b/crates/google-workspace-cli/src/credential_store.rs index 52958e2b..eaf956ce 100644 --- a/crates/google-workspace-cli/src/credential_store.rs +++ b/crates/google-workspace-cli/src/credential_store.rs @@ -365,9 +365,25 @@ pub fn encrypted_credentials_path() -> PathBuf { crate::auth_commands::config_dir().join("credentials.enc") } -/// Saves credentials JSON to an encrypted file. +/// Returns the path for encrypted credentials within a specific profile. +pub fn encrypted_credentials_path_for_profile( + base_dir: &std::path::Path, + profile: &crate::profile::ProfileName, +) -> PathBuf { + crate::profile::profile_dir(base_dir, profile).join("credentials.enc") +} + +/// Saves credentials JSON to an encrypted file using the default key. +#[allow(dead_code)] pub fn save_encrypted(json: &str) -> anyhow::Result { let path = encrypted_credentials_path(); + save_encrypted_to_path(json, &path)?; + Ok(path) +} + +/// Saves credentials JSON to a specific encrypted file path using the default key. +#[allow(dead_code)] +pub fn save_encrypted_to_path(json: &str, path: &std::path::Path) -> anyhow::Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; #[cfg(unix)] @@ -387,10 +403,39 @@ pub fn save_encrypted(json: &str) -> anyhow::Result { // Write atomically via a sibling .tmp file + rename so the credentials // file is never left in a corrupt partial-write state on crash/Ctrl-C. - crate::fs_util::atomic_write(&path, &encrypted) + crate::fs_util::atomic_write(path, &encrypted) .map_err(|e| anyhow::anyhow!("Failed to write credentials: {e}"))?; - Ok(path) + Ok(()) +} + +/// Saves credentials JSON encrypted using a profile-specific key. +pub fn save_encrypted_for_profile( + json: &str, + path: &std::path::Path, + key_file: &std::path::Path, + profile: &str, +) -> anyhow::Result<()> { + 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 encrypted = encrypt_for_profile(json.as_bytes(), key_file, profile)?; + crate::fs_util::atomic_write(path, &encrypted) + .map_err(|e| anyhow::anyhow!("Failed to write credentials: {e}"))?; + + Ok(()) } /// Loads and decrypts credentials JSON from a specific path. @@ -400,11 +445,90 @@ pub fn load_encrypted_from_path(path: &std::path::Path) -> anyhow::Result anyhow::Result { + let data = std::fs::read(path)?; + let plaintext = decrypt_for_profile(&data, key_file, profile)?; + Ok(String::from_utf8(plaintext)?) +} + /// Loads and decrypts credentials JSON from the default encrypted file. pub fn load_encrypted() -> anyhow::Result { load_encrypted_from_path(&encrypted_credentials_path()) } +/// Encrypt plaintext using a profile-specific key. +/// +/// The key is resolved from the profile's own key file and keyring entry +/// (`gws-cli` / `/`), separate from the default key. +pub fn encrypt_for_profile( + plaintext: &[u8], + key_file: &std::path::Path, + profile: &str, +) -> anyhow::Result> { + let key = get_or_create_key_for_profile(key_file, profile)?; + let cipher = Aes256Gcm::new_from_slice(&key) + .map_err(|e| anyhow::anyhow!("Failed to create cipher: {e}"))?; + + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); + let ciphertext = cipher + .encrypt(&nonce, plaintext) + .map_err(|e| anyhow::anyhow!("Encryption failed: {e}"))?; + + let mut result = nonce.to_vec(); + result.extend_from_slice(&ciphertext); + Ok(result) +} + +/// Decrypt data using a profile-specific key. +pub fn decrypt_for_profile( + data: &[u8], + key_file: &std::path::Path, + profile: &str, +) -> anyhow::Result> { + if data.len() < 12 { + anyhow::bail!("Encrypted data too short"); + } + + let key = get_or_create_key_for_profile(key_file, profile)?; + let cipher = Aes256Gcm::new_from_slice(&key) + .map_err(|e| anyhow::anyhow!("Failed to create cipher: {e}"))?; + + let nonce = Nonce::from_slice(&data[..12]); + let plaintext = cipher.decrypt(nonce, &data[12..]).map_err(|_| { + anyhow::anyhow!( + "Decryption failed for profile '{profile}'. Credentials may have been created \ + on a different machine. Run `gws auth logout` and `gws auth login` to re-authenticate." + ) + })?; + + Ok(plaintext) +} + +/// Resolve or create a per-profile encryption key. +/// +/// Uses a profile-scoped keyring entry (`gws-cli` / `/`) +/// and a profile-specific key file, completely isolated from other profiles. +fn get_or_create_key_for_profile( + key_file: &std::path::Path, + profile: &str, +) -> anyhow::Result<[u8; 32]> { + let backend = KeyringBackend::from_env(); + + let username = std::env::var("USER") + .or_else(|_| std::env::var("USERNAME")) + .unwrap_or_else(|_| "unknown-user".to_string()); + + let keyring_user = format!("{}/{}", username, profile); + let provider = OsKeyring::new("gws-cli", &keyring_user); + + resolve_key(backend, &provider, key_file) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/google-workspace-cli/src/main.rs b/crates/google-workspace-cli/src/main.rs index 41dcc1e1..7e86606b 100644 --- a/crates/google-workspace-cli/src/main.rs +++ b/crates/google-workspace-cli/src/main.rs @@ -34,6 +34,7 @@ mod helpers; mod logging; mod oauth_config; mod output; +pub(crate) mod profile; mod schema; mod services; mod setup; @@ -70,26 +71,46 @@ async fn run() -> Result<(), GwsError> { )); } - // Find the first non-flag arg (skip --api-version and its value) + // Find the first non-flag arg (skip --api-version, --profile and their values) let mut first_arg: Option = None; + let mut profile_override: Option = None; { - let mut skip_next = false; - for a in args.iter().skip(1) { - if skip_next { - skip_next = false; - continue; - } + let mut i = 1; // skip argv[0] + while i < args.len() { + let a = &args[i]; if a == "--api-version" { - skip_next = true; + i += 2; // skip flag and its value continue; } if a.starts_with("--api-version=") { + i += 1; + continue; + } + if a == "--profile" { + if let Some(val) = args.get(i + 1) { + // Don't consume flags or known commands as profile values + if !val.starts_with('-') { + profile_override = Some(val.clone()); + i += 2; // skip flag and its value + } else { + // --profile with no value (next arg is a flag) — skip just the flag + i += 1; + } + } else { + i += 1; + } + continue; + } + if let Some(val) = a.strip_prefix("--profile=") { + profile_override = Some(val.to_string()); + i += 1; continue; } if !a.starts_with("--") || a.as_str() == "--help" || a.as_str() == "--version" { first_arg = Some(a.clone()); break; } + i += 1; } } let first_arg = first_arg.ok_or_else(|| { @@ -134,8 +155,24 @@ async fn run() -> Result<(), GwsError> { // Handle the `auth` command if first_arg == "auth" { - let auth_args: Vec = args.iter().skip(2).cloned().collect(); - return auth_commands::handle_auth_command(&auth_args).await; + // Strip --profile (and its value) from auth args since it's already extracted. + let mut auth_args: Vec = Vec::new(); + let mut skip_next = false; + for a in args.iter().skip(2) { + if skip_next { + skip_next = false; + continue; + } + if a == "--profile" { + skip_next = true; + continue; + } + if a.starts_with("--profile=") { + continue; + } + auth_args.push(a.clone()); + } + return auth_commands::handle_auth_command(&auth_args, profile_override.as_deref()).await; } // Parse service name and optional version override @@ -354,11 +391,11 @@ pub fn filter_args_for_subcommand(args: &[String], service_name: &str) -> Vec Result { + crate::validate::validate_profile_name(name)?; + Ok(Self(name.to_string())) + } + + /// Return the validated name as a string slice. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for ProfileName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// Resolve the active profile from (in priority order): +/// 1. `--profile` CLI flag override +/// 2. `GOOGLE_WORKSPACE_CLI_PROFILE` env var +/// 3. `active_profile` file in the config directory +/// 4. `"default"` fallback +pub fn resolve_active_profile( + cli_override: Option<&str>, + base_dir: &Path, +) -> Result { + // 1. CLI flag (highest priority) + if let Some(name) = cli_override { + return ProfileName::new(name); + } + + // 2. Environment variable + if let Ok(name) = std::env::var("GOOGLE_WORKSPACE_CLI_PROFILE") { + if !name.is_empty() { + return ProfileName::new(&name); + } + } + + // 3. active_profile file + let active_file = base_dir.join(ACTIVE_PROFILE_FILE); + if let Ok(contents) = std::fs::read_to_string(&active_file) { + let name = contents.trim(); + if !name.is_empty() { + // Validate file contents to prevent path traversal from tampered files + return ProfileName::new(name); + } + } + + // 4. Default fallback + ProfileName::new(DEFAULT_PROFILE) +} + +/// Return the profile-specific config directory path. +/// +/// Returns `/profiles//`. +/// Does NOT check existence or create the directory — callers should +/// use `create_profile()` or `ensure_profile_dir()` for that. +pub fn profile_dir(base_dir: &Path, profile: &ProfileName) -> PathBuf { + base_dir.join(PROFILES_DIR).join(profile.as_str()) +} + +/// Proactively ensure the profile directory exists with secure permissions. +/// +/// Uses `create_dir_all` directly (no check-then-create TOCTOU pattern). +pub async fn ensure_profile_dir( + base_dir: &Path, + profile: &ProfileName, +) -> Result { + let dir = profile_dir(base_dir, profile); + tokio::fs::create_dir_all(&dir).await.map_err(|e| { + GwsError::Validation(format!( + "Failed to create profile directory '{}': {e}", + dir.display() + )) + })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Err(e) = + tokio::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).await + { + eprintln!( + "Warning: failed to set permissions on profile directory '{}': {e}", + dir.display() + ); + } + } + Ok(dir) +} + +/// Create a new profile directory. Returns the path to the created directory. +pub async fn create_profile(base_dir: &Path, profile: &ProfileName) -> Result { + let dir = profile_dir(base_dir, profile); + if tokio::fs::try_exists(&dir).await.unwrap_or(false) { + return Err(GwsError::Validation(format!( + "Profile '{}' already exists", + profile.as_str() + ))); + } + ensure_profile_dir(base_dir, profile).await +} + +/// Delete a profile and all its credential files. +/// +/// Warns (does not fail) on individual file removal errors — lesson from +/// previous PR review feedback. +pub async fn delete_profile(base_dir: &Path, profile: &ProfileName) -> Result<(), GwsError> { + if profile.as_str() == DEFAULT_PROFILE { + // Allow deleting default — user can re-create it with `gws auth login` + } + + let dir = profile_dir(base_dir, profile); + if !tokio::fs::try_exists(&dir).await.unwrap_or(false) { + return Err(GwsError::Validation(format!( + "Profile '{}' does not exist", + profile.as_str() + ))); + } + + // Remove the profile directory and all contents + if let Err(e) = tokio::fs::remove_dir_all(&dir).await { + eprintln!( + "Warning: failed to remove profile directory '{}': {e}", + dir.display() + ); + return Err(GwsError::Validation(format!( + "Failed to delete profile '{}': {e}", + profile.as_str() + ))); + } + + // If this was the active profile, clear the active_profile file + let active_file = base_dir.join(ACTIVE_PROFILE_FILE); + if let Ok(contents) = tokio::fs::read_to_string(&active_file).await { + if contents.trim() == profile.as_str() { + let _ = tokio::fs::remove_file(&active_file).await; + } + } + + // Best-effort: remove keyring entry for this profile + let username = std::env::var("USER") + .or_else(|_| std::env::var("USERNAME")) + .unwrap_or_else(|_| "unknown-user".to_string()); + let keyring_user = format!("{}/{}", username, profile.as_str()); + if let Ok(entry) = keyring::Entry::new("gws-cli", &keyring_user) { + let _ = entry.delete_credential(); + } + + Ok(()) +} + +/// List all profiles and whether each is active. +/// +/// Returns `(profile_name, is_active)` pairs sorted alphabetically. +pub async fn list_profiles(base_dir: &Path) -> Result, GwsError> { + let profiles_dir = base_dir.join(PROFILES_DIR); + + // Determine which profile is active + let active = resolve_active_profile(None, base_dir) + .map(|p| p.as_str().to_string()) + .unwrap_or_else(|_| DEFAULT_PROFILE.to_string()); + + let mut profiles = Vec::new(); + + if !tokio::fs::try_exists(&profiles_dir).await.unwrap_or(false) { + // No profiles dir yet — return just the default + profiles.push((DEFAULT_PROFILE.to_string(), active == DEFAULT_PROFILE)); + return Ok(profiles); + } + + let mut entries = tokio::fs::read_dir(&profiles_dir).await.map_err(|e| { + GwsError::Validation(format!( + "Failed to read profiles directory '{}': {e}", + profiles_dir.display() + )) + })?; + + while let Some(entry) = entries + .next_entry() + .await + .map_err(|e| GwsError::Validation(format!("Failed to read profile entry: {e}")))? + { + if let Ok(file_type) = entry.file_type().await { + if file_type.is_dir() { + let name = entry.file_name().to_string_lossy().to_string(); + // Validate each directory name — skip invalid ones + if ProfileName::new(&name).is_ok() { + let is_active = name == active; + profiles.push((name, is_active)); + } + } + } + } + + profiles.sort_by(|a, b| a.0.cmp(&b.0)); + + if profiles.is_empty() { + profiles.push((DEFAULT_PROFILE.to_string(), active == DEFAULT_PROFILE)); + } + + Ok(profiles) +} + +/// Set the active profile by writing the `active_profile` file atomically. +pub async fn set_active_profile(base_dir: &Path, profile: &ProfileName) -> Result<(), GwsError> { + // Verify the profile directory exists + let dir = profile_dir(base_dir, profile); + if !tokio::fs::try_exists(&dir).await.unwrap_or(false) { + return Err(GwsError::Validation(format!( + "Profile '{}' does not exist. Create it first with `gws auth profile create {}`", + profile.as_str(), + profile.as_str() + ))); + } + + let active_file = base_dir.join(ACTIVE_PROFILE_FILE); + + // Atomic write to prevent partial writes + crate::fs_util::atomic_write_async(&active_file, profile.as_str().as_bytes()) + .await + .map_err(|e| GwsError::Validation(format!("Failed to set active profile: {e}")))?; + + Ok(()) +} + +/// Run one-time migration from flat config layout to per-profile layout. +/// +/// Triggered when `profiles/` does not exist but root credential files do. +/// Uses copy-then-delete for safety: originals removed only after copies verified. +pub async fn migrate_to_profiles(base_dir: &Path) -> Result { + let profiles_dir = base_dir.join(PROFILES_DIR); + let root_enc = base_dir.join("credentials.enc"); + + // Only migrate if profiles dir doesn't exist but root credentials do + if tokio::fs::try_exists(&profiles_dir).await.unwrap_or(false) { + return Ok(false); // Already migrated + } + if !tokio::fs::try_exists(&root_enc).await.unwrap_or(false) { + return Ok(false); // Nothing to migrate + } + + let profile = ProfileName::new(DEFAULT_PROFILE)?; + let target_dir = ensure_profile_dir(base_dir, &profile).await?; + + // Files to migrate + let files_to_migrate = [ + "credentials.enc", + "token_cache.json", + "sa_token_cache.json", + ".encryption_key", + "account_timezone", + ]; + + let mut migrated = Vec::new(); + + // Phase 1: Copy files + for filename in &files_to_migrate { + let src = base_dir.join(filename); + let dst = target_dir.join(filename); + + if tokio::fs::try_exists(&src).await.unwrap_or(false) { + match tokio::fs::copy(&src, &dst).await { + Ok(_) => { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = tokio::fs::set_permissions( + &dst, + std::fs::Permissions::from_mode(0o600), + ) + .await; + } + migrated.push(filename.to_string()); + } + Err(e) => { + eprintln!("Warning: failed to migrate {}: {e}", src.display()); + } + } + } + } + + // Phase 2: Write active_profile + let active_file = base_dir.join(ACTIVE_PROFILE_FILE); + crate::fs_util::atomic_write_async(&active_file, DEFAULT_PROFILE.as_bytes()) + .await + .map_err(|e| { + GwsError::Validation(format!( + "Failed to write active_profile during migration: {e}" + )) + })?; + + // Phase 3: Copy keyring entry + let username = std::env::var("USER") + .or_else(|_| std::env::var("USERNAME")) + .unwrap_or_else(|_| "unknown-user".to_string()); + + // Try to copy the keyring entry from old name to new profile-scoped name + let old_keyring_user = username.clone(); + let new_keyring_user = format!("{}/{}", username, DEFAULT_PROFILE); + if let Ok(old_entry) = keyring::Entry::new("gws-cli", &old_keyring_user) { + if let Ok(password) = old_entry.get_password() { + if let Ok(new_entry) = keyring::Entry::new("gws-cli", &new_keyring_user) { + let _ = new_entry.set_password(&password); + } + } + } + + // Phase 4: Delete originals (only after successful copies) + for filename in &migrated { + let src = base_dir.join(filename); + if let Err(e) = tokio::fs::remove_file(&src).await { + if e.kind() != std::io::ErrorKind::NotFound { + eprintln!( + "Warning: failed to remove original file after migration: {}", + src.display() + ); + } + } + } + + if !migrated.is_empty() { + eprintln!( + "Migrated credentials to profile '{}'. Use `gws auth profile list` to see profiles.", + DEFAULT_PROFILE + ); + } + + Ok(true) +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + + #[test] + fn profile_name_valid() { + assert!(ProfileName::new("default").is_ok()); + assert!(ProfileName::new("work").is_ok()); + assert!(ProfileName::new("my_profile").is_ok()); + assert!(ProfileName::new("dev_01").is_ok()); + assert!(ProfileName::new("a").is_ok()); + assert!(ProfileName::new("profile_with-dash").is_ok()); + } + + #[test] + fn profile_name_rejects_empty() { + assert!(ProfileName::new("").is_err()); + } + + #[test] + fn profile_name_rejects_dot() { + assert!(ProfileName::new(".").is_err()); + assert!(ProfileName::new("..").is_err()); + } + + #[test] + fn profile_name_rejects_leading_hyphen() { + assert!(ProfileName::new("-profile").is_err()); + } + + #[test] + fn profile_name_rejects_uppercase() { + assert!(ProfileName::new("Work").is_err()); + assert!(ProfileName::new("DEFAULT").is_err()); + } + + #[test] + fn profile_name_rejects_path_traversal() { + assert!(ProfileName::new("../etc").is_err()); + assert!(ProfileName::new("a/b").is_err()); + assert!(ProfileName::new("a\\b").is_err()); + } + + #[test] + fn profile_name_rejects_percent() { + assert!(ProfileName::new("a%2e").is_err()); + } + + #[test] + fn profile_name_rejects_control_chars() { + assert!(ProfileName::new("abc\0def").is_err()); + assert!(ProfileName::new("abc\ndef").is_err()); + } + + #[test] + fn profile_name_rejects_too_long() { + let long = "a".repeat(65); + assert!(ProfileName::new(&long).is_err()); + // 64 should be ok + let max = "a".repeat(64); + assert!(ProfileName::new(&max).is_ok()); + } + + #[test] + fn profile_dir_constructs_correct_path() { + let base = Path::new("/home/user/.config/gws"); + let profile = ProfileName::new("work").unwrap(); + assert_eq!( + profile_dir(base, &profile), + PathBuf::from("/home/user/.config/gws/profiles/work") + ); + } + + #[tokio::test] + async fn create_and_list_profiles() { + let dir = tempfile::tempdir().unwrap(); + let base = dir.path(); + + let default = ProfileName::new("default").unwrap(); + create_profile(base, &default).await.unwrap(); + + let work = ProfileName::new("work").unwrap(); + create_profile(base, &work).await.unwrap(); + + let profiles = list_profiles(base).await.unwrap(); + assert_eq!(profiles.len(), 2); + assert!(profiles.iter().any(|(n, _)| n == "default")); + assert!(profiles.iter().any(|(n, _)| n == "work")); + } + + #[tokio::test] + async fn create_duplicate_profile_fails() { + let dir = tempfile::tempdir().unwrap(); + let base = dir.path(); + + let profile = ProfileName::new("work").unwrap(); + create_profile(base, &profile).await.unwrap(); + assert!(create_profile(base, &profile).await.is_err()); + } + + #[tokio::test] + async fn delete_nonexistent_profile_fails() { + let dir = tempfile::tempdir().unwrap(); + let base = dir.path(); + + let profile = ProfileName::new("ghost").unwrap(); + assert!(delete_profile(base, &profile).await.is_err()); + } + + #[tokio::test] + async fn switch_profile() { + let dir = tempfile::tempdir().unwrap(); + let base = dir.path(); + + let default = ProfileName::new("default").unwrap(); + create_profile(base, &default).await.unwrap(); + + let work = ProfileName::new("work").unwrap(); + create_profile(base, &work).await.unwrap(); + + set_active_profile(base, &work).await.unwrap(); + + let active = resolve_active_profile(None, base).unwrap(); + assert_eq!(active.as_str(), "work"); + } + + #[tokio::test] + async fn switch_nonexistent_profile_fails() { + let dir = tempfile::tempdir().unwrap(); + let base = dir.path(); + + let profile = ProfileName::new("ghost").unwrap(); + assert!(set_active_profile(base, &profile).await.is_err()); + } + + #[test] + fn resolve_profile_cli_override() { + let dir = tempfile::tempdir().unwrap(); + let profile = resolve_active_profile(Some("work"), dir.path()).unwrap(); + assert_eq!(profile.as_str(), "work"); + } + + #[test] + #[serial] + fn resolve_profile_env_var() { + let dir = tempfile::tempdir().unwrap(); + + std::env::set_var("GOOGLE_WORKSPACE_CLI_PROFILE", "staging"); + let profile = resolve_active_profile(None, dir.path()).unwrap(); + std::env::remove_var("GOOGLE_WORKSPACE_CLI_PROFILE"); + + assert_eq!(profile.as_str(), "staging"); + } + + #[test] + fn resolve_profile_file() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("active_profile"), "production").unwrap(); + + let profile = resolve_active_profile(None, dir.path()).unwrap(); + assert_eq!(profile.as_str(), "production"); + } + + #[test] + fn resolve_profile_default_fallback() { + let dir = tempfile::tempdir().unwrap(); + let profile = resolve_active_profile(None, dir.path()).unwrap(); + assert_eq!(profile.as_str(), "default"); + } + + #[test] + fn resolve_profile_rejects_traversal_in_file() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("active_profile"), "../etc/passwd").unwrap(); + + let result = resolve_active_profile(None, dir.path()); + assert!(result.is_err()); + } + + #[tokio::test] + async fn migrate_creates_default_profile() { + let dir = tempfile::tempdir().unwrap(); + let base = dir.path(); + + // Create root-level credential files (old layout) + tokio::fs::write(base.join("credentials.enc"), b"fake-enc-data") + .await + .unwrap(); + tokio::fs::write(base.join("token_cache.json"), b"fake-cache") + .await + .unwrap(); + + let migrated = migrate_to_profiles(base).await.unwrap(); + assert!(migrated); + + // Verify files were copied to profiles/default/ + let default_dir = base.join("profiles").join("default"); + assert!(default_dir.join("credentials.enc").exists()); + assert!(default_dir.join("token_cache.json").exists()); + + // Verify originals were removed + assert!(!base.join("credentials.enc").exists()); + assert!(!base.join("token_cache.json").exists()); + + // Verify active_profile was set + let active = std::fs::read_to_string(base.join("active_profile")).unwrap(); + assert_eq!(active.trim(), "default"); + } + + #[tokio::test] + async fn migrate_no_op_when_already_migrated() { + let dir = tempfile::tempdir().unwrap(); + let base = dir.path(); + + // Create profiles directory (already migrated) + tokio::fs::create_dir_all(base.join("profiles").join("default")) + .await + .unwrap(); + + let migrated = migrate_to_profiles(base).await.unwrap(); + assert!(!migrated); + } + + #[tokio::test] + async fn migrate_no_op_when_no_credentials() { + let dir = tempfile::tempdir().unwrap(); + let migrated = migrate_to_profiles(dir.path()).await.unwrap(); + assert!(!migrated); + } + + #[tokio::test] + async fn delete_profile_removes_directory() { + let dir = tempfile::tempdir().unwrap(); + let base = dir.path(); + + let profile = ProfileName::new("work").unwrap(); + create_profile(base, &profile).await.unwrap(); + + // Create some files in the profile + let pdir = profile_dir(base, &profile); + tokio::fs::write(pdir.join("credentials.enc"), b"data") + .await + .unwrap(); + + delete_profile(base, &profile).await.unwrap(); + assert!(!pdir.exists()); + } + + #[tokio::test] + async fn delete_active_profile_clears_active_file() { + let dir = tempfile::tempdir().unwrap(); + let base = dir.path(); + + let profile = ProfileName::new("work").unwrap(); + create_profile(base, &profile).await.unwrap(); + set_active_profile(base, &profile).await.unwrap(); + + delete_profile(base, &profile).await.unwrap(); + + // active_profile file should be removed + assert!(!base.join("active_profile").exists()); + } +} diff --git a/crates/google-workspace-cli/src/timezone.rs b/crates/google-workspace-cli/src/timezone.rs index b7cd6577..a4f6583e 100644 --- a/crates/google-workspace-cli/src/timezone.rs +++ b/crates/google-workspace-cli/src/timezone.rs @@ -30,13 +30,22 @@ const CACHE_FILENAME: &str = "account_timezone"; /// Cache TTL in seconds (24 hours). const CACHE_TTL_SECS: u64 = 86400; -/// Returns the path to the timezone cache file. +/// Returns the path to the timezone cache file for the active profile. fn cache_path() -> PathBuf { - crate::auth_commands::config_dir().join(CACHE_FILENAME) + let base_dir = crate::auth_commands::config_dir(); + let profile = crate::profile::resolve_active_profile(None, &base_dir) + .unwrap_or_else(|_| { + crate::profile::ProfileName::new(crate::profile::DEFAULT_PROFILE) + .expect("'default' is always a valid profile name") + }); + crate::profile::profile_dir(&base_dir, &profile).join(CACHE_FILENAME) } -/// Remove the cached timezone file. Called on auth login/logout to -/// invalidate stale values when the account changes. +/// Remove the cached timezone file from the root config directory. +/// +/// Kept for backward compatibility; new code should use +/// [`invalidate_cache_for_profile`] instead. +#[allow(dead_code)] pub fn invalidate_cache() { let path = cache_path(); if let Err(e) = std::fs::remove_file(&path) { @@ -46,6 +55,19 @@ pub fn invalidate_cache() { } } +/// Remove the cached timezone file for a specific profile. +pub fn invalidate_cache_for_profile( + base_dir: &std::path::Path, + profile: &crate::profile::ProfileName, +) { + let path = crate::profile::profile_dir(base_dir, profile).join(CACHE_FILENAME); + if let Err(e) = std::fs::remove_file(&path) { + if e.kind() != std::io::ErrorKind::NotFound { + tracing::warn!(path = %path.display(), error = %e, "failed to invalidate timezone cache for profile"); + } + } +} + /// Read the cached timezone if it exists and is fresh (< 24h old). fn read_cache() -> Option { let path = cache_path(); diff --git a/crates/google-workspace-cli/src/token_storage.rs b/crates/google-workspace-cli/src/token_storage.rs index 5fb17a00..9f805864 100644 --- a/crates/google-workspace-cli/src/token_storage.rs +++ b/crates/google-workspace-cli/src/token_storage.rs @@ -24,25 +24,57 @@ use crate::output::sanitize_for_terminal; /// the cached tokens at rest using AES-256-GCM encryption. pub struct EncryptedTokenStorage { file_path: PathBuf, + /// Profile-specific key file and profile name for per-profile encryption. + /// When `None`, uses the default (legacy) encryption key. + profile_key: Option<(PathBuf, String)>, // Add memory cache since TokenStorage getters can be called frequently cache: Arc>>>, } impl EncryptedTokenStorage { + #[allow(dead_code)] pub fn new(path: PathBuf) -> Self { Self { file_path: path, + profile_key: None, cache: Arc::new(Mutex::new(None)), } } + /// Create a profile-aware token storage that uses the profile's own encryption key. + pub fn new_with_profile(path: PathBuf, key_file: PathBuf, profile: String) -> Self { + Self { + file_path: path, + profile_key: Some((key_file, profile)), + cache: Arc::new(Mutex::new(None)), + } + } + + fn decrypt_data(&self, data: &[u8]) -> anyhow::Result> { + match &self.profile_key { + Some((key_file, profile)) => { + crate::credential_store::decrypt_for_profile(data, key_file, profile) + } + None => crate::credential_store::decrypt(data), + } + } + + fn encrypt_data(&self, plaintext: &[u8]) -> anyhow::Result> { + match &self.profile_key { + Some((key_file, profile)) => { + crate::credential_store::encrypt_for_profile(plaintext, key_file, profile) + } + None => crate::credential_store::encrypt(plaintext), + } + } + async fn load_from_disk(&self) -> HashMap { let data = match tokio::fs::read(&self.file_path).await { Ok(d) => d, Err(_) => return HashMap::new(), // File doesn't exist yet — normal on first run }; - let decrypted = match crate::credential_store::decrypt(&data) { + let decrypted = match self.decrypt_data(&data) { Ok(d) => d, Err(e) => { eprintln!( @@ -79,7 +111,7 @@ impl EncryptedTokenStorage { async fn save_to_disk(&self, map: &HashMap) -> anyhow::Result<()> { let json = serde_json::to_string(map)?; - let encrypted = crate::credential_store::encrypt(json.as_bytes())?; + let encrypted = self.encrypt_data(json.as_bytes())?; if let Some(parent) = self.file_path.parent() { tokio::fs::create_dir_all(parent).await.map_err(|e| { diff --git a/crates/google-workspace/src/validate.rs b/crates/google-workspace/src/validate.rs index 32ef200f..36fdd1f6 100644 --- a/crates/google-workspace/src/validate.rs +++ b/crates/google-workspace/src/validate.rs @@ -350,6 +350,62 @@ pub fn validate_api_identifier(s: &str) -> Result<&str, GwsError> { Ok(s) } +/// Maximum length for a profile name. +const MAX_PROFILE_NAME_LEN: usize = 64; + +/// Validate a profile name for use as a directory name under `profiles/`. +/// +/// Rules: +/// - Lowercase only: `[a-z0-9_-]` +/// - Cannot start with `-` (avoids CLI flag confusion) +/// - Cannot be empty, `.`, or `..` +/// - Max 64 characters +/// - No `/`, `\`, `%`, null bytes, or control characters +/// +/// These rules prevent: +/// - Path traversal (`../`, `..`) +/// - CLI flag injection (`-flag`) +/// - URL-encoded bypasses (`%2e%2e`) +/// - Filesystem ambiguity (uppercase on case-insensitive FS) +pub fn validate_profile_name(name: &str) -> Result<&str, GwsError> { + if name.is_empty() { + return Err(GwsError::Validation( + "Profile name must not be empty".to_string(), + )); + } + + if name == "." || name == ".." { + return Err(GwsError::Validation(format!( + "Profile name must not be '.' or '..': {name}" + ))); + } + + if name.len() > MAX_PROFILE_NAME_LEN { + return Err(GwsError::Validation(format!( + "Profile name must be at most {MAX_PROFILE_NAME_LEN} characters, got {}", + name.len() + ))); + } + + if name.starts_with('-') { + return Err(GwsError::Validation(format!( + "Profile name must not start with '-': {name}" + ))); + } + + // Reject any character not in [a-z0-9_-] + if !name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') + { + return Err(GwsError::Validation(format!( + "Profile name must contain only lowercase alphanumeric characters, '_', or '-': {name}" + ))); + } + + Ok(name) +} + #[cfg(test)] mod tests { use super::*; @@ -835,4 +891,96 @@ mod tests { "traversal via non-existent prefix should be rejected" ); } + + // --- validate_profile_name --- + + #[test] + fn profile_name_valid_simple() { + assert_eq!(validate_profile_name("default").unwrap(), "default"); + assert_eq!(validate_profile_name("work").unwrap(), "work"); + assert_eq!(validate_profile_name("my_profile").unwrap(), "my_profile"); + assert_eq!(validate_profile_name("dev_01").unwrap(), "dev_01"); + assert_eq!(validate_profile_name("a").unwrap(), "a"); + } + + #[test] + fn profile_name_allows_internal_hyphen() { + assert_eq!(validate_profile_name("my-profile").unwrap(), "my-profile"); + } + + #[test] + fn profile_name_rejects_empty() { + assert!(validate_profile_name("").is_err()); + } + + #[test] + fn profile_name_rejects_dot_and_dotdot() { + assert!(validate_profile_name(".").is_err()); + assert!(validate_profile_name("..").is_err()); + } + + #[test] + fn profile_name_rejects_leading_hyphen() { + let err = validate_profile_name("-flag").unwrap_err(); + assert!(err.to_string().contains("must not start with '-'")); + } + + #[test] + fn profile_name_rejects_uppercase() { + assert!(validate_profile_name("Work").is_err()); + assert!(validate_profile_name("DEFAULT").is_err()); + assert!(validate_profile_name("myProfile").is_err()); + } + + #[test] + fn profile_name_rejects_slash() { + assert!(validate_profile_name("a/b").is_err()); + assert!(validate_profile_name("../etc").is_err()); + } + + #[test] + fn profile_name_rejects_backslash() { + assert!(validate_profile_name("a\\b").is_err()); + } + + #[test] + fn profile_name_rejects_percent() { + assert!(validate_profile_name("a%2e").is_err()); + } + + #[test] + fn profile_name_rejects_control_chars() { + assert!(validate_profile_name("abc\0def").is_err()); + assert!(validate_profile_name("abc\ndef").is_err()); + assert!(validate_profile_name("abc\tdef").is_err()); + } + + #[test] + fn profile_name_rejects_spaces() { + assert!(validate_profile_name("my profile").is_err()); + } + + #[test] + fn profile_name_rejects_dots_in_name() { + // Dots are not allowed (only [a-z0-9_-]) + assert!(validate_profile_name("my.profile").is_err()); + } + + #[test] + fn profile_name_rejects_too_long() { + let long = "a".repeat(65); + assert!(validate_profile_name(&long).is_err()); + } + + #[test] + fn profile_name_accepts_max_length() { + let max = "a".repeat(64); + assert!(validate_profile_name(&max).is_ok()); + } + + #[test] + fn profile_name_rejects_unicode() { + assert!(validate_profile_name("wörk").is_err()); + assert!(validate_profile_name("工作").is_err()); + } }