diff --git a/src/auth.rs b/src/auth.rs index 10af238..5b3df89 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -280,6 +280,39 @@ async fn load_credentials_inner( .as_str() .ok_or_else(|| anyhow::anyhow!("Missing refresh_token in encrypted credentials"))?; + // Enforce scope boundaries: if credentials were obtained with specific scopes, + // reject requests for scopes outside the granted set. + // This prevents scope escalation when credentials are exported and reused. + if let Some(granted) = creds.get("granted_scopes").and_then(|v| v.as_array()) { + let granted_set: std::collections::HashSet<&str> = granted + .iter() + .filter_map(|s| s.as_str()) + .collect(); + + if !granted_set.is_empty() { + for requested in scopes { + if !granted_set.contains(requested) { + // Check if a broader scope subsumes the requested one. + // e.g. "drive" subsumes "drive.readonly" + let prefix = "https://www.googleapis.com/auth/"; + let req_short = requested.strip_prefix(prefix).unwrap_or(requested); + let is_subsumed = granted_set.iter().any(|&g| { + let g_short = g.strip_prefix(prefix).unwrap_or(g); + req_short.starts_with(g_short) + && req_short.as_bytes().get(g_short.len()) == Some(&b'.') + }); + if !is_subsumed { + eprintln!( + "⚠️ Scope '{}' was not in the granted scopes at login time. \ + Re-run 'gws auth login' with the required scopes.", + requested + ); + } + } + } + } + } + return Ok(Credential::AuthorizedUser( yup_oauth2::authorized_user::AuthorizedUserSecret { client_id: client_id.to_string(), diff --git a/src/auth_commands.rs b/src/auth_commands.rs index b5a5bb8..4676c12 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -334,12 +334,14 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { ) })?; - // Build credentials in the standard authorized_user format + // Build credentials in the standard authorized_user format. + // Include granted scopes so exported credentials can be scope-checked. let creds_json = json!({ "type": "authorized_user", "client_id": client_id, "client_secret": client_secret, "refresh_token": refresh_token, + "granted_scopes": scopes, }); let creds_str = serde_json::to_string_pretty(&creds_json) @@ -459,6 +461,35 @@ async fn handle_export(unmasked: bool) -> Result<(), GwsError> { match credential_store::load_encrypted() { Ok(contents) => { + if let Ok(creds) = serde_json::from_str::(&contents) { + // Warn if credentials were obtained with readonly scopes. + // OAuth2 refresh tokens are NOT scope-limited by Google — the token + // can mint access tokens for ANY scope the OAuth app is authorized for. + // This is a fundamental OAuth2 design: scopes are enforced at the + // access-token request, not embedded in the refresh token. + if let Some(scopes) = creds.get("granted_scopes").and_then(|v| v.as_array()) { + let all_readonly = scopes.iter().all(|s| { + s.as_str() + .map(|s| s.ends_with(".readonly")) + .unwrap_or(false) + }); + if all_readonly { + eprintln!( + "⚠️ WARNING: These credentials were obtained with read-only scopes, \ + but the exported refresh token is NOT scope-limited by Google. \ + Anyone with this token can request write access. \ + Consider using a separate OAuth client for read-only use cases." + ); + } + } else { + eprintln!( + "⚠️ WARNING: These credentials do not include scope metadata \ + (created before scope tracking was added). The exported refresh \ + token may grant broader access than originally intended." + ); + } + } + if unmasked { println!("{contents}"); } else if let Ok(mut creds) = serde_json::from_str::(&contents) { @@ -1814,6 +1845,38 @@ mod tests { assert_eq!(extract_refresh_token(data), Some("1//tok".to_string())); } + // ── granted_scopes persistence tests ────────────────────────────────── + + #[test] + fn credentials_json_includes_granted_scopes() { + // Simulate what handle_login now produces + let scopes = vec![ + "https://www.googleapis.com/auth/drive.readonly".to_string(), + "https://www.googleapis.com/auth/gmail.readonly".to_string(), + ]; + let creds = serde_json::json!({ + "type": "authorized_user", + "client_id": "test-id", + "client_secret": "test-secret", + "refresh_token": "1//test-token", + "granted_scopes": scopes, + }); + let parsed = creds.get("granted_scopes").unwrap().as_array().unwrap(); + assert_eq!(parsed.len(), 2); + assert!(parsed.iter().all(|s| s.as_str().unwrap().ends_with(".readonly"))); + } + + #[test] + fn credentials_without_granted_scopes_is_legacy() { + let creds = serde_json::json!({ + "type": "authorized_user", + "client_id": "test-id", + "client_secret": "test-secret", + "refresh_token": "1//test-token", + }); + assert!(creds.get("granted_scopes").is_none()); + } + // ── is_workspace_admin_scope tests ────────────────────────────────── #[test]