Skip to content

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

Closed

Description

@AkshayShah03

🔴 Required Information

Describe the Bug:
When an OAuth2 refresh fails permanently (the refresh token was revoked or expired, so the token endpoint answers invalid_grant), CredentialManager.get_auth_credential keeps returning the expired access token on every call and saves it back each time. Tools built on BaseAuthenticatedTool (including AuthenticatedFunctionTool) and MCP toolset auth only ask the user to authorize again when get_auth_credential returns None, so they never do. The user is stuck with a dead token for the rest of the session.

This is the intended design per the comment in OAuth2CredentialRefresher.refresh: "Non-fatal: keep the stale token so its eventual 401 re-triggers auth." That works for RestApiTool, which got 401 recovery in the fix for #7085 (063278a). The other authenticated tools have no 401 handling, so for them the 401 never re-triggers auth. The calls just keep failing (MCP servers and APIs called from an AuthenticatedFunctionTool reject the expired token).

Steps to Reproduce:

  1. pip install google-adk==2.12.0
  2. Run the snippet below. It patches authlib's HTTP call so the token endpoint answers invalid_grant, and calls get_auth_credential once per turn with the stored expired credential.

Expected Behavior:
After a refresh that can't succeed, get_auth_credential returns None, so the tool calls request_credential and the user is asked to authorize again.

Observed Behavior:

turn 0: get_auth_credential returned 'expired', saved=1
turn 1: get_auth_credential returned 'expired', saved=1
turn 2: get_auth_credential returned 'expired', saved=1

Environment Details:

  • ADK Library Version: 2.12.0 (also reproduced on main at bf67397)
  • Desktop OS: macOS
  • Python Version: 3.12.13

Model Information:

  • Are you using LiteLLM: No
  • Which model is being used: N/A

🟡 Optional Information

Minimal Reproduction Code:

import asyncio
import json
import time
from unittest import mock

from authlib.integrations.requests_client import OAuth2Session
from fastapi.openapi.models import OAuth2, OAuthFlowAuthorizationCode, OAuthFlows
from google.adk.auth.auth_credential import AuthCredential, AuthCredentialTypes, OAuth2Auth
from google.adk.auth.auth_tool import AuthConfig
from google.adk.auth.credential_manager import CredentialManager
import requests


def token_endpoint(self, method, url, **kwargs):
  """The refresh token was revoked: the token endpoint answers invalid_grant."""
  response = requests.Response()
  response.status_code = 400
  response._content = json.dumps({"error": "invalid_grant"}).encode()
  response.headers["Content-Type"] = "application/json"
  return response


scheme = OAuth2(
    flows=OAuthFlows(
        authorizationCode=OAuthFlowAuthorizationCode(
            authorizationUrl="https://idp.example.com/auth",
            tokenUrl="https://idp.example.com/token",
            scopes={},
        )
    )
)
raw = AuthCredential(
    auth_type=AuthCredentialTypes.OAUTH2,
    oauth2=OAuth2Auth(client_id="cid", client_secret="secret"),
)


async def main():
  for turn in range(3):
    stored = AuthCredential(
        auth_type=AuthCredentialTypes.OAUTH2,
        oauth2=OAuth2Auth(
            client_id="cid",
            client_secret="secret",
            access_token="expired",
            refresh_token="revoked",
            expires_at=int(time.time()) - 60,
        ),
    )
    context = mock.Mock()
    context.load_credential = mock.AsyncMock(return_value=stored)
    context.save_credential = mock.AsyncMock()
    context.get_auth_response = mock.Mock(return_value=None)
    manager = CredentialManager(
        AuthConfig(auth_scheme=scheme, raw_auth_credential=raw)
    )
    with mock.patch.object(OAuth2Session, "request", token_endpoint):
      credential = await manager.get_auth_credential(context)
    print(
        f"turn {turn}: get_auth_credential returned"
        f" {credential and credential.oauth2.access_token!r},"
        f" saved={context.save_credential.await_count}"
    )


asyncio.run(main())

How often has this issue occurred?:

  • Always (100%) once a refresh token is revoked or expires

Proposed fix (looking for guidance before I open a PR)

This touches the "keep the stale token and let the 401 re-trigger auth" design, so I'd like to check the direction first.

  1. Return None only when the refresh failed permanently (my preference). If the token endpoint answers with an OAuth2 error such as invalid_grant, the refresh token is dead, and no retry will help. In that case CredentialManager returns None (and doesn't save the expired token), so every authenticated tool asks the user to authorize again. Transport errors (timeouts, connection errors) keep today's behaviour of returning the stale token, so a network blip doesn't force a new sign-in. The refresher would need to report which kind of failure happened, for example through a small result type or an exception that CredentialManager catches.
  2. Return None whenever the token is still expired after a refresh attempt. This is simpler and covers both cases, but a transient network error during refresh would then also ask the user to sign in again.
  3. Keep the design and add 401 recovery to the other tools, as RestApiTool has. This keeps the credential layer unchanged, but each tool type needs its own recovery. MCP failures surface differently (through the session or transport), and an AuthenticatedFunctionTool wraps arbitrary user code, so there is no response for ADK to inspect.

Questions:

  • Is option 1 acceptable, or would you rather keep the credential layer as it is and go with option 3?
  • If option 1, do you have a preference for how the refresher reports a permanent failure?

Related: #7728 (expired token with no refresh token crashes; PR #7729), which is a separate, smaller fix.

Activity

  1. inamdarmihir commented on Oct 26, 2026

    @inamdarmihir
    Contributor

    I'd like to take this one. Plan: when the refresh fails with a permanent invalid_grant (revoked or expired refresh token), treat the credential as unusable so CredentialManager.get_auth_credential returns None and the existing request_credential flow asks the user to re-authorize. Transient refresh failures (network errors, 5xx) will keep the current keep-the-stale-token behavior, so the 401 recovery added for #7085 is unaffected. I'll add unit tests for both cases.

  2. added
    auth[Component] This issue is related to authorization
    on Oct 29, 2026
  3. llalitkumarrr commented on Oct 29, 2026

    @llalitkumarrr
    Collaborator

    Hello @AkshayShah03,

    We were able to reproduce your issue on the current main. However it was not reproducible after applying the changes recommended in #7731. Could you check it out and share your feedback on whether this resolves the issue?

  4. AkshayShah03 commented on Oct 29, 2026

    @AkshayShah03
    ContributorAuthor

    Thanks @llalitkumarrr. I went through #7731 and it matches option 1 from the proposal. invalid_grant raises CredentialRevokedError, CredentialManager returns None so the user is asked to authorize again, and transport errors keep the stored token. RestApiTool's reactive refresh in tool_auth_handler.py also calls the refresher, but it catches the exception there and falls back to re-authorization, so the new error doesn't escape. It also merges cleanly with #7729 (the no-refresh-token fix); the only overlap is both PRs adding tests at the end of test_oauth2_credential_refresher.py. Looks good to me.

  5. added a commit that references this issue on Nov 4, 2026
    70e1cbd
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

auth[Component] This issue is related to authorization

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions