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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/fix-readonly-scope-enforcement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@googleworkspace/cli": patch
---

fix(auth): enforce readonly scopes by revoking stale tokens on scope change and adding client-side guard

Fixes #168
5 changes: 4 additions & 1 deletion src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,10 @@ impl AccessTokenProvider for FakeTokenProvider {
/// - Well-known ADC path: `~/.config/gcloud/application_default_credentials.json`
/// (populated by `gcloud auth application-default login`)
pub async fn get_token(scopes: &[&str]) -> anyhow::Result<String> {
// 0. Direct token from env var (highest priority, bypasses all credential loading)
// 0. Enforce readonly session: reject write scopes if user logged in with --readonly
crate::auth_commands::check_scopes_allowed(scopes).await?;

// 1. 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() {
return Ok(token);
Expand Down
212 changes: 211 additions & 1 deletion src/auth_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,80 @@ fn token_cache_path() -> PathBuf {
config_dir().join("token_cache.json")
}

fn scopes_path() -> PathBuf {
config_dir().join("scopes.json")
}

/// Save the configured scope set so scope changes can be detected across sessions.
async fn save_scopes(scopes: &[String]) -> Result<(), GwsError> {
let json = serde_json::to_string_pretty(scopes)
.map_err(|e| GwsError::Validation(format!("Failed to serialize scopes: {e}")))?;
crate::fs_util::atomic_write_async(&scopes_path(), json.as_bytes())
.await
.map_err(|e| GwsError::Validation(format!("Failed to save scopes file: {e}")))?;
Ok(())
}

/// Load the previously saved scope set, if any.
///
/// Returns `Ok(None)` if `scopes.json` does not exist, `Ok(Some(...))` on
/// success, or `Err` if the file exists but is unreadable or contains invalid
/// JSON. This ensures a corrupt file is surfaced as an error rather than
/// silently disabling the readonly guard.
pub async fn load_saved_scopes() -> Result<Option<Vec<String>>, GwsError> {
let path = scopes_path();
match tokio::fs::read_to_string(&path).await {
Ok(data) => {
let scopes: Vec<String> = serde_json::from_str(&data).map_err(|e| {
GwsError::Validation(format!("Failed to parse {}: {e}", path.display()))
})?;
Ok(Some(scopes))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(GwsError::Validation(format!(
"Failed to read {}: {e}",
path.display()
))),
}
}

/// Returns true if a scope does not grant write access (identity or .readonly scopes).
fn is_non_write_scope(scope: &str) -> bool {
scope.ends_with(".readonly")
|| scope == "openid"
|| scope.starts_with("https://www.googleapis.com/auth/userinfo.")
|| scope == "email"
|| scope == "profile"
}

/// Returns true if the saved scopes are all read-only.
///
/// Propagates errors from `load_saved_scopes` (corrupt file).
/// Returns `false` if no scopes file exists.
pub async fn is_readonly_session() -> Result<bool, GwsError> {
Ok(load_saved_scopes()
.await?
.map(|scopes| scopes.iter().all(|s| is_non_write_scope(s)))
.unwrap_or(false))
}

/// Check if the requested scopes are compatible with the current session.
///
/// In a readonly session, write-scope requests are rejected with a clear error.
/// Reads `scopes.json` once for the entire batch.
pub async fn check_scopes_allowed(scopes: &[&str]) -> Result<(), GwsError> {
if is_readonly_session().await? {
if let Some(scope) = scopes.iter().find(|s| !is_non_write_scope(s)) {
return Err(GwsError::Auth(format!(
"This operation requires scope '{}' (write access), but the current session \
uses read-only scopes. Run `gws auth login` (without --readonly) to upgrade.",
scope
)));
}
}
Ok(())
}

/// Handle `gws auth <subcommand>`.
pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> {
const USAGE: &str = concat!(
Expand Down Expand Up @@ -210,6 +284,63 @@ impl yup_oauth2::authenticator_delegate::InstalledFlowDelegate for CliFlowDelega
}
}

/// Attempt to revoke the old refresh token via Google's revocation endpoint.
///
/// Best-effort: warns on failure but does not return an error, since the
/// subsequent credential cleanup and fresh login will proceed regardless.
/// Kept as sync `load_encrypted()` intentionally — the macOS Keychain ties
/// ACL permissions to the calling thread, and `spawn_blocking` causes
/// repeated Keychain prompts.
async fn attempt_token_revocation() {
let creds_str = match credential_store::load_encrypted() {
Ok(s) => s,
Err(e) => {
eprintln!(
"Warning: could not load credentials ({}). Old token was not revoked.",
crate::output::sanitize_for_terminal(&e.to_string())
);
return;
}
};

let creds: serde_json::Value = match serde_json::from_str(&creds_str) {
Ok(j) => j,
Err(e) => {
eprintln!(
"Warning: could not parse credentials ({}). Old token was not revoked.",
crate::output::sanitize_for_terminal(&e.to_string())
);
return;
}
};

if let Some(rt) = creds.get("refresh_token").and_then(|v| v.as_str()) {
let client = reqwest::Client::new();
match client
.post("https://oauth2.googleapis.com/revoke")
.form(&[("token", rt)])
.send()
.await
{
Ok(resp) if resp.status().is_success() => {}
Ok(resp) => {
eprintln!(
"Warning: token revocation returned HTTP {}. \
The old token may still be valid on Google's side.",
resp.status()
);
}
Err(e) => {
eprintln!(
"Warning: could not revoke old token ({}). \
The old token may still be valid on Google's side.",
crate::output::sanitize_for_terminal(&e.to_string())
);
}
}
}
}

async fn handle_login(args: &[String]) -> Result<(), GwsError> {
// Extract -s/--services from args
let mut services_filter: Option<HashSet<String>> = None;
Expand Down Expand Up @@ -275,6 +406,35 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> {
..Default::default()
};

// If scopes changed from the previous login, revoke the old refresh token
// so Google removes the prior consent grant. Without revocation, Google's
// consent screen shows previously-granted scopes pre-checked and the user
// may unknowingly re-grant broad access.
if let Some(prev_scopes) = load_saved_scopes().await? {
let prev_set: HashSet<&str> = prev_scopes.iter().map(|s| s.as_str()).collect();
let new_set: HashSet<&str> = scopes.iter().map(|s| s.as_str()).collect();
if prev_set != new_set {
attempt_token_revocation().await;
// Clear local credential and cache files to force a fresh login.
let enc_path = credential_store::encrypted_credentials_path();
if let Err(e) = tokio::fs::remove_file(&enc_path).await {
if e.kind() != std::io::ErrorKind::NotFound {
return Err(GwsError::Auth(format!(
"Failed to remove old credentials file: {e}. Please remove it manually."
)));
}
}
if let Err(e) = tokio::fs::remove_file(token_cache_path()).await {
if e.kind() != std::io::ErrorKind::NotFound {
return Err(GwsError::Auth(format!(
"Failed to remove old token cache: {e}. Please remove it manually."
)));
}
}
Comment on lines +420 to +433
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The error messages for failing to remove the old credentials and token cache files should include the file paths. This will help users with manual cleanup if the automatic removal fails by telling them exactly which file to remove.

            if let Err(e) = tokio::fs::remove_file(&enc_path).await {
                if e.kind() != std::io::ErrorKind::NotFound {
                    return Err(GwsError::Auth(format!(
                        "Failed to remove old credentials file '{}': {e}. Please remove it manually.",
                        enc_path.display()
                    )));
                }
            }
            let token_path = token_cache_path();
            if let Err(e) = tokio::fs::remove_file(&token_path).await {
                if e.kind() != std::io::ErrorKind::NotFound {
                    return Err(GwsError::Auth(format!(
                        "Failed to remove old token cache '{}': {e}. Please remove it manually.",
                        token_path.display()
                    )));
                }
            }

eprintln!("Scopes changed — revoked previous credentials.");
}
}

// Ensure openid + email + profile scopes are always present so we can
// identify the user via the userinfo endpoint after login, and so the
// Gmail helpers can fall back to the People API to populate the From
Expand Down Expand Up @@ -357,6 +517,10 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> {
let enc_path = credential_store::save_encrypted(&creds_str)
.map_err(|e| GwsError::Auth(format!("Failed to encrypt credentials: {e}")))?;

// Persist the configured scope set for scope-change detection and
// client-side guard enforcement.
save_scopes(&scopes).await?;

// Clean up temp file
let _ = std::fs::remove_file(&temp_path);

Expand Down Expand Up @@ -1173,6 +1337,17 @@ async fn handle_status() -> Result<(), GwsError> {
}
} // end !cfg!(test)

// Show configured scope mode from scopes.json (independent of network)
if let Some(saved_scopes) = load_saved_scopes().await? {
let is_readonly = saved_scopes.iter().all(|s| is_non_write_scope(s));
output["configured_scopes"] = json!(saved_scopes);
output["scope_mode"] = json!(if is_readonly {
"readonly"
} else {
"default"
});
}

println!(
"{}",
serde_json::to_string_pretty(&output).unwrap_or_default()
Expand All @@ -1185,10 +1360,11 @@ fn handle_logout() -> Result<(), GwsError> {
let enc_path = credential_store::encrypted_credentials_path();
let token_cache = token_cache_path();
let sa_token_cache = config_dir().join("sa_token_cache.json");
let scopes_file = scopes_path();

let mut removed = Vec::new();

for path in [&enc_path, &plain_path, &token_cache, &sa_token_cache] {
for path in [&enc_path, &plain_path, &token_cache, &sa_token_cache, &scopes_file] {
if path.exists() {
std::fs::remove_file(path).map_err(|e| {
GwsError::Validation(format!("Failed to remove {}: {e}", path.display()))
Expand Down Expand Up @@ -2214,4 +2390,38 @@ mod tests {
let result = extract_scopes_from_doc(&doc, false);
assert!(result.is_empty());
}

// --- Scope persistence and guard tests ---

#[test]
fn test_save_and_load_scopes_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("scopes.json");
let scopes = vec![
"https://www.googleapis.com/auth/gmail.readonly".to_string(),
"openid".to_string(),
];
let json = serde_json::to_string_pretty(&scopes).unwrap();
crate::fs_util::atomic_write(&path, json.as_bytes()).unwrap();
let loaded: Vec<String> =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(loaded, scopes);
}

#[test]
fn test_is_non_write_scope() {
// Readonly and identity scopes are non-write
assert!(is_non_write_scope("https://www.googleapis.com/auth/drive.readonly"));
assert!(is_non_write_scope("https://www.googleapis.com/auth/gmail.readonly"));
assert!(is_non_write_scope("openid"));
assert!(is_non_write_scope("email"));
assert!(is_non_write_scope("profile"));
assert!(is_non_write_scope("https://www.googleapis.com/auth/userinfo.email"));

// Write scopes are not non-write
assert!(!is_non_write_scope("https://www.googleapis.com/auth/drive"));
assert!(!is_non_write_scope("https://www.googleapis.com/auth/gmail.modify"));
assert!(!is_non_write_scope("https://www.googleapis.com/auth/calendar"));
assert!(!is_non_write_scope("https://www.googleapis.com/auth/pubsub"));
}
}
2 changes: 2 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,8 @@ async fn run() -> Result<(), GwsError> {
let scopes: Vec<&str> = select_scope(&method.scopes).into_iter().collect();

// Authenticate: try OAuth, fail with error if credentials exist but are broken
// Note: readonly scope guard is enforced inside auth::get_token() so it also
// covers helper commands that call get_token() directly.
let (token, auth_method) = match auth::get_token(&scopes).await {
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
Err(e) => {
Expand Down
Loading