Sitelet https://github.com/googleworkspace/cli/issues/876
Skip to content

gws auth login can silently leave a stale/split token cache, causing 403 insufficient authentication scopes after re-auth #876

Open

Description

@kbroughton

Summary

After re-authenticating (gws auth login -s drive,docs), gws continued returning 403 insufficient authentication scopes on every Drive API call, while Gmail-scoped calls succeeded fine throughout. The login command's own output correctly listed https://www.googleapis.com/auth/drive as a granted scope, and ~/.config/gws/credentials.enc had a fresh timestamp — but the actual Drive API calls kept failing as if that scope had never been granted.

Environment

  • gws version: 0.11.1 (@googleworkspace/cli)
  • macOS 15.7.3 (Build 24G419)
  • Auth method: OAuth2, installed/desktop client (redirect_uris: ["http://localhost"])
  • Keyring backend: keyring

What was ruled out before finding the real cause

Extensive troubleshooting confirmed this was not caused by any of the following (each checked directly):

  • OAuth consent screen scope configuration (Drive API confirmed ENABLED via gcloud services list)
  • OAuth consent screen test users list (account confirmed present)
  • A stray local process holding a port from a previous gws auth login invocation (found and killed one, no change)
  • A full OS reboot (no change — rules out any in-memory/process-level caching)
  • client_secret.json validity (if this were invalid, login itself would fail with invalid_client, not succeed and then fail on API calls — and other-scoped calls, e.g. Gmail, worked fine throughout)

Multiple full gws auth login -s drive,docs re-runs across this window, including after switching to a different Google account and back, did not resolve the issue.

Root cause (as best diagnosed)

~/.config/gws/ contains two separate local token/credential stores:

  • credentials.enc (encrypted, gets a fresh timestamp on every gws auth login)
  • token_cache.json (binary — not actually JSON despite the extension/name; failed UTF-8 decode when inspected)

A normal gws auth login re-auth appears to refresh credentials.enc but not fully invalidate/replace whatever state token_cache.json holds (or vice versa — the exact read/write interaction between the two wasn't fully traced from the outside). The practical symptom: the CLI looks like it completed a fresh login with the requested scope, but the token actually used for API calls doesn't carry that scope.

Workaround that fixed it

Manually deleting the cached credential/token state (while preserving the app config) and then doing a completely fresh login resolved the issue immediately:

rm ~/.config/gws/credentials.enc ~/.config/gws/token_cache.json
rm -rf ~/.config/gws/cache/*   # cached API discovery docs, unrelated to auth but safe to clear too
gws auth login -s drive,docs

(client_secret.json and .encryption_key were left untouched — those are the app identity/encryption key, not per-session state.)

Immediately after this, gws drive files list succeeded and returned real results.

Suggested fix

gws auth login (or a new --fresh/--clear-cache flag) should fully clear all local token/credential state before starting a new interactive login, rather than leaving a possibly-stale secondary cache (token_cache.json) in place alongside a freshly-written credentials.enc. Right now the only way to force a truly clean re-auth is to know to manually delete files inside ~/.config/gws/ — there's no documented or CLI-exposed way to do this.

Happy to provide more detail if useful — this took a couple hours of systematic elimination to track down, so hopefully this saves someone else the trouble.

Activity

  1. ryangu00 commented on Jul 31, 2026

    @ryangu00

    Reproduced on 0.22.5 (macOS 25.5, keyring backend) with a discriminator that pins the mechanism down to the token cache file, plus a validated workaround.

    Scenario. Existing grant without gmail.settings.basic; ran gws auth login --scopes …gmail.settings.basic,gmail.modify,gmail.labels…, consent completed, auth status immediately showed the new scope set, and credentials.enc got a fresh mtime.

    Split behavior, same grant, same minute:

    • gws gmail users settings filters list → works (proves the new grant is live)
    • gws gmail users settings filters create → 403 “Request had insufficient authentication scopes”, persistently (retried over ~20 minutes; not TTL-flavored)

    Control experiment — took refresh_token from gws auth export --unmasked, minted an access token directly against oauth2.googleapis.com/token, and called POST /gmail/v1/users/me/settings/filters raw: succeeds first try. So the grant is fine; only the CLI’s own token path is serving something stale for some calls.

    File-level evidence:

    credentials.enc    mtime 10:49:57   # refreshed by the re-auth
    token_cache.json   mtime 10:42:58   # untouched — pre-re-auth
    

    gws auth login refreshed the credentials but did not invalidate ~/.config/gws/token_cache.json.

    Workaround (validated):

    rm ~/.config/gws/token_cache.json   # safe — it's a cache
    # next gws call mints fresh tokens from the new grant; the failing call now succeeds

    After removing the cache file, the exact same filters create invocation succeeded immediately.

    Suggested fix: invalidate (or namespace-by-grant) token_cache.json whenever auth login writes new credentials — a stale cache surviving a re-auth is what produces the “some calls work, some 403” split reported here. Happy to turn this into a PR if that direction looks right to the maintainers.

  2. MrMalkio commented on Aug 25, 2026

    @MrMalkio

    Independent confirmation on a different platform and keyring backend — this isn't macOS/keyring-specific.

    Environment: Windows 11, gws 0.22.5, GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND=file (so credentials.enc, not the OS keyring).

    Scenario: existing 14-scope grant; ran gws auth login --scopes … adding Chat and Admin SDK scopes. Consent completed, auth status immediately reported all 22 scopes with token_valid: true.

    Result — no split in our case; every newly-scoped call failed:

    • gws admin-reports activities list → 403 insufficient authentication scopes
    • gws chat spaces list → 403 insufficient authentication scopes
    • Previously-granted services (drive, gmail, calendar, tasks, people) → all fine

    File-level evidence:

    credentials.enc    mtime 18:55   # refreshed by re-auth
    token_cache.json   mtime 18:56   # newer, but still serving the pre-re-auth access token
    

    Worth flagging that our cache had a newer mtime than credentials.enc and still served the stale token — so mtime alone isn't a reliable tell for whether the cache is poisoned. The prior access token was ~11 minutes into its hour, consistent with reuse rather than re-mint.

    Same workaround, same result: moving token_cache.json aside made both failing calls succeed on the next invocation, no re-consent needed.

    One data point on impact: we found a token_cache.pre-drive-gmail.bak in the same config dir from a month earlier — a previous encounter with this bug, "resolved" by renaming the cache and then never re-authenticating, which left the install with zero stored credentials for a month. The failure mode reads as "the login failed" when the login actually succeeded, which is what makes it expensive.

    +1 on invalidating the cache in auth login.

  3. jojinkoo commented on Sep 2, 2026

    @jojinkoo

    Mechanism confirmed in the v0.22.5 source, plus a discriminator that explains the "some calls 403, others work" split.

    Reproduced on gws 0.22.5 (macOS 26, keyring backend) while adding the Admin SDK Directory read-only scopes to an existing grant with gws auth login --scopes …. Consent completed, auth status listed all 15 scopes and token_valid: true, credentials.enc had a fresh mtime — and then, with the same credentials in the same minute:

    • users list (Directory API, reached as admin-reports users list --api-version directory_v1) → 403 "Request had insufficient authentication scopes", persistently
    • groups list, members list → 200

    The split lines up with which methods had already been called before the re-login. Reading the code at tag v0.22.5:

    1. crates/google-workspace-cli/src/main.rs L259–265 — select_scope(&method.scopes) picks one scope per Discovery method and passes it to auth::get_token(&scopes).
    2. auth.rs L262–270 — the authorized-user path builds a yup_oauth2::AuthorizedUserAuthenticator with EncryptedTokenStorage, so a still-valid cached token is served without a refresh.
    3. token_storage.rs L114–119 — the cache key is the sorted, joined scope string of that request, i.e. one cache entry per method scope.
    4. auth_commands.rs — token_cache.json is touched only by token_cache_path(), handle_status() and handle_logout() (L1456ff, which removes it together with sa_token_cache.json). The login path (run_login → handle_login_inner) never references it.

    So after a re-login that adds scopes, every method whose scope key was already cached under the old grant keeps receiving the old access token until it expires (≤1 h) → 403, while methods first called after the re-login mint a fresh token from the new refresh token → 200. That is exactly the split in the original report and in @ryangu00's comment; the "every newly-scoped call fails" variant in @MrMalkio's case is the same mechanism when those scope keys were already in the cache (e.g. from probes made before re-auth). It also explains why the cache file can have a newer mtime than credentials.enc and still serve stale tokens: the login writes its own scope-set entry, it does not evict the others.

    Discriminator: with the cache in place, users list failed at 14:43:58; after mv ~/.config/gws/token_cache.json ~/.config/gws/token_cache.json.bak the identical call succeeded at 14:44:20 with no re-consent, and groups list, members list and drive about get kept working. A new token_cache.json was written by that call.

    Suggested fix: have the login handler invalidate token_cache.json (and sa_token_cache.json) once the new credentials are stored — the same removal handle_logout() already performs — or add a fingerprint of the refresh token/grant to the cache key so an entry minted under a previous grant can never be served to a new one. Either is a small, local change.

  4. stefano-tensortech-co commented on Sep 11, 2026

    @stefano-tensortech-co

    We hit this exact symptom on Windows 11, gws v0.22.5.

    Repro:

    1. We ran gws chat spaces list. It failed with 403 Request had insufficient authentication scopes.
    2. We ran gws auth login with --scopes. We passed the three Chat scope URLs directly. We did not use the -s shorthand.
    3. The consent screen showed the Chat scopes as granted. The login command's own success output showed the same result.
    4. We ran gws chat spaces list again. It failed with the same 403 error.
    5. We ran gws auth status. It confirmed the stored credentials hold all three Chat scopes. It showed token_valid: true. It showed a refresh token present.
    6. We repeated the login four times in total. We enabled the Chat API on the GCP project first. We then added the three Chat scopes to the OAuth consent screen's scope list. Each repeat gave the same 403 error.
    7. [WORKAROUND OK] We renamed it in place (token_cache.json -> token_cache.json.bak_<timestamp>). We ran gws chat spaces list again. It worked at once. It needed no new consent.

    This confirms a fourth data point. The stale token cache is the actual cause, not the OAuth grant. PR #808 already fixed this defect. A stale-issue bot closed it automatically, not on its merit. We ask a maintainer to reopen it.

  5. lautou commented on Sep 30, 2026

    @lautou

    Possibly-relevant data point, but flagging the confound honestly rather than claiming a clean repro. On 0.22.5: after recovering from a full credential wipe (separate issue, -s <service> replacing rather than merging scopes — commented on #673), we hit persistent 403 insufficient authentication scopes on both drive files list (reason: insufficientPermissions) and a Forms API call (reason: unknown — different reason string for what looked like the same failure class), even though gws auth status consistently showed the correct full scope list including drive/forms.body/forms.responses.readonly throughout. This persisted across at least one more --scopes re-login. It only cleared after a full gws auth logout + fresh login. That's consistent with this issue's stale-token_cache.json mechanism — but we changed two things in the same round (also fixed an OAuth consent-screen misconfiguration on our end at the same time), so I can't claim this as an isolated confirmation. Noting it in case the reason-string difference (insufficientPermissions vs unknown) or the "survives one re-login, cleared by full logout" detail is useful to whoever narrows this further.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions