Skip to content

fix(auth): ask for re-authorization when the OAuth2 refresh token is revoked - #7731

Closed
inamdarmihir wants to merge 2 commits into
google:mainfrom
inamdarmihir:fix/oauth2-invalid-grant-reauth
Closed

inamdarmihir wants to merge 2 commits into
google:mainfrom
inamdarmihir:fix/oauth2-invalid-grant-reauth

Conversation

@inamdarmihir

Copy link
Copy Markdown
Contributor

Link to Issue or Description of Change

1. Link to an existing issue (if applicable):

Problem

When the token endpoint rejects an OAuth2 refresh with invalid_grant (the refresh token was revoked or expired), OAuth2CredentialRefresher.refresh logs the error and returns the stale credential, and CredentialManager.get_auth_credential returns and re-saves it. Authenticated tools (AuthenticatedFunctionTool, MCP toolset auth) only call request_credential when get_auth_credential returns None, so they keep sending a dead access token and the user is never asked to re-authorize.

Solution

  • OAuth2CredentialRefresher.refresh raises a new CredentialRevokedError (a CredentialRefresherError subclass) when the authlib/requests error is invalid_grant.
  • CredentialManager.get_auth_credential catches it and returns None without saving, so the existing request_credential flow asks for authorization again. If the dead credential came from the credential store, a fresh auth response from the user is still preferred, exchanged and saved, so a revoked stored credential cannot shadow the new authorization.
  • Every other failure (network errors, 5xx, other OAuth2 errors) keeps the stale token as before. ToolAuthHandler._refresh_and_store already wraps refresher.refresh in except Exception, so the RestApiTool 401 recovery is unchanged.

Testing Plan

Unit Tests:

  • I have added or updated unit tests for my change.
  • All unit tests pass locally.

New cases: invalid_grant (both InvalidGrantError and OAuth2Error(error="invalid_grant")) raises in the refresher, server_error keeps the stale token, a revoked credential returns None and is not saved, a fresh auth response wins over a revoked stored credential, and _refresh_credential propagates the error.

  • With the credential_manager.py and oauth2_credential_refresher.py changes reverted: 4 failed, 73 passed.
  • With the fix: tests/unittests/auth 346 passed, tests/unittests/tools 3310 passed.

Manual End-to-End (E2E) Tests:

Local HTTP token endpoint, a real CredentialManager and authlib session, stored credential expired with access_token=STALE:

BEFORE  invalid_grant (400): result=STALE, saved=True
BEFORE  transient 503:       result=STALE, saved=True
AFTER   invalid_grant (400): result=None,  saved=False
AFTER   transient 503:       result=STALE, saved=True

Not run: the full repo suite, and a live Google OAuth flow.

Checklist

  • I have read the CONTRIBUTING.md document.
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.
  • I have manually tested my changes end-to-end.
  • Any dependent changes have been merged and published in downstream modules.

Additional context

  • invalid_grant is detected through the .error attribute authlib parses from the token response. A provider that signals it in a non-standard body stays on the old keep-stale path.
  • The dead stored credential is not deleted from the credential service. Each turn tries one refresh until the user re-authorizes.
  • A sibling PR for BaseAuthenticatedTool saves invalid credentials #3016 (stored credential rejected with 401/403) also touches credential_manager.py. The two branches merge cleanly.

Authored by Mihir Inamdar.

…revoked

When the token endpoint rejects a refresh with `invalid_grant`, the refresh
token is permanently dead, but OAuth2CredentialRefresher swallowed the error
and CredentialManager kept returning, and re-saving, the stale access token.
Tools then kept sending an expired token and the user was never asked to
re-authorize.

The refresher now raises CredentialRevokedError for `invalid_grant`, and
CredentialManager.get_auth_credential returns None without saving, so the
existing request_credential flow asks the user to authorize again. A fresh
auth response is still preferred over the stored dead credential. Other
failures (network errors, 5xx, other OAuth2 errors) keep the stale token so
the 401 recovery in RestApiTool is unaffected.

Fixes google#7730
@google-cla

google-cla Bot commented Oct 26, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@inamdarmihir

Copy link
Copy Markdown
Contributor Author

@googlebot I signed it!

copybara-service Bot pushed a commit that referenced this pull request Nov 4, 2026
Merge #7731

When the token endpoint rejects a refresh with invalid_grant,
OAuth2CredentialRefresher.refresh now raises a CredentialRefresherError
subclass instead of returning the stale credential, and
CredentialManager.get_auth_credential returns None and removes the dead
credential from the credential service, so authenticated tools and toolsets
ask the user to authorize again. A client-credentials flow exchanges its
configured credential again instead. Other refresh failures still keep the
stale token.

The credential manager now calls BaseCredentialService.save_credential with
exchanged_auth_credential=None to remove a stored credential; the
save_credential docstring documents that None means remove, so that
load_credential returns None afterwards. If a custom credential service
raises on None, the error is logged and the call continues.

Fixes #7730

PiperOrigin-RevId: 1013682546
@adk-bot

adk-bot commented Nov 4, 2026

Copy link
Copy Markdown
Collaborator

Thank you @inamdarmihir for your contribution! 🎉

Your changes have been successfully imported and merged via Copybara in commit 70e1cbd.

Closing this PR as the changes are now in the main branch.

@adk-bot adk-bot added the merged [Status] This PR is merged label Nov 4, 2026
@adk-bot adk-bot closed this Nov 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merged [Status] This PR is merged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OAuth2: after a refresh fails with invalid_grant, the expired token is returned forever and the user is never asked to re-authorize

3 participants