-
Notifications
You must be signed in to change notification settings - Fork 1k
fix(auth): enforce readonly scopes by revoking stale tokens and adding client-side guard #520
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
14d3fd8
e15724f
80b47e7
ec96752
14a2dfe
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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!( | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Printing the To mitigate this, you should use the eprintln!(
"Warning: could not revoke old token ({:?}). \
The old token may still be valid on Google's side.",
e
);References
|
||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| // 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<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")); | ||
| } | ||
gerfalcon marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.