Skip to content

[Bug]: Partial (granular) consent breaks login and then forces a browser re-login every hour on --auth=oauth2 #116

Open

Description

@AustrianTradingMachine

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:

  1. src/colab_cli/auth.py loads with an explicit scope list:
    Credentials.from_authorized_user_file(TOKEN_CONFIG_PATH, PUBLIC_SCOPES).
  2. google/oauth2/credentials.py::from_authorized_user_info only falls back to the scope
    list stored in the file when the caller passes None
    (if scopes is None and "scopes" in info). Since this caller always passes
    PUBLIC_SCOPES, the credential's _scopes is always the six requested scopes,
    regardless of what was granted — and the scopes field in token.json is dead weight.
  3. On refresh, google/oauth2/_client.py puts them on the wire:
    if scopes: body["scope"] = " ".join(scopes).
  4. Google rejects a refresh whose scope is not a subset of the grant → invalid_scope.
  5. get_credentials catches, sets creds = 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

  1. colab whoami (default --auth=oauth2), and on the consent screen decline the
    Google Cloud and Google Drive checkboxes.
  2. The login fails with the Scope has changed warning above; no token file is written.
  3. OAUTHLIB_RELAX_TOKEN_SCOPE=1 colab sessions → succeeds, token written.
  4. 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

  1. Persist the granted scopes, not the requested ones.
    google_auth_oauthlib.flow.Flow.credentials already carries granted_scopes from the
    token response.
  2. Load with scopes=None — Credentials.from_authorized_user_file(TOKEN_CONFIG_PATH)
    — so the persisted list wins, matching what the ADC path does.
  3. Do not let a partial grant discard a successful exchange. Set
    OAUTHLIB_RELAX_TOKEN_SCOPE=1 around fetch_token (or catch the Warning), 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.
  4. Consider not requesting the union up front. drive.file is only needed by
    drivemount and cloud-platform only by colab 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

  1. ajadkumar91350-art commented on Sep 4, 2026

    @ajadkumar91350-art

    Python
    print("hello Wold" )

  2. dacineu commented on Oct 3, 2026

    @dacineu

    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_SCOPES bug at src/colab_cli/auth.py:127-129 is unchanged on main as 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:

    $ curl -s "https://oauth2.googleapis.com/tokeninfo?access_token=ya29.…"
      email          = <account>
      granted scopes = email, profile,
                       https://www.googleapis.com/auth/userinfo.email,
                       https://www.googleapis.com/auth/userinfo.profile,
                       openid
    

    Failure 1 — mechanism pinned to a specific line

    Worth recording because the traceback is uninformative: the abort is a bare Warning raised as an exception, at oauthlib/oauth2/rfc6749/parameters.py:462-472:

    if params.scope_changed:
        message = 'Scope has changed from "{old}" to "{new}".'.format(…)
        scope_changed.send(…)
        if not os.environ.get('OAUTHLIB_RELAX_TOKEN_SCOPE', None):
            w = Warning(message)
            raise w          # <-- not ScopeChangeError; that class doesn't exist in 4.0.0

    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 why token.json never appears. Verified both ways with the exact 3-scope response body:

    strict (default)             -> RAISED Warning
    OAUTHLIB_RELAX_TOKEN_SCOPE=1 -> parsed OK
    

    This matches the Scope has changed output 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: ScopeChangeError does not exist in oauthlib 4.0.0, so this surfaces as a bare Warning rather 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:

    --- a/colab_cli/auth.py
    +++ b/colab_cli/auth.py
    @@ -124,9 +124,12 @@
    
         if os.path.exists(TOKEN_CONFIG_PATH):
             try:
    -            creds = Credentials.from_authorized_user_file(
    -                TOKEN_CONFIG_PATH, PUBLIC_SCOPES
    -            )
    +            creds = Credentials.from_authorized_user_file(TOKEN_CONFIG_PATH)
             except Exception as e:
                 logger.warning(f"Failed to load token from {TOKEN_CONFIG_PATH}: {e}")

    Rationale, matching your cause chain: from_authorized_user_info only reads the persisted scopes when the caller passes None (if scopes is None and "scopes" in info), so hardcoding PUBLIC_SCOPES guarantees _scopes is always the six requested scopes and the value stored in token.json is dead weight. Passing None lets 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:00Z and confirmed the CLI self-heals and serves a live API call:

    $ python -c "…set expiry to 2020-01-01…"
    $ colab sessions
    [?] <name> | Hardware: T4 | Shape: Standard | Variant: GPU
    $ python -c "print(json.load(open('token.json'))['expiry'])"
    2026-10-03T12:31:31.434831Z        # <-- refreshed, scopes still the granted 3
    

    And the end-to-end goal works over the real bridge (ProxyCommand colab ssh --proxy-mode -s <name>), returning root on the VM. No re-auth prompt on subsequent runs.

    Two notes

    • Items 1 and 2 together are the real fix. Persisting granted_scopes (item 1) is what stops the next refresh from re-introducing the problem; item 2 alone stops it being written wrong. With RELAX set, flow.credentials.granted_scopes is populated and available.
    • Doc nit, separate from the bug: README.md documents --auth as defaulting to adc, but the CLI actually defaults to oauth2 ([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.md currently says they aren't, so no PR from me for now.

  3. sethtroisi commented on Nov 11, 2026

    @sethtroisi
    Collaborator

    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.

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