Skip to content

fix(auth): enforce readonly scopes by revoking stale tokens and adding client-side guard#681

Open
gerfalcon wants to merge 1 commit intogoogleworkspace:mainfrom
gerfalcon:fix/issue-168-readonly-scope-enforcement
Open

fix(auth): enforce readonly scopes by revoking stale tokens and adding client-side guard#681
gerfalcon wants to merge 1 commit intogoogleworkspace:mainfrom
gerfalcon:fix/issue-168-readonly-scope-enforcement

Conversation

@gerfalcon
Copy link
Copy Markdown

Summary

Fixes #168. Supersedes #520 (closed due to force-push issues).

gws auth login --readonly doesn't actually enforce read-only access when the user previously logged in with broader scopes. The refresh token keeps its original grants, and Google ignores the scope param on refresh — so the token silently has write access.

What this PR does

  1. Saves configured scopes to scopes.json on login so we can detect scope changes later
  2. Revokes the old refresh token when scopes change, clearing local creds before re-authenticating — this removes the prior consent grant so Google only shows the new scopes
  3. Blocks write operations client-side when in a readonly session (defense-in-depth, enforced in auth::get_token so helpers like +send are also covered)
  4. Shows scope_mode in gws auth status for transparency

Why just prompt=consent isn't enough

Google's consent screen shows previously-granted scopes pre-checked. Users click "Allow" and unknowingly re-grant broad access. Revoking the token first removes the prior grant entirely.

Test plan

  • 2 new unit tests + full suite passes (790 tests)
  • cargo clippy -- -D warnings clean
  • gws gmail +send in readonly session → blocked with clear error: "This operation requires scope '...' (write access), but the current session uses read-only scopes."
  • gws calendar +agenda in readonly session → works (uses .readonly scope)
  • gws drive files list in readonly session → blocked (Discovery doc's first scope is broad drive)
  • gws auth status → shows scope_mode: readonly and configured_scopes
  • Switch scopes.json to default (write) scopes → gws gmail +send --dry-run passes scope guard
  • gws auth login --readonlygws auth login (full) → prints "Scopes changed — revoked previous credentials." and re-authenticates with full scopes
  • gws auth logoutscopes.json listed in removed and confirmed deleted

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 6, 2026

🦋 Changeset detected

Latest commit: d1ed759

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@googleworkspace/cli Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@gemini-code-assist
Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request addresses an issue where read-only authentication sessions were not effectively enforced, allowing users to retain broader write access from previous logins. By persisting scope configurations and implementing a client-side guard, the CLI now ensures that write operations are strictly blocked during read-only sessions. Additionally, the system now proactively revokes old tokens upon scope changes to force a clean re-authentication, preventing users from inadvertently re-granting excessive permissions.

Highlights

  • Scope Persistence: Introduced a scopes.json file to track and persist configured OAuth scopes across sessions.
  • Token Revocation: Implemented automatic revocation of old refresh tokens when scopes change to ensure Google clears prior consent grants.
  • Client-Side Guard: Added a mandatory check in get_token to block write operations when the current session is restricted to read-only scopes.
  • Transparency: Updated gws auth status to display the current scope_mode and configured scopes.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Generative AI Prohibited Use Policy, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements read-only session enforcement by persisting the configured scope set and adding client-side guards to the token retrieval process. It also introduces automatic token revocation and credential cleanup when scope changes are detected during login. Feedback points out a performance issue with frequent disk I/O in the authentication path, a networking bug where token revocation bypasses shared client configurations like proxies, and a logic flaw in scope comparison that may cause unnecessary re-authentications due to identity scope mismatches.

@gerfalcon
Copy link
Copy Markdown
Author

@jpoehnelt This is the rebased version of #520 — single clean commit on latest main, all conflicts resolved. Ready for review when you have a chance.

…g client-side guard

Rebased on main after workspace refactor (googleworkspace#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 googleworkspace#168
@gerfalcon gerfalcon force-pushed the fix/issue-168-readonly-scope-enforcement branch from b1a01e9 to d1ed759 Compare April 6, 2026 08:14
@googleworkspace-bot
Copy link
Copy Markdown
Collaborator

/gemini review

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements read-only scope enforcement by persisting the authorized scope set and adding client-side guards. It also introduces automatic OAuth token revocation and local credential cleanup when switching between scope sets. Feedback identifies a potential stale state issue caused by caching the session's read-only status and a logic error in the scope comparison that could lead to redundant re-authentication prompts.

Comment on lines +398 to +407
static CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
if let Some(&val) = CACHE.get() {
return Ok(val);
}
let res = load_saved_scopes()
.await?
.map(|scopes| scopes.iter().all(|s| is_non_write_scope(s)))
.unwrap_or(false);
let _ = CACHE.set(res);
Ok(res)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Caching the readonly status in a OnceLock can lead to stale state within the same process execution. For example, an orchestration command like gws auth setup --login might trigger a token check (caching the old state) before performing a fresh login that changes the scope mode. Since reading the small scopes.json file is negligible in terms of performance for a CLI, it is safer to remove the cache to ensure correctness.

Suggested change
static CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
if let Some(&val) = CACHE.get() {
return Ok(val);
}
let res = load_saved_scopes()
.await?
.map(|scopes| scopes.iter().all(|s| is_non_write_scope(s)))
.unwrap_or(false);
let _ = CACHE.set(res);
Ok(res)
let res = load_saved_scopes()
.await?
.map(|scopes| scopes.iter().all(|s| is_non_write_scope(s)))
.unwrap_or(false);
Ok(res)

Comment on lines +750 to +755
let prev_set: HashSet<&str> = prev_scopes
.iter()
.map(|s| s.as_str())
.filter(|s| !is_non_write_scope(s) || s.ends_with(".readonly"))
.collect();
let new_set: HashSet<&str> = scopes.iter().map(|s| s.as_str()).collect();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The comparison between prev_set and new_set is inconsistent because identity scopes (like openid) are filtered out of prev_set but not new_set. If a user explicitly includes identity scopes in their login command (e.g., via --scopes), new_set will contain them while prev_set will not, triggering an unnecessary token revocation and re-authentication prompt even if the functional scopes haven't changed.

        let prev_set: HashSet<&str> = prev_scopes
            .iter()
            .map(|s| s.as_str())
            .filter(|s| !is_non_write_scope(s) || s.ends_with(".readonly"))
            .collect();
        let new_set: HashSet<&str> = scopes
            .iter()
            .map(|s| s.as_str())
            .filter(|s| !is_non_write_scope(s) || s.ends_with(".readonly"))
            .collect();

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

gws auth login --readonly + auth export --unmasked appears to allow full access on external machine

2 participants