Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/auth-profiles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@googleworkspace/cli": minor
---

Add named auth profiles with isolated credentials and token caches.
5 changes: 2 additions & 3 deletions crates/google-workspace-cli/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,10 +220,9 @@ pub async fn get_token(scopes: &[&str]) -> anyhow::Result<String> {
}

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
Expand Down
106 changes: 101 additions & 5 deletions crates/google-workspace-cli/src/auth_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -334,15 +335,63 @@ pub fn config_dir() -> PathBuf {
primary
}

fn plain_credentials_path() -> PathBuf {
static ACTIVE_PROFILE: OnceLock<String> = 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<String>) -> 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.
Expand Down Expand Up @@ -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)",
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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();

Expand All @@ -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,
})
Expand Down Expand Up @@ -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<String> = vec![];
Expand Down
2 changes: 1 addition & 1 deletion crates/google-workspace-cli/src/credential_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading