Skip to content

Commit 46af288

Browse files
committed
feat(auth): add named account profiles
1 parent a3768d0 commit 46af288

6 files changed

Lines changed: 252 additions & 42 deletions

File tree

.changeset/auth-profiles.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@googleworkspace/cli": minor
3+
---
4+
5+
Add named auth profiles with isolated credentials and token caches.

crates/google-workspace-cli/src/auth.rs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -220,10 +220,9 @@ pub async fn get_token(scopes: &[&str]) -> anyhow::Result<String> {
220220
}
221221

222222
let creds_file = std::env::var("GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE").ok();
223-
let config_dir = crate::auth_commands::config_dir();
224223
let enc_path = credential_store::encrypted_credentials_path();
225-
let default_path = config_dir.join("credentials.json");
226-
let token_cache = config_dir.join("token_cache.json");
224+
let default_path = crate::auth_commands::plain_credentials_path();
225+
let token_cache = crate::auth_commands::token_cache_path();
227226

228227
let creds = load_credentials_inner(creds_file.as_deref(), &enc_path, &default_path).await?;
229228
get_token_inner(scopes, creds, &token_cache).await

crates/google-workspace-cli/src/auth_commands.rs

Lines changed: 101 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ use std::collections::HashSet;
1616
use std::io::{BufRead, BufReader, Write};
1717
use std::net::TcpListener;
1818
use std::path::{Path, PathBuf};
19+
use std::sync::OnceLock;
1920

2021
use serde::Deserialize;
2122
use serde_json::json;
@@ -334,15 +335,63 @@ pub fn config_dir() -> PathBuf {
334335
primary
335336
}
336337

337-
fn plain_credentials_path() -> PathBuf {
338+
static ACTIVE_PROFILE: OnceLock<String> = OnceLock::new();
339+
340+
pub(crate) const DEFAULT_PROFILE: &str = "default";
341+
342+
pub(crate) fn validate_profile_name(name: &str) -> Result<(), GwsError> {
343+
if name.is_empty()
344+
|| name.starts_with('-')
345+
|| !name
346+
.chars()
347+
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
348+
{
349+
return Err(GwsError::Validation(
350+
"Profile names must start with a letter, number, or '_' and may only contain letters, numbers, '-' and '_'".to_string(),
351+
));
352+
}
353+
Ok(())
354+
}
355+
356+
pub(crate) fn set_active_profile(profile: Option<String>) -> Result<(), GwsError> {
357+
if let Some(profile) = profile {
358+
validate_profile_name(&profile)?;
359+
let _ = ACTIVE_PROFILE.set(profile);
360+
}
361+
Ok(())
362+
}
363+
364+
pub(crate) fn active_profile() -> String {
365+
ACTIVE_PROFILE
366+
.get()
367+
.cloned()
368+
.or_else(|| std::env::var("GOOGLE_WORKSPACE_CLI_PROFILE").ok())
369+
.filter(|p| validate_profile_name(p).is_ok())
370+
.unwrap_or_else(|| DEFAULT_PROFILE.to_string())
371+
}
372+
373+
pub(crate) fn profile_dir() -> PathBuf {
374+
let profile = active_profile();
375+
if profile == DEFAULT_PROFILE {
376+
config_dir()
377+
} else {
378+
config_dir().join("profiles").join(profile)
379+
}
380+
}
381+
382+
pub(crate) fn plain_credentials_path() -> PathBuf {
338383
if let Ok(path) = std::env::var("GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE") {
339384
return PathBuf::from(path);
340385
}
341-
config_dir().join("credentials.json")
386+
profile_dir().join("credentials.json")
342387
}
343388

344-
fn token_cache_path() -> PathBuf {
345-
config_dir().join("token_cache.json")
389+
pub(crate) fn token_cache_path() -> PathBuf {
390+
profile_dir().join("token_cache.json")
391+
}
392+
393+
pub(crate) fn service_account_token_cache_path() -> PathBuf {
394+
profile_dir().join("sa_token_cache.json")
346395
}
347396

348397
/// Which scope set to use for login.
@@ -644,9 +693,15 @@ async fn handle_login_inner(
644693
let enc_path = credential_store::save_encrypted(&creds_str)
645694
.map_err(|e| GwsError::Auth(format!("Failed to encrypt credentials: {e}")))?;
646695

696+
for path in [token_cache_path(), service_account_token_cache_path()] {
697+
let _ = std::fs::remove_file(path);
698+
}
699+
crate::timezone::invalidate_cache();
700+
647701
let output = json!({
648702
"status": "success",
649703
"message": "Authentication successful. Encrypted credentials saved.",
704+
"profile": active_profile(),
650705
"account": actual_email.as_deref().unwrap_or("(unknown)"),
651706
"credentials_file": enc_path.display().to_string(),
652707
"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> {
12231278
};
12241279

12251280
let mut output = json!({
1281+
"profile": active_profile(),
1282+
"profile_dir": profile_dir().display().to_string(),
12261283
"auth_method": auth_method,
12271284
"storage": storage,
12281285
"keyring_backend": credential_store::active_backend_name(),
@@ -1457,7 +1514,7 @@ fn handle_logout() -> Result<(), GwsError> {
14571514
let plain_path = plain_credentials_path();
14581515
let enc_path = credential_store::encrypted_credentials_path();
14591516
let token_cache = token_cache_path();
1460-
let sa_token_cache = config_dir().join("sa_token_cache.json");
1517+
let sa_token_cache = service_account_token_cache_path();
14611518

14621519
let mut removed = Vec::new();
14631520

@@ -1476,11 +1533,13 @@ fn handle_logout() -> Result<(), GwsError> {
14761533
let output = if removed.is_empty() {
14771534
json!({
14781535
"status": "success",
1536+
"profile": active_profile(),
14791537
"message": "No credentials found to remove.",
14801538
})
14811539
} else {
14821540
json!({
14831541
"status": "success",
1542+
"profile": active_profile(),
14841543
"message": "Logged out. All credentials and token caches removed.",
14851544
"removed": removed,
14861545
})
@@ -1900,6 +1959,43 @@ mod tests {
19001959
assert!(path.starts_with(config_dir()));
19011960
}
19021961

1962+
#[test]
1963+
#[serial_test::serial]
1964+
fn profile_dir_defaults_to_config_dir() {
1965+
unsafe {
1966+
std::env::remove_var("GOOGLE_WORKSPACE_CLI_PROFILE");
1967+
}
1968+
assert_eq!(active_profile(), DEFAULT_PROFILE);
1969+
assert_eq!(profile_dir(), config_dir());
1970+
}
1971+
1972+
#[test]
1973+
#[serial_test::serial]
1974+
fn named_profile_uses_isolated_paths() {
1975+
unsafe {
1976+
std::env::set_var("GOOGLE_WORKSPACE_CLI_PROFILE", "work");
1977+
}
1978+
1979+
let dir = profile_dir();
1980+
assert!(dir.ends_with("profiles/work") || dir.ends_with(r"profiles\work"));
1981+
assert!(plain_credentials_path().starts_with(&dir));
1982+
assert!(token_cache_path().starts_with(&dir));
1983+
assert!(service_account_token_cache_path().starts_with(&dir));
1984+
1985+
unsafe {
1986+
std::env::remove_var("GOOGLE_WORKSPACE_CLI_PROFILE");
1987+
}
1988+
}
1989+
1990+
#[test]
1991+
fn validate_profile_name_rejects_traversal() {
1992+
assert!(validate_profile_name("work").is_ok());
1993+
assert!(validate_profile_name("../work").is_err());
1994+
assert!(validate_profile_name("work.profile").is_err());
1995+
assert!(validate_profile_name("--help").is_err());
1996+
assert!(validate_profile_name("").is_err());
1997+
}
1998+
19031999
#[tokio::test]
19042000
async fn handle_auth_command_empty_args_prints_usage() {
19052001
let args: Vec<String> = vec![];

crates/google-workspace-cli/src/credential_store.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -424,7 +424,7 @@ pub fn active_backend_name() -> &'static str {
424424

425425
/// Returns the path for encrypted credentials.
426426
pub fn encrypted_credentials_path() -> PathBuf {
427-
crate::auth_commands::config_dir().join("credentials.enc")
427+
crate::auth_commands::profile_dir().join("credentials.enc")
428428
}
429429

430430
/// Saves credentials JSON to an encrypted file.

0 commit comments

Comments
 (0)