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
155 changes: 154 additions & 1 deletion src/auth_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,54 @@ 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.
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(&scopes_path(), json.as_bytes())
.map_err(|e| GwsError::Validation(format!("Failed to save scopes file: {e}")))?;
Ok(())
}

/// Load the previously saved scope set, if any.
pub fn load_saved_scopes() -> Option<Vec<String>> {
let data = std::fs::read_to_string(scopes_path()).ok()?;
serde_json::from_str(&data).ok()
}

/// 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"
}

/// Returns true if the saved scopes are all read-only.
pub fn is_readonly_session() -> bool {
load_saved_scopes()
.map(|scopes| scopes.iter().all(|s| is_non_write_scope(s)))
.unwrap_or(false)
}

/// Check if a requested scope is compatible with the current session.
///
/// In a readonly session, write-scope requests are rejected with a clear error.
pub fn check_scope_allowed(scope: &str) -> Result<(), GwsError> {
if is_readonly_session() && !is_non_write_scope(scope) {
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 @@ -275,6 +323,63 @@ 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() {
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 {
// Revoke the old refresh token so Google removes the prior consent grant.
if let Ok(creds_str) = credential_store::load_encrypted() {
if let Ok(creds) = serde_json::from_str::<serde_json::Value>(&creds_str) {
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 ({e}). \
The old token may still be valid on Google's side."
);
Comment on lines +354 to +357
Copy link
Contributor

Choose a reason for hiding this comment

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

security-high high

Printing the reqwest::Error directly to the terminal using the default Display trait can be a security risk. If the error is related to DNS resolution or other network issues where an attacker can control parts of the error message, it could lead to terminal escape sequence injection. This could be used to trick the user or hide other malicious activity on the terminal.

To mitigate this, you should use the Debug format specifier ({:?}), which is designed for developer output and will safely escape control characters.

                                eprintln!(
                                    "Warning: could not revoke old token ({:?}). \
                                     The old token may still be valid on Google's side.",
                                    e
                                );
References
  1. Sanitize error strings printed to the terminal to prevent escape sequence injection.

}
}
}
}
}
// Clear local credential and cache files to force a fresh login.
let enc_path = credential_store::encrypted_credentials_path();
if let Err(e) = std::fs::remove_file(&enc_path) {
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) = std::fs::remove_file(token_cache_path()) {
if e.kind() != std::io::ErrorKind::NotFound {
return Err(GwsError::Auth(format!(
"Failed to remove old token cache: {e}. Please remove it manually."
)));
}
}
eprintln!("Scopes changed — revoked previous credentials.");
}
}

// Ensure openid + email scopes are always present so we can identify the user
// via the userinfo endpoint after login.
let identity_scopes = ["openid", "https://www.googleapis.com/auth/userinfo.email"];
Expand Down Expand Up @@ -351,6 +456,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)?;

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

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

// Show configured scope mode from scopes.json (independent of network)
if let Some(saved) = load_saved_scopes() {
output["configured_scopes"] = json!(saved);
output["scope_mode"] = json!(if is_readonly_session() {
"readonly"
} else {
"default"
});
}

println!(
"{}",
serde_json::to_string_pretty(&output).unwrap_or_default()
Expand All @@ -1179,10 +1298,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 @@ -2208,4 +2328,37 @@ 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("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"));
}
}
5 changes: 5 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,11 @@ async fn run() -> Result<(), GwsError> {
// to avoid restrictive scopes like gmail.metadata that block query parameters.
let scopes: Vec<&str> = select_scope(&method.scopes).into_iter().collect();

// Enforce readonly session: reject write scopes if user logged in with --readonly
if let Some(scope) = scopes.first() {
auth_commands::check_scope_allowed(scope)?;
}

// Authenticate: try OAuth, fail with error if credentials exist but are broken
let (token, auth_method) = match auth::get_token(&scopes).await {
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
Expand Down
Loading