fix(google): replace deprecated /oauth2/v1/tokeninfo with /oauth2/v3/userinfo#3603
Conversation
…en verification
The v1 tokeninfo endpoint (GET /oauth2/v1/tokeninfo?access_token=TOKEN) is
deprecated by Google and intermittently returns `invalid_token` for valid
access tokens, causing spurious authentication failures in production.
Switch to GET /oauth2/v3/userinfo with a Bearer Authorization header, which:
- Is the recommended way to validate Google OAuth tokens
- Sends the token in the header rather than a query parameter
- Returns all necessary user claims (sub, email, name, picture, …) in a
single API call, eliminating the separate v2/userinfo fetch
- Returns HTTP 401 for expired/invalid tokens, making an explicit
`expires_in <= 0` check unnecessary
Because the v3 userinfo endpoint does not return a scope string, granted
scopes are inferred from the fields present in the response (email implies
the email scope; name/picture imply the profile scope). Both shorthand
("email") and full-URI ("https://www.googleapis.com/auth/userinfo.email")
forms are included so that required_scopes checks work regardless of which
form the caller normalised to.
`AccessToken.expires_at` is set to None; expired tokens are already rejected
by the HTTP 401 response from the endpoint itself.
Add `TestGoogleTokenVerifier` tests that assert:
- Bearer header is used, not a query parameter
- v3/userinfo is called, not v1/tokeninfo
- Scopes are correctly inferred from response fields
- Tokens missing `sub` or required scopes are rejected
Test Failure AnalysisSummary: One test failed on Python 3.13 only — Root Cause: The Suggested Solution: This is a pre-existing flaky test, not introduced by this PR. The test should be fixed separately — the
This PR can be merged independently of that fix. Detailed AnalysisFailing test (only on Python 3.13): Test setup ( rate_limit_server.add_middleware(
RateLimitingMiddleware(
max_requests_per_second=6.0,
burst_capacity=5, # 1 init + 2 list_tools + 2 calls before limit
global_limit=True,
)
)
# Expects the 3rd call_tool to raise ToolErrorThe comment All other jobs passed — including Python 3.10 on both ubuntu and windows, integration tests, and lowest-direct-dependency tests. Files changed in this PR (unrelated to failure):
Related Files
Edited to reflect latest CI failure (workflow run 23537989917). Previous failure was a ruff formatting issue. |
jlowin
left a comment
There was a problem hiding this comment.
Thanks for fixing this — the v1 tokeninfo endpoint being flaky is a real problem worth solving, and the Bearer header improvement is a good security practice. However, the replacement endpoint (/oauth2/v3/userinfo) isn't the right one for token verification, and the change introduces two regressions that would break existing functionality.
client_id gets the wrong value. The userinfo endpoint doesn't return an audience/application identifier — it returns user profile data. The PR sets client_id=sub, which is the Google user ID, not the OAuth application ID. These are different concepts: client_id on AccessToken represents which application obtained the token (used downstream in telemetry as enduser.id, and conceptually parallel to how the Discord provider extracts and validates the application ID). After this change, every token would report the end-user's Google ID as the client application.
Scope inference from response fields can only detect three scopes. The heuristic of checking which fields are present in the userinfo response can only ever infer openid, email, and profile. A token granted https://www.googleapis.com/auth/calendar or https://www.googleapis.com/auth/drive.readonly would have those scopes silently dropped, making required_scopes reject every token for anything beyond those three hardcoded values.
Both of these are because the userinfo endpoint answers a different question than tokeninfo. Userinfo tells you who the user is; tokeninfo tells you what the token is authorized to do (audience, scopes, expiration). The existing two-call architecture — tokeninfo for validation, then userinfo for profile — is the correct pattern.
The fix should be simpler. Google's current canonical token introspection endpoint is https://oauth2.googleapis.com/tokeninfo (documented here with an example response). It returns aud, scope, expires_in, email — everything the current code needs. The main change from v1 is that audience becomes aud and user_id becomes sub. So the fix is roughly:
response = await client.get(
"https://oauth2.googleapis.com/tokeninfo",
params={"access_token": token},
headers={"User-Agent": "FastMCP-Google-OAuth"},
)
# ...
token_info = response.json()
client_id = token_info.get("aud", "unknown") # was "audience" in v1The Bearer header improvement can still be adopted for the separate userinfo call that fetches profile data.
A couple of minor items: the @pytest.mark.anyio decorators aren't needed (the project uses asyncio_mode = "auto" globally — none of the existing ~1400 async tests use markers), and CI is failing on a ruff formatting issue.
Switch from v3/userinfo to https://oauth2.googleapis.com/tokeninfo for token verification. This gives us the correct OAuth app ID from `aud` (the actual client_id / audience), the complete list of granted scopes directly from the `scope` field, and the token expiry via `expires_in`. User profile data (name, picture, locale) is still fetched from the v2 userinfo endpoint as a separate call after the token is validated. Addresses reviewer feedback on PR PrefectHQ#3603.
The test was flaky on Python 3.13 because the number of MCP protocol messages consumed during client initialization varies with the asyncio scheduler. On 3.13 the init phase consumed fewer tokens, leaving enough capacity for the 3rd explicit call to succeed. Fix by: - Switching to near-zero refill rate (0.001 req/s) so elapsed time does not replenish tokens during the test - Explicitly resetting global_limiter.tokens to 4 after the client connects, removing any dependency on init-phase token consumption Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Use high burst capacity so init token count is irrelevant, then drain to zero and assert rejection — no dependency on internal protocol message counts.
Co-authored-by: Claude Opus 4.6 <[email protected]> Co-authored-by: Jeremiah Lowin <[email protected]> Co-authored-by: Marvin Context Protocol <41898282+Marvin Context [email protected]> Co-authored-by: voidborne-d <[email protected]> Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com> Co-authored-by: Claude <[email protected]> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: d 🔹 <[email protected]> Co-authored-by: Jeremiah Lowin <[email protected]> Co-authored-by: nightcityblade <[email protected]> Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]> Co-authored-by: Claude Sonnet 4.6 <[email protected]> Co-authored-by: Bill Easton <[email protected]> Co-authored-by: Sumanshu Nankana <[email protected]> Co-authored-by: Eric Robinson <[email protected]> Co-authored-by: Martim Santos <[email protected]> Co-authored-by: d 🔹 <[email protected]> Co-authored-by: Matthieu B <[email protected]> Co-authored-by: Sascha Buehrle <[email protected]> Co-authored-by: Hakancan <[email protected]> Co-authored-by: nightcityblade <[email protected]> Co-authored-by: Matt Hallowell <[email protected]> Co-authored-by: nate nowack <[email protected]> Co-authored-by: Bill Easton <[email protected]> Co-authored-by: Marcus Shu <[email protected]> Co-authored-by: Rushabh Doshi <[email protected]> Co-authored-by: AIKAWA Shigechika <[email protected]> Co-authored-by: Jeremy Simon <[email protected]> Co-authored-by: Miguel Miranda Dias <[email protected]> Co-authored-by: Anthony James Padavano <[email protected]> Co-authored-by: Mostafa Kamal <[email protected]> Fix auto-close MRE script posting comment without closing (#3386) Fix WorkOS token scope verification bypass 🤖 Generated with Codex (#3407) Fix initialize McpError fallthrough 🤖 Generated with Codex (#3413) Fix transform arg collisions with passthrough params (#3431) Fix get_* returning None when latest version is disabled (#3439) Fix get_* returning None when latest version is disabled (#3421) Fix server lifespan overlap teardown (#3415) Fix $ref output schema object detection regression (#3420) resolved annotations (#3429) Fix async partial callables rejected by iscoroutinefunction (#3438) Fix async partial callables rejected by iscoroutinefunction (#3423) fix: add version to components (#3458) fix: use intent-based flag for OIDC scope patch in load_access_token (#3465) Fixes #3461 fix: normalize Google scope shorthands and surface valid_scopes (#3477) fix: resolve ty 0.0.23 type-checking errors and bump pin (#3481) fix: shield lifespan teardown from cancellation (#3480) fix: forward custom_route endpoints from mounted servers (#3462) fix updates _get_additional_http_routes() to traverse providers, Fixes #3457 fix: remove hardcoded version from CLI help text (#3456) fix: monty 0.0.8 compatibility, drop external_functions from constructor (#3468) fix: task test teardown hanging 5s per test (#3499) Closes #3498 fix: validate workspace path is a directory before cursor install (#3440) Fixes #3426 fix: handle re.error from malformed URI templates in build_regex (#3501) fix: reject empty/OIDC-only required_scopes in AzureProvider (#3503) fix: restrict $ref resolution to local refs only (SSRF/LFI) (#3502) fix warnings and timeouts (#3504) close upgrade check issue when build passes (#3505) Closes #3484 fix: URL-encode path params to prevent SSRF/path traversal (GHSA-vv7q-7jx5-f767) (#3507) fix: prevent path traversal in skill download (#3493) fix: prefer IdP-granted scopes over client-requested scopes in OAuthProxy (#3492) fix: remove unrelated transform and http.py changes from PR scope fix: remove forced follow_redirects from httpx_client_factory calls (#3496) fix: stop passing follow_redirects to httpx_client_factory fix: restore follow_redirects=True for custom httpx client factories Closes #3509 fix: CSRF double-submit cookie check in consent flow (#3519) fix: validate server names in install commands (#3522) fix: use raw strings for regex in pytest.raises match (#3523) fix: reject refresh tokens used as Bearer access tokens (#3524) fix: route ResourcesAsTools/PromptsAsTools through server middleware (#3495) fix: resolve Pyright "Module is not callable" on @tool, @resource, @prompt decorators (#3540) fix: filter warnings by message in KEY_PREFIX test (#3549) fix: suppress output schema for ToolResult subclass annotations (#3548) fix: increase sleep duration in proxy cache tests (#3567) fix: store absolute token expiry to prevent stale expires_in on reload (#3572) fix: preserve tool properties named 'title' during schema compression (#3582) Fix loopback redirect URI port matching per RFC 8252 §7.3 (#3589) Fix app tool routing: visibility check and middleware propagation (#3591) Fix query parameter serialization to respect OpenAPI explode/style settings (#3595) Fix dev apps form: union types, textarea support, JSON parsing (#3597) fix(google): replace deprecated /oauth2/v1/tokeninfo with /oauth2/v3/userinfo (#3603) fix: resolve EntraOBOToken dependency injection through MultiAuth (#3609) fix(docs): correct misleading stateless_http header (#3622) fix: filesystem provider import machinery (#3626) Closes #3625 (issues 2, 3, 6) fix: recover StdioTransport after subprocess exits (#3630) fix(server): preserve mounted tool task metadata (#3632) fix: scope deprecation warning filter to FastMCPDeprecationWarning (#3649) fix imports, add PrefabAppConfig (#3650) fix: resolve CurrentFastMCP/ctx.fastmcp to child server in mounted background tasks (#3651) Fix blocking docs issues: chart imports, Select API, Rx consistency (#3652) closed by default (#3657) Fix prompt caching middleware missing wrap/unwrap round-trip (#3666) fix: serialize object query params per OpenAPI style/explode rules (#3662) Fixes #2857 fix: HTTP request headers not accessible in background task workers (#3631) fix: restore HTTP headers in worker execution path for background tasks (#3681) fix: strip discriminator after dereferencing schemas (#3682) fix: remove stale ty:ignore directives for ty 0.0.26 (#3684) Fix docs gaps in app provider pages (#3690) fix: dev apps log panel UX improvements (#3698) fix dev server empty string args (#3700)
GoogleProvider was contributed upstream (PrefectHQ/fastmcp#3603, #3722) and released in v3.2.1. Remove the local copy and import from fastmcp.server.auth.providers.google instead. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Fixes #3601
Switched
GoogleTokenVerifier.verify_token()fromGET /oauth2/v1/tokeninfo(deprecated) toGET https://oauth2.googleapis.com/tokeninfo?access_token=TOKEN.The canonical tokeninfo endpoint returns:
aud— the OAuth app ID (correct value forclient_id)scope— the full list of granted scopes, space-separatedexpires_in— seconds until expiry, used to setexpires_atsub— unique user IDUser profile data (name, picture, locale) is fetched in a follow-up call to
GET /oauth2/v2/userinfowith a Bearer header, keeping profile claims in theAccessTokenwithout affecting token validation.Updated tests: removed
@pytest.mark.anyio(redundant withasyncio_mode = "auto"), updated mocks to use tokeninfo response shape, added coverage forexpires_at,aud→client_id, scope passthrough, and non-openid scopes like calendar.