feat: multi-account support (#85)

* feat: multi-account support with --account flag, per-account credential storage

- Add --account global flag and GOOGLE_WORKSPACE_CLI_ACCOUNT env var
- Per-account encrypted credential files (credentials.<b64-email>.enc)
- Per-account token cache (token_cache.<b64-email>.json)
- accounts.json registry with default account tracking
- New auth subcommands: list, default, per-account logout
- login_hint in OAuth URL for account pre-selection
- Email validation via Google userinfo after OAuth flow
- 12 new unit tests (380 total)

BREAKING CHANGE: Existing users must run 'gws auth login' again.
Credential storage changed from single credentials.enc to per-account files.

* refactor: Improve error handling for file system operations, rename `GWS_ACCOUNT` to `GOOGLE_WORKSPACE_CLI_ACCOUNT`, and refine service account token cache path generation.

* fix: clean up per-account token caches on logout

---------

Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
This commit is contained in:
Justin Poehnelt
2026-03-04 23:19:14 -07:00
committed by GitHub
parent 805e037c74
commit d1825f9385
22 changed files with 1083 additions and 110 deletions
+20
View File
@@ -0,0 +1,20 @@
---
"gws": minor
---
### Multi-Account Support
Add support for managing multiple Google accounts with per-account credential storage.
**New features:**
- `--account EMAIL` global flag available on every command
- `GOOGLE_WORKSPACE_CLI_ACCOUNT` environment variable as fallback
- `gws auth login --account EMAIL` — associates credentials with a specific account
- `gws auth list` — lists all registered accounts
- `gws auth default EMAIL` — sets the default account
- `gws auth logout --account EMAIL` — removes a specific account
- `login_hint` in OAuth URL for automatic account pre-selection in browser
- Email validation via Google userinfo endpoint after OAuth flow
**Breaking change:** Existing users must run `gws auth login` again after upgrading. The credential storage format has changed from a single `credentials.enc` to per-account files (`credentials.<b64-email>.enc`) with an `accounts.json` registry.
+20 -15
View File
@@ -38,21 +38,25 @@ Use `patch` for fixes/chores, `minor` for new features, `major` for breaking cha
## Architecture
The CLI uses a **two-phase argument parsing** strategy:
1. Parse argv to extract the service name (e.g., `drive`)
2. Fetch the service's Discovery Document, build a dynamic `clap::Command` tree, then re-parse
### Source Layout
| File | Purpose |
|---|---|
| `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` | Headless OAuth2 via `yup-oauth2` |
| `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 |
| `src/error.rs` | Structured JSON error output |
| File | Purpose |
| ------------------------- | ----------------------------------------------------------------------------------------- |
| `src/main.rs` | Entrypoint, two-phase CLI parsing, `--account` global flag extraction, 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/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 |
| `src/error.rs` | Structured JSON error output |
## Demo Videos
@@ -84,11 +88,11 @@ ASCII art title cards live in `art/`. The `scripts/show-art.sh` helper clears th
When adding new helpers or CLI flags that accept file paths, **always validate** using the shared helpers:
| Scenario | Validator | Rejects |
|---|---|---|
| File path for writing (`--output-dir`) | `validate::validate_safe_output_dir()` | Absolute paths, `../` traversal, symlinks outside CWD, control chars |
| File path for reading (`--dir`) | `validate::validate_safe_dir_path()` | Absolute paths, `../` traversal, symlinks outside CWD, control chars |
| Enum/allowlist values (`--msg-format`) | clap `value_parser` (see `gmail/mod.rs`) | Any value not in the allowlist |
| Scenario | Validator | Rejects |
| -------------------------------------- | ---------------------------------------- | -------------------------------------------------------------------- |
| File path for writing (`--output-dir`) | `validate::validate_safe_output_dir()` | Absolute paths, `../` traversal, symlinks outside CWD, control chars |
| File path for reading (`--dir`) | `validate::validate_safe_dir_path()` | Absolute paths, `../` traversal, symlinks outside CWD, control chars |
| Enum/allowlist values (`--msg-format`) | clap `value_parser` (see `gmail/mod.rs`) | Any value not in the allowlist |
```rust
// In your argument parser:
@@ -150,4 +154,5 @@ When adding a new helper or CLI command:
- `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)
- Supports `.env` files via `dotenvy`
+25 -6
View File
@@ -103,6 +103,23 @@ gws auth login # subsequent logins
> Requires the [`gcloud` CLI](https://cloud.google.com/sdk/docs/install) to be installed and authenticated.
### 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.<b64-email>.enc` in `~/.config/gws/`, with an `accounts.json` registry tracking defaults.
### Manual OAuth setup (Google Cloud Console)
Use this when `gws auth setup` cannot automate project/client creation, or when you want explicit control.
@@ -172,12 +189,14 @@ 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 | Encrypted credentials (OS keyring) | `gws auth login` |
| 4 | Plaintext credentials | `~/.config/gws/credentials.json` |
| 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`.
Environment variables can also live in a `.env` file.
+259
View File
@@ -0,0 +1,259 @@
// 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<String>,
/// Map from normalised email → account metadata.
pub accounts: BTreeMap<String, AccountMeta>,
}
/// 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.<b64>.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<Option<AccountsRegistry>> {
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(&registry), 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(&registry).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(&registry), 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(&registry).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"));
}
}
+106 -14
View File
@@ -36,9 +36,12 @@ 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. Encrypted credentials at `~/.config/gws/credentials.enc` (User only)
/// 2. Per-account encrypted credentials via `accounts.json` registry
/// 3. Plaintext credentials at `~/.config/gws/credentials.json` (User only)
pub async fn get_token(scopes: &[&str]) -> anyhow::Result<String> {
///
/// 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<String> {
// 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() {
@@ -52,26 +55,112 @@ pub async fn get_token(scopes: &[&str]) -> anyhow::Result<String> {
.unwrap_or_else(|| PathBuf::from("."))
.join("gws");
let enc_path = credential_store::encrypted_credentials_path();
// 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, impersonated_user.as_deref()).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.<b64-email>.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 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,
impersonated_user.as_deref(),
)
.await
}
let creds = load_credentials_inner(creds_file.as_deref(), &enc_path, &default_path).await?;
/// Resolve which account to use:
/// 1. Explicit `account` parameter takes priority.
/// 2. Fall back to `accounts.json` default.
/// 3. If no registry exists but legacy `credentials.enc` exists, fail with upgrade message.
/// 4. If nothing exists, return None (will fall through to standard error).
fn resolve_account(account: Option<&str>) -> anyhow::Result<Option<String>> {
let registry = crate::accounts::load_accounts()?;
get_token_inner(scopes, creds, &config_dir, impersonated_user.as_deref()).await
match (account, &registry) {
// 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 <email>'."
);
}
}
// No account, no registry — check for legacy credentials
(None, None) => {
let legacy_path = credential_store::encrypted_credentials_path();
if legacy_path.exists() {
anyhow::bail!(
"Legacy credentials found at {}. \
gws now supports multiple accounts. \
Please run 'gws auth login' to upgrade your credentials.",
legacy_path.display()
);
}
// No registry, no legacy — fall through to standard credential loading
Ok(None)
}
}
}
async fn get_token_inner(
scopes: &[&str],
creds: Credential,
config_dir: &std::path::Path,
token_cache_path: &std::path::Path,
impersonated_user: Option<&str>,
) -> anyhow::Result<String> {
match creds {
Credential::AuthorizedUser(secret) => {
let token_cache = config_dir.join("token_cache.json");
let auth = yup_oauth2::AuthorizedUserAuthenticator::builder(secret)
.with_storage(Box::new(crate::token_storage::EncryptedTokenStorage::new(
token_cache,
token_cache_path.to_path_buf(),
)))
.build()
.await
@@ -84,11 +173,14 @@ async fn get_token_inner(
.to_string())
}
Credential::ServiceAccount(key) => {
let token_cache = config_dir.join("service_account_token_cache.json");
let mut builder =
yup_oauth2::ServiceAccountAuthenticator::builder(key).with_storage(Box::new(
crate::token_storage::EncryptedTokenStorage::new(token_cache),
));
let tc_filename = token_cache_path
.file_name()
.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 mut builder = yup_oauth2::ServiceAccountAuthenticator::builder(key).with_storage(
Box::new(crate::token_storage::EncryptedTokenStorage::new(sa_cache)),
);
// Check for impersonation
if let Some(user) = impersonated_user {
@@ -321,7 +413,7 @@ mod tests {
std::env::set_var("GOOGLE_WORKSPACE_CLI_TOKEN", "my-test-token");
}
let result = get_token(&["https://www.googleapis.com/auth/drive"]).await;
let result = get_token(&["https://www.googleapis.com/auth/drive"], None).await;
unsafe {
if let Some(t) = old_token {
+331 -43
View File
@@ -115,17 +115,22 @@ fn token_cache_path() -> PathBuf {
/// Handle `gws auth <subcommand>`.
pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> {
const USAGE: &str = concat!(
"Usage: gws auth <login|setup|status|export|logout>\n\n",
" login Authenticate via OAuth2 (opens browser)\n",
" --readonly Request read-only scopes\n",
" --full Request all scopes incl. pubsub + cloud-platform\n",
" (may trigger restricted_client for unverified apps)\n",
" --scopes Comma-separated custom scopes\n",
" setup Configure GCP project + OAuth client (requires gcloud)\n",
" --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",
"Usage: gws auth <login|setup|status|export|logout|list|default> [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",
" --scopes Comma-separated custom scopes\n",
" setup Configure GCP project + OAuth client (requires gcloud)\n",
" --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",
);
// Honour --help / -h before treating the first arg as a subcommand.
@@ -142,14 +147,19 @@ 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(),
"logout" => handle_logout(&args[1..]),
"list" => handle_list(),
"default" => handle_default(&args[1..]),
other => Err(GwsError::Validation(format!(
"Unknown auth subcommand: '{other}'. Use: login, setup, status, export, logout"
"Unknown auth subcommand: '{other}'. Use: login, setup, status, export, logout, list, default"
))),
}
}
/// Custom delegate that prints the OAuth URL on its own line for easy copying.
struct CliFlowDelegate;
/// Optionally includes `login_hint` in the URL for account pre-selection.
struct CliFlowDelegate {
login_hint: Option<String>,
}
impl yup_oauth2::authenticator_delegate::InstalledFlowDelegate for CliFlowDelegate {
fn present_user_url<'a>(
@@ -159,14 +169,50 @@ impl yup_oauth2::authenticator_delegate::InstalledFlowDelegate for CliFlowDelega
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String, String>> + Send + 'a>>
{
Box::pin(async move {
// Inject login_hint into the OAuth URL if we have one
let display_url = if let Some(ref hint) = self.login_hint {
let encoded: String = percent_encoding::percent_encode(
hint.as_bytes(),
percent_encoding::NON_ALPHANUMERIC,
)
.to_string();
if url.contains('?') {
format!("{url}&login_hint={encoded}")
} else {
format!("{url}?login_hint={encoded}")
}
} else {
url.to_string()
};
eprintln!("Open this URL in your browser to authenticate:\n");
eprintln!(" {url}\n");
eprintln!(" {display_url}\n");
Ok(String::new())
})
}
}
async fn handle_login(args: &[String]) -> Result<(), GwsError> {
// Extract --account from args if provided
let mut account_email: Option<String> = None;
let mut filtered_args: Vec<String> = Vec::new();
let mut skip_next = false;
for i in 0..args.len() {
if skip_next {
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;
}
filtered_args.push(args[i].clone());
}
// Resolve client_id and client_secret:
// 1. Env vars (highest priority)
// 2. Saved client_secret.json from `gws auth setup` or manual download
@@ -183,7 +229,7 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> {
}
// Determine scopes: explicit flags > interactive TUI > defaults
let scopes = resolve_scopes(args, project_id.as_deref()).await;
let scopes = resolve_scopes(&filtered_args, project_id.as_deref()).await;
let secret = yup_oauth2::ApplicationSecret {
client_id: client_id.clone(),
@@ -216,7 +262,9 @@ 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))
.flow_delegate(Box::new(CliFlowDelegate {
login_hint: account_email.clone(),
}))
.build()
.await
.map_err(|e| GwsError::Auth(format!("Failed to build authenticator: {e}")))?;
@@ -255,9 +303,63 @@ 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}")))?;
// Save encrypted
let enc_path = credential_store::save_encrypted(&creds_str)
.map_err(|e| GwsError::Auth(format!("Failed to encrypt credentials: {e}")))?;
// Fetch the user's email from Google userinfo to validate and register
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(&registry)
.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);
}
}
// Clean up temp file
let _ = std::fs::remove_file(&temp_path);
@@ -265,6 +367,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)"),
"credentials_file": enc_path.display().to_string(),
"encryption": "AES-256-GCM (key secured by OS Keyring or local `.encryption_key`)",
"scopes": scopes,
@@ -283,6 +386,27 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> {
}
}
/// Fetch the authenticated user's email from Google's userinfo endpoint.
async fn fetch_userinfo_email(access_token: &str) -> Option<String> {
let client = match crate::client::build_client() {
Ok(c) => c,
Err(_) => return None,
};
let resp = client
.get("https://www.googleapis.com/oauth2/v2/userinfo")
.bearer_auth(access_token)
.send()
.await
.ok()?;
if !resp.status().is_success() {
return None;
}
let body: serde_json::Value = resp.json().await.ok()?;
body.get("email")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
async fn handle_export(unmasked: bool) -> Result<(), GwsError> {
let enc_path = credential_store::encrypted_credentials_path();
if !enc_path.exists() {
@@ -914,34 +1038,198 @@ 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 mut removed = Vec::new();
for path in [&enc_path, &plain_path, &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());
fn handle_logout(args: &[String]) -> Result<(), GwsError> {
// Extract --account from args
let mut account_email: Option<String> = 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());
}
}
let output = if removed.is_empty() {
json!({
"status": "success",
"message": "No credentials found to remove.",
})
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(&registry)
.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 {
json!({
"status": "success",
"message": "Logged out. Credentials and token cache removed.",
"removed": removed,
// 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();
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());
}
}
// 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());
}
}
}
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(&registry);
let accounts: Vec<serde_json::Value> = 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(""),
})
})
};
.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<String> = 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 <email> or gws auth default --account <email>".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(&registry)
.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,
});
println!(
"{}",
+91 -2
View File
@@ -227,7 +227,13 @@ pub fn save_encrypted(json: &str) -> anyhow::Result<PathBuf> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
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()
);
}
}
}
@@ -242,7 +248,12 @@ pub fn save_encrypted(json: &str) -> anyhow::Result<PathBuf> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
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(path)
@@ -260,6 +271,52 @@ pub fn load_encrypted() -> anyhow::Result<String> {
load_encrypted_from_path(&encrypted_credentials_path())
}
/// Returns the path for per-account encrypted credentials.
///
/// The filename is `credentials.<b64-email>.enc` where `<b64-email>` 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"))
}
/// Saves credentials JSON to a per-account encrypted file.
pub fn save_encrypted_for(json: &str, account: &str) -> anyhow::Result<PathBuf> {
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()
);
}
}
}
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()
);
}
}
Ok(path)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -367,4 +424,36 @@ mod tests {
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"
);
}
}
+2 -2
View File
@@ -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).await {
let (token, auth_method) = match auth::get_token(&scopes_str, None).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])
let token = auth::get_token(&[cal_scope], None)
.await
.map_err(|e| GwsError::Auth(format!("Calendar auth failed: {e}")))?;
+1 -1
View File
@@ -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).await {
let (token, auth_method) = match auth::get_token(&scope_strs, None).await {
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
Err(_) => (None, executor::AuthMethod::None),
};
+1 -1
View File
@@ -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).await {
let (token, auth_method) = match auth::get_token(&scope_strs, None).await {
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
Err(_) => (None, executor::AuthMethod::None),
};
+1 -1
View File
@@ -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).await {
let (token, auth_method) = match auth::get_token(&scopes, None).await {
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
Err(_) => (None, executor::AuthMethod::None),
};
+1 -1
View File
@@ -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])
let ws_token = auth::get_token(&[WORKSPACE_EVENTS_SCOPE], None)
.await
.map_err(|e| GwsError::Auth(format!("Failed to get token: {e}")))?;
+2 -2
View File
@@ -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])
let pubsub_token = auth::get_token(&[PUBSUB_SCOPE], None)
.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])
let ws_token = auth::get_token(&[WORKSPACE_EVENTS_SCOPE], None)
.await
.map_err(|e| {
GwsError::Auth(format!("Failed to get Workspace Events token: {e}"))
+1 -1
View File
@@ -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).await {
let (token, auth_method) = match auth::get_token(&scopes, None).await {
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
Err(_) => (None, executor::AuthMethod::None),
};
+1 -1
View File
@@ -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])
let token = auth::get_token(&[GMAIL_SCOPE], None)
.await
.map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?;
+2 -2
View File
@@ -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])
let gmail_token = auth::get_token(&[GMAIL_SCOPE], None)
.await
.context("Failed to get Gmail token")?;
let pubsub_token = auth::get_token(&[PUBSUB_SCOPE])
let pubsub_token = auth::get_token(&[PUBSUB_SCOPE], None)
.await
.context("Failed to get Pub/Sub token")?;
+2 -2
View File
@@ -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<SanitizationResult, GwsError> {
let (body, url) = build_sanitize_request_data(template, text, "sanitizeUserPrompt")?;
let token = auth::get_token(&[CLOUD_PLATFORM_SCOPE])
let token = auth::get_token(&[CLOUD_PLATFORM_SCOPE], None)
.await
.context("Failed to get auth token for Model Armor")?;
@@ -281,7 +281,7 @@ pub async fn sanitize_text(template: &str, text: &str) -> Result<SanitizationRes
/// Make a POST request to Model Armor's regional API endpoint.
async fn model_armor_post(url: &str, body: &str) -> Result<(), GwsError> {
let token = auth::get_token(&[CLOUD_PLATFORM_SCOPE])
let token = auth::get_token(&[CLOUD_PLATFORM_SCOPE], None)
.await
.context("Failed to get auth token")?;
+1 -1
View File
@@ -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).await {
let (token, auth_method) = match auth::get_token(&scopes, None).await {
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
Err(_) => (None, executor::AuthMethod::None),
};
+2 -2
View File
@@ -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).await {
let (token, auth_method) = match auth::get_token(&scope_strs, None).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).await {
let (token, auth_method) = match auth::get_token(&scope_strs, None).await {
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
Err(_) => (None, executor::AuthMethod::None),
};
+5 -5
View File
@@ -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])
let token = auth::get_token(&[cal_scope, tasks_scope], None)
.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])
let token = auth::get_token(&[cal_scope], None)
.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])
let token = auth::get_token(&[gmail_scope, tasks_scope], None)
.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])
let token = auth::get_token(&[cal_scope, gmail_scope], None)
.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])
let token = auth::get_token(&[drive_scope, chat_scope], None)
.await
.map_err(|e| GwsError::Auth(format!("Auth failed: {e}")))?;
+208 -7
View File
@@ -19,6 +19,7 @@
//! 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;
@@ -57,23 +58,53 @@ async fn main() {
async fn run() -> Result<(), GwsError> {
let args: Vec<String> = 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 <service> <resource> [sub-resource] <method> [flags]"
"No service specified. Usage: gws [--account EMAIL] <service> <resource> [sub-resource] <method> [flags]"
.to_string(),
));
}
let first_arg = &args[1];
// Find the first non-flag arg (skip --account and its value)
let mut first_arg: Option<String> = None;
{
let mut skip_next = false;
for a in args.iter().skip(1) {
if skip_next {
skip_next = false;
continue;
}
if a == "--account" {
skip_next = true;
continue;
}
if a.starts_with("--account=") {
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(|| GwsError::Validation(
"No service specified. Usage: gws [--account EMAIL] <service> <resource> [sub-resource] <method> [flags]"
.to_string(),
))?;
// Handle --help and --version at top level
if is_help_flag(first_arg) {
if is_help_flag(&first_arg) {
print_usage();
return Ok(());
}
if is_version_flag(first_arg) {
if is_version_flag(&first_arg) {
println!("gws {}", env!("CARGO_PKG_VERSION"));
println!("This is not an officially supported Google product.");
return Ok(());
@@ -112,7 +143,7 @@ async fn run() -> Result<(), GwsError> {
}
// Parse service name and optional version override
let (api_name, version) = parse_service_and_version(&args, first_arg)?;
let (api_name, version) = parse_service_and_version(&args, &first_arg)?;
// For synthetic services (no Discovery doc), use an empty RestDescription
let doc = if api_name == "workflow" {
@@ -205,7 +236,7 @@ async fn run() -> Result<(), GwsError> {
let scopes: Vec<&str> = method.scopes.iter().map(|s| s.as_str()).collect();
// Authenticate: try OAuth, otherwise proceed unauthenticated
let (token, auth_method) = match auth::get_token(&scopes).await {
let (token, auth_method) = match auth::get_token(&scopes, account.as_deref()).await {
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
Err(_) => (None, executor::AuthMethod::None),
};
@@ -274,10 +305,13 @@ pub fn filter_args_for_subcommand(args: &[String]) -> Vec<String> {
skip_next = false;
continue;
}
if arg == "--api-version" {
if arg == "--api-version" || arg == "--account" {
skip_next = true;
continue;
}
if arg.starts_with("--account=") || arg.starts_with("--api-version=") {
continue;
}
sub_args.push(arg.clone());
}
sub_args
@@ -393,6 +427,9 @@ fn print_usage() {
println!(
" GOOGLE_WORKSPACE_CLI_CLIENT_SECRET OAuth client secret (for gws auth login)"
);
println!(
" GOOGLE_WORKSPACE_CLI_ACCOUNT Default account email for multi-account"
);
println!();
println!("COMMUNITY:");
println!(" Star the repo: https://github.com/googleworkspace/cli");
@@ -403,6 +440,20 @@ fn print_usage() {
println!(" This is not an officially supported Google product.");
}
/// Extract --account value from raw CLI args (before clap parsing).
/// Supports both `--account EMAIL` and `--account=EMAIL` syntax.
fn extract_global_account(args: &[String]) -> Option<String> {
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")
}
@@ -571,4 +622,154 @@ mod tests {
let (method, _) = resolve_method_from_matches(&doc, &matches).unwrap();
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<String> = vec![
"gws".into(),
"drive".into(),
"--account".into(),
"user@corp.com".into(),
"files".into(),
"list".into(),
];
let filtered = filter_args_for_subcommand(&args);
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<String> = vec![
"gws".into(),
"drive".into(),
"--api-version".into(),
"v3".into(),
"files".into(),
"list".into(),
];
let filtered = filter_args_for_subcommand(&args);
assert_eq!(filtered, vec!["gws", "files", "list"]);
}
#[test]
fn test_filter_args_strips_both_account_and_api_version() {
let args: Vec<String> = 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);
assert_eq!(filtered, vec!["gws", "files", "list"]);
}
#[test]
fn test_filter_args_no_special_flags() {
let args: Vec<String> = vec![
"gws".into(),
"drive".into(),
"files".into(),
"list".into(),
"--format".into(),
"table".into(),
];
let filtered = filter_args_for_subcommand(&args);
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<String> = vec![
"gws".into(),
"drive".into(),
"--account".into(),
"work@corp.com".into(),
"files".into(),
"list".into(),
];
let filtered = filter_args_for_subcommand(&args);
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_strips_account_equals() {
let args: Vec<String> = vec![
"gws".into(),
"drive".into(),
"--account=a@b.com".into(),
"files".into(),
"list".into(),
];
let filtered = filter_args_for_subcommand(&args);
assert!(!filtered.iter().any(|a| a.contains("account")));
assert_eq!(filtered, vec!["gws", "files", "list"]);
}
}
+1 -1
View File
@@ -407,7 +407,7 @@ async fn handle_tools_call(params: &Value, config: &ServerConfig) -> Result<Valu
};
let scopes: Vec<&str> = method.scopes.iter().map(|s| s.as_str()).collect();
let (token, auth_method) = match crate::auth::get_token(&scopes).await {
let (token, auth_method) = match crate::auth::get_token(&scopes, None).await {
Ok(t) => (Some(t), crate::executor::AuthMethod::OAuth),
Err(_) => (None, crate::executor::AuthMethod::None),
};