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
33 changes: 33 additions & 0 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
65 changes: 64 additions & 1 deletion src/auth_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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::<serde_json::Value>(&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::<serde_json::Value>(&contents) {
Expand Down Expand Up @@ -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]
Expand Down