Repository navigation
Description
Summary
If a user approves only some of the six requested scopes on Google's granular consent
screen — a supported, deliberate choice — --auth=oauth2 (the default) breaks twice: the
login is discarded outright, and once that is worked around, every token refresh fails and
the CLI falls back to a fresh browser consent. In practice that means a browser login
every hour, which rules out unattended and agent use on the default auth path.
The same library already handles this correctly on the --auth=adc path, so the fix is
small and has a working reference inside the same dependency.
Failure 1 — a successful login is thrown away
_run_remote_flow exchanges the code successfully (HTTP 200, access_token +
refresh_token returned), and oauthlib then raises because the granted scope set is
narrower than the requested one:
Warning: Scope has changed from "https://www.googleapis.com/auth/colaboratory
https://www.googleapis.com/auth/userinfo.profile
https://www.googleapis.com/auth/userinfo.email
https://www.googleapis.com/auth/cloud-platform openid
https://www.googleapis.com/auth/drive.file" to "openid
https://www.googleapis.com/auth/colaboratory
https://www.googleapis.com/auth/userinfo.profile
https://www.googleapis.com/auth/userinfo.email".
No ~/.config/colab-cli/token.json is written. Setting OAUTHLIB_RELAX_TOKEN_SCOPE=1
gets past it, and the resulting credential works — colab sessions returns normally.
Failure 2 — that credential cannot refresh
[colab] whoami: failed to refresh credentials: ('invalid_scope: Bad Request', {'error':
'invalid_scope', 'error_description': 'Bad Request'})
Cause chain:
src/colab_cli/auth.pyloads with an explicit scope list:
Credentials.from_authorized_user_file(TOKEN_CONFIG_PATH, PUBLIC_SCOPES).google/oauth2/credentials.py::from_authorized_user_infoonly falls back to the scope
list stored in the file when the caller passesNone
(if scopes is None and "scopes" in info). Since this caller always passes
PUBLIC_SCOPES, the credential's_scopesis always the six requested scopes,
regardless of what was granted — and thescopesfield intoken.jsonis dead weight.- On refresh,
google/oauth2/_client.pyputs them on the wire:
if scopes: body["scope"] = " ".join(scopes). - Google rejects a refresh whose scope is not a subset of the grant →
invalid_scope. get_credentialscatches, setscreds = None, and falls through to
_run_remote_flow— a new browser consent, roughly once an hour.
colab whoami surfaces this immediately because it always refreshes; every other command
hits it when the access token expires.
The ADC path already does this right
google/auth/_default.py loads ADC via load_credentials_from_file(...) without a
scopes argument, so from_authorized_user_info uses the scopes recorded in the file, and
with_scopes_if_required is a no-op for user credentials (requires_scopes is False in
google/oauth2/credentials.py:257). The requested list can therefore never contradict the
grant.
Demonstrated with the same refresh token that fails above, copied into an ADC-format file:
$ colab --auth=adc whoami
Auth provider: adc
Email: <redacted>
Expires in: 59m # i.e. it just refreshed
Scopes:
- https://www.googleapis.com/auth/colaboratory
- https://www.googleapis.com/auth/userinfo.email
- https://www.googleapis.com/auth/userinfo.profile
- openid
$ colab --auth=adc sessions
[colab] No active sessions found on server.Same account, same refresh token, same permissions — only the code path that presents it
differs.
Reproduction
colab whoami(default--auth=oauth2), and on the consent screen decline the
Google Cloud and Google Drive checkboxes.- The login fails with the
Scope has changedwarning above; no token file is written. OAUTHLIB_RELAX_TOKEN_SCOPE=1 colab sessions→ succeeds, token written.colab whoami→invalid_scope.
Environment
- google-colab-cli 0.6.0 (PyPI,
uv tool install) - Darwin 25.6.0 (arm64), Python 3.13
- Code sites checked against
main; unchanged at HEAD.
Suggested fix
- Persist the granted scopes, not the requested ones.
google_auth_oauthlib.flow.Flow.credentialsalready carriesgranted_scopesfrom the
token response. - Load with
scopes=None—Credentials.from_authorized_user_file(TOKEN_CONFIG_PATH)
— so the persisted list wins, matching what the ADC path does. - Do not let a partial grant discard a successful exchange. Set
OAUTHLIB_RELAX_TOKEN_SCOPE=1aroundfetch_token(or catch theWarning), then check
the granted set for what the CLI actually needs and fail loudly naming the missing scope
— instead of failing opaquely an hour later. - Consider not requesting the union up front.
drive.fileis only needed by
drivemountandcloud-platformonly bycolab auth; asking for both at login is what
puts users in front of this choice in the first place. Users declining broad Cloud and
Drive permissions for a Colab CLI is a reasonable default, not an edge case.
Happy to send a PR for 1–3 if that would help.
Activity
ajadkumar91350-art commentedon Sep 4, 2026
Python
print("hello Wold" )
dacineu commentedon Oct 3, 2026
Still reproducible on 0.7.4 — and I have a verified fix for Failure 2
Following up on this report (still open, and the
PUBLIC_SCOPESbug atsrc/colab_cli/auth.py:127-129is unchanged onmainas of today). I hit both failures and closed out the loop, so here is fresh evidence on a newer release plus a patch that fixes the refresh leg end-to-end. Not opening a new issue since this one covers it.Confirmed on 0.7.4 / oauthlib 4.0.0
This is not limited to 0.6.0. On 0.7.4 with oauthlib 4.0.0, the same account that fails for the reporter fails identically for me. Tokeninfo on the issued token confirms the grant is the identity trio:
Failure 1 — mechanism pinned to a specific line
Worth recording because the traceback is uninformative: the abort is a bare
Warningraised as an exception, atoauthlib/oauth2/rfc6749/parameters.py:462-472:So
fetch_token()gets its HTTP 200 and a perfectly valid token, then throws anyway — which is why the failure looks like a network/auth error and whytoken.jsonnever appears. Verified both ways with the exact 3-scope response body:This matches the
Scope has changedoutput already quoted here, and it means item 3 of the suggested fix is the load-bearing one — the exchange must survive a partial grant or nothing downstream ever runs.One version note, since it affects how you read the above:
ScopeChangeErrordoes not exist in oauthlib 4.0.0, so this surfaces as a bareWarningrather than a typed exception. If any code catches that specific class, it will miss this on 4.x.Failure 2 — one-line fix, verified
Your suggested fix #2 is correct and sufficient for the refresh leg. This is the patch, against
main:Rationale, matching your cause chain:
from_authorized_user_infoonly reads the persistedscopeswhen the caller passesNone(if scopes is None and "scopes" in info), so hardcodingPUBLIC_SCOPESguarantees_scopesis always the six requested scopes and the value stored intoken.jsonis dead weight. PassingNonelets the granted set win — which is what the ADC path already does.Verification — rather than trust the read-only path, I forced the stored token to
expiry=2020-01-01T00:00:00Zand confirmed the CLI self-heals and serves a live API call:And the end-to-end goal works over the real bridge (
ProxyCommand colab ssh --proxy-mode -s <name>), returningrooton the VM. No re-auth prompt on subsequent runs.Two notes
granted_scopes(item 1) is what stops the next refresh from re-introducing the problem; item 2 alone stops it being written wrong. WithRELAXset,flow.credentials.granted_scopesis populated and available.README.mddocuments--authas defaulting toadc, but the CLI actually defaults tooauth2([default: oauth2]in--help). Minor, but it sends people down the wrong path for this exact failure.Happy to provide a PR for 1–3 if external contributions are being accepted again — I see
CONTRIBUTING.mdcurrently says they aren't, so no PR from me for now.sethtroisi commentedon Nov 11, 2026
I've fixed the colab-cli code (in #235)
EDIT: This is fixed with #235, you may have to remove scopes that you have already approved from https://myaccount.google.com/connections to see the granular permissions again.
This will be in 0.8.1 which we'll cut in the next week or so.