Skip to content

Commit d1ed759

Browse files
committed
fix(auth): enforce readonly scopes by revoking stale tokens and adding client-side guard
Rebased on main after workspace refactor (#613). All changes now target crates/google-workspace-cli/src/. - Persist configured scopes to scopes.json on login - Revoke old refresh token when scopes change (extracted into attempt_token_revocation helper) - Client-side scope guard in auth::get_token blocks write ops in readonly sessions (covers helpers like +send) - load_saved_scopes returns Result to surface corrupt scopes.json - Show scope_mode in auth status - Clean up scopes.json on logout - Sanitize error output via sanitize_for_terminal - Add 'profile' as non-write scope alias Fixes #168
1 parent a3768d0 commit d1ed759

3 files changed

Lines changed: 241 additions & 2 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@googleworkspace/cli": patch
3+
---
4+
5+
fix(auth): enforce readonly scopes by revoking stale tokens and adding client-side guard
6+
7+
Fixes #168

crates/google-workspace-cli/src/auth.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,12 @@ impl AccessTokenProvider for FakeTokenProvider {
212212
/// - Well-known ADC path: `~/.config/gcloud/application_default_credentials.json`
213213
/// (populated by `gcloud auth application-default login`)
214214
pub async fn get_token(scopes: &[&str]) -> anyhow::Result<String> {
215-
// 0. Direct token from env var (highest priority, bypasses all credential loading)
215+
// 0. Enforce readonly session: reject write scopes if user logged in with --readonly
216+
// Note: readonly scope guard is enforced here so it also covers helper commands
217+
// that call get_token() directly (e.g. +send, +triage).
218+
crate::auth_commands::check_scopes_allowed(scopes).await?;
219+
220+
// 1. Direct token from env var (highest priority, bypasses all credential loading)
216221
if let Ok(token) = std::env::var("GOOGLE_WORKSPACE_CLI_TOKEN") {
217222
if !token.is_empty() {
218223
return Ok(token);

crates/google-workspace-cli/src/auth_commands.rs

Lines changed: 228 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,147 @@ fn token_cache_path() -> PathBuf {
345345
config_dir().join("token_cache.json")
346346
}
347347

348+
fn scopes_path() -> PathBuf {
349+
config_dir().join("scopes.json")
350+
}
351+
352+
/// Save the configured scope set so scope changes can be detected across sessions.
353+
async fn save_scopes(scopes: &[String]) -> Result<(), GwsError> {
354+
let json = serde_json::to_string_pretty(scopes)
355+
.map_err(|e| GwsError::Validation(format!("Failed to serialize scopes: {e}")))?;
356+
crate::fs_util::atomic_write_async(&scopes_path(), json.as_bytes())
357+
.await
358+
.map_err(|e| GwsError::Validation(format!("Failed to save scopes file: {e}")))?;
359+
Ok(())
360+
}
361+
362+
/// Load the previously saved scope set, if any.
363+
///
364+
/// Returns `Ok(None)` if `scopes.json` does not exist, `Ok(Some(...))` on
365+
/// success, or `Err` if the file exists but is unreadable or contains invalid
366+
/// JSON.
367+
pub async fn load_saved_scopes() -> Result<Option<Vec<String>>, GwsError> {
368+
let path = scopes_path();
369+
match tokio::fs::read_to_string(&path).await {
370+
Ok(data) => {
371+
let scopes: Vec<String> = serde_json::from_str(&data).map_err(|e| {
372+
GwsError::Validation(format!("Failed to parse {}: {e}", path.display()))
373+
})?;
374+
Ok(Some(scopes))
375+
}
376+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
377+
Err(e) => Err(GwsError::Validation(format!(
378+
"Failed to read {}: {e}",
379+
path.display()
380+
))),
381+
}
382+
}
383+
384+
/// Returns true if a scope does not grant write access (identity or .readonly scopes).
385+
fn is_non_write_scope(scope: &str) -> bool {
386+
scope.ends_with(".readonly")
387+
|| scope == "openid"
388+
|| scope.starts_with("https://www.googleapis.com/auth/userinfo.")
389+
|| scope == "email"
390+
|| scope == "profile"
391+
}
392+
393+
/// Returns true if the saved scopes are all read-only.
394+
///
395+
/// The result is cached for the lifetime of the process to avoid reading
396+
/// `scopes.json` on every API call.
397+
pub async fn is_readonly_session() -> Result<bool, GwsError> {
398+
static CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
399+
if let Some(&val) = CACHE.get() {
400+
return Ok(val);
401+
}
402+
let res = load_saved_scopes()
403+
.await?
404+
.map(|scopes| scopes.iter().all(|s| is_non_write_scope(s)))
405+
.unwrap_or(false);
406+
let _ = CACHE.set(res);
407+
Ok(res)
408+
}
409+
410+
/// Check if the requested scopes are compatible with the current session.
411+
///
412+
/// In a readonly session, write-scope requests are rejected with a clear error.
413+
pub async fn check_scopes_allowed(scopes: &[&str]) -> Result<(), GwsError> {
414+
if is_readonly_session().await? {
415+
if let Some(scope) = scopes.iter().find(|s| !is_non_write_scope(s)) {
416+
return Err(GwsError::Auth(format!(
417+
"This operation requires scope '{}' (write access), but the current session \
418+
uses read-only scopes. Run `gws auth login` (without --readonly) to upgrade.",
419+
scope
420+
)));
421+
}
422+
}
423+
Ok(())
424+
}
425+
426+
/// Attempt to revoke the old refresh token via Google's revocation endpoint.
427+
///
428+
/// Best-effort: warns on failure but does not return an error, since the
429+
/// subsequent credential cleanup and fresh login will proceed regardless.
430+
async fn attempt_token_revocation() {
431+
let creds_str = match credential_store::load_encrypted() {
432+
Ok(s) => s,
433+
Err(e) => {
434+
eprintln!(
435+
"Warning: could not load credentials ({}). Old token was not revoked.",
436+
crate::output::sanitize_for_terminal(&e.to_string())
437+
);
438+
return;
439+
}
440+
};
441+
442+
let creds: serde_json::Value = match serde_json::from_str(&creds_str) {
443+
Ok(j) => j,
444+
Err(e) => {
445+
eprintln!(
446+
"Warning: could not parse credentials ({}). Old token was not revoked.",
447+
crate::output::sanitize_for_terminal(&e.to_string())
448+
);
449+
return;
450+
}
451+
};
452+
453+
if let Some(rt) = creds.get("refresh_token").and_then(|v| v.as_str()) {
454+
let client = match crate::client::shared_client() {
455+
Ok(c) => c,
456+
Err(e) => {
457+
eprintln!(
458+
"Warning: could not build HTTP client ({}). Old token was not revoked.",
459+
crate::output::sanitize_for_terminal(&e.to_string())
460+
);
461+
return;
462+
}
463+
};
464+
match client
465+
.post("https://oauth2.googleapis.com/revoke")
466+
.form(&[("token", rt)])
467+
.send()
468+
.await
469+
{
470+
Ok(resp) if resp.status().is_success() => {}
471+
Ok(resp) => {
472+
eprintln!(
473+
"Warning: token revocation returned HTTP {}. \
474+
The old token may still be valid on Google's side.",
475+
resp.status()
476+
);
477+
}
478+
Err(e) => {
479+
eprintln!(
480+
"Warning: could not revoke old token ({}). \
481+
The old token may still be valid on Google's side.",
482+
crate::output::sanitize_for_terminal(&e.to_string())
483+
);
484+
}
485+
}
486+
}
487+
}
488+
348489
/// Which scope set to use for login.
349490
enum ScopeMode {
350491
/// Use the default scopes (MINIMAL_SCOPES).
@@ -598,6 +739,42 @@ async fn handle_login_inner(
598739
// Remove restrictive scopes when broader alternatives are present.
599740
let mut scopes = filter_redundant_restrictive_scopes(scopes);
600741

742+
// If scopes changed from the previous login, revoke the old refresh token
743+
// so Google removes the prior consent grant. Without revocation, Google's
744+
// consent screen shows previously-granted scopes pre-checked and the user
745+
// may unknowingly re-grant broad access.
746+
if let Some(prev_scopes) = load_saved_scopes().await? {
747+
// Filter out identity scopes from both sets — they are auto-added after
748+
// this comparison, so including them would cause a false mismatch on
749+
// every login (prev_scopes has them, current scopes don't yet).
750+
let prev_set: HashSet<&str> = prev_scopes
751+
.iter()
752+
.map(|s| s.as_str())
753+
.filter(|s| !is_non_write_scope(s) || s.ends_with(".readonly"))
754+
.collect();
755+
let new_set: HashSet<&str> = scopes.iter().map(|s| s.as_str()).collect();
756+
if prev_set != new_set {
757+
attempt_token_revocation().await;
758+
// Clear local credential and cache files to force a fresh login.
759+
let enc_path = credential_store::encrypted_credentials_path();
760+
if let Err(e) = tokio::fs::remove_file(&enc_path).await {
761+
if e.kind() != std::io::ErrorKind::NotFound {
762+
return Err(GwsError::Auth(format!(
763+
"Failed to remove old credentials file: {e}. Please remove it manually."
764+
)));
765+
}
766+
}
767+
if let Err(e) = tokio::fs::remove_file(token_cache_path()).await {
768+
if e.kind() != std::io::ErrorKind::NotFound {
769+
return Err(GwsError::Auth(format!(
770+
"Failed to remove old token cache: {e}. Please remove it manually."
771+
)));
772+
}
773+
}
774+
eprintln!("Scopes changed — revoked previous credentials.");
775+
}
776+
}
777+
601778
// Ensure openid + email + profile scopes are always present so we can
602779
// identify the user via the userinfo endpoint after login, and so the
603780
// Gmail helpers can fall back to the People API to populate the From
@@ -644,6 +821,10 @@ async fn handle_login_inner(
644821
let enc_path = credential_store::save_encrypted(&creds_str)
645822
.map_err(|e| GwsError::Auth(format!("Failed to encrypt credentials: {e}")))?;
646823

824+
// Persist the configured scope set for scope-change detection and
825+
// client-side guard enforcement.
826+
save_scopes(&scopes).await?;
827+
647828
let output = json!({
648829
"status": "success",
649830
"message": "Authentication successful. Encrypted credentials saved.",
@@ -1446,6 +1627,17 @@ async fn handle_status() -> Result<(), GwsError> {
14461627
}
14471628
} // end !cfg!(test)
14481629

1630+
// Show configured scope mode from scopes.json (independent of network)
1631+
if let Some(saved_scopes) = load_saved_scopes().await? {
1632+
let is_readonly = saved_scopes.iter().all(|s| is_non_write_scope(s));
1633+
output["configured_scopes"] = json!(saved_scopes);
1634+
output["scope_mode"] = json!(if is_readonly {
1635+
"readonly"
1636+
} else {
1637+
"default"
1638+
});
1639+
}
1640+
14491641
println!(
14501642
"{}",
14511643
serde_json::to_string_pretty(&output).unwrap_or_default()
@@ -1458,10 +1650,11 @@ fn handle_logout() -> Result<(), GwsError> {
14581650
let enc_path = credential_store::encrypted_credentials_path();
14591651
let token_cache = token_cache_path();
14601652
let sa_token_cache = config_dir().join("sa_token_cache.json");
1653+
let scopes_file = scopes_path();
14611654

14621655
let mut removed = Vec::new();
14631656

1464-
for path in [&enc_path, &plain_path, &token_cache, &sa_token_cache] {
1657+
for path in [&enc_path, &plain_path, &token_cache, &sa_token_cache, &scopes_file] {
14651658
if path.exists() {
14661659
std::fs::remove_file(path).map_err(|e| {
14671660
GwsError::Validation(format!("Failed to remove {}: {e}", path.display()))
@@ -2532,4 +2725,38 @@ mod tests {
25322725
let err = read_refresh_token_from_cache(file.path()).unwrap_err();
25332726
assert!(err.to_string().contains("no refresh token was returned"));
25342727
}
2728+
2729+
// --- Scope persistence and guard tests ---
2730+
2731+
#[test]
2732+
fn test_save_and_load_scopes_roundtrip() {
2733+
let dir = tempfile::tempdir().unwrap();
2734+
let path = dir.path().join("scopes.json");
2735+
let scopes = vec![
2736+
"https://www.googleapis.com/auth/gmail.readonly".to_string(),
2737+
"openid".to_string(),
2738+
];
2739+
let json = serde_json::to_string_pretty(&scopes).unwrap();
2740+
crate::fs_util::atomic_write(&path, json.as_bytes()).unwrap();
2741+
let loaded: Vec<String> =
2742+
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2743+
assert_eq!(loaded, scopes);
2744+
}
2745+
2746+
#[test]
2747+
fn test_is_non_write_scope() {
2748+
// Readonly and identity scopes are non-write
2749+
assert!(is_non_write_scope("https://www.googleapis.com/auth/drive.readonly"));
2750+
assert!(is_non_write_scope("https://www.googleapis.com/auth/gmail.readonly"));
2751+
assert!(is_non_write_scope("openid"));
2752+
assert!(is_non_write_scope("email"));
2753+
assert!(is_non_write_scope("profile"));
2754+
assert!(is_non_write_scope("https://www.googleapis.com/auth/userinfo.email"));
2755+
2756+
// Write scopes are not non-write
2757+
assert!(!is_non_write_scope("https://www.googleapis.com/auth/drive"));
2758+
assert!(!is_non_write_scope("https://www.googleapis.com/auth/gmail.modify"));
2759+
assert!(!is_non_write_scope("https://www.googleapis.com/auth/calendar"));
2760+
assert!(!is_non_write_scope("https://www.googleapis.com/auth/pubsub"));
2761+
}
25352762
}

0 commit comments

Comments
 (0)