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:
@@ -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.
|
||||
@@ -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`
|
||||
|
||||
@@ -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
@@ -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(®istry), 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(®istry).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(®istry), 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(®istry).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
@@ -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, ®istry) {
|
||||
// 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
@@ -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(®istry)
|
||||
.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(®istry)
|
||||
.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(®istry);
|
||||
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(®istry)
|
||||
.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
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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}")))?;
|
||||
|
||||
|
||||
@@ -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}"))
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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}")))?;
|
||||
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user