diff --git a/.changeset/fix-readonly-scope-enforcement.md b/.changeset/fix-readonly-scope-enforcement.md new file mode 100644 index 00000000..e35b967a --- /dev/null +++ b/.changeset/fix-readonly-scope-enforcement.md @@ -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 diff --git a/src/auth_commands.rs b/src/auth_commands.rs index f51ba6dd..ea12de13 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -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> { + 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 `. pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> { const USAGE: &str = concat!( @@ -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::(&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." + ); + } + } + } + } + } + // 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"]; @@ -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); @@ -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() @@ -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())) @@ -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 = + 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")); + } } diff --git a/src/main.rs b/src/main.rs index bd72c642..976b51af 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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),