Skip to content

fix(google): replace deprecated /oauth2/v1/tokeninfo with /oauth2/v3/userinfo#3603

Merged
jlowin merged 6 commits intoPrefectHQ:mainfrom
shigechika:fix/google-provider-tokeninfo
Mar 25, 2026
Merged

fix(google): replace deprecated /oauth2/v1/tokeninfo with /oauth2/v3/userinfo#3603
jlowin merged 6 commits intoPrefectHQ:mainfrom
shigechika:fix/google-provider-tokeninfo

Conversation

@shigechika
Copy link
Copy Markdown
Contributor

@shigechika shigechika commented Mar 24, 2026

Fixes #3601

Switched GoogleTokenVerifier.verify_token() from GET /oauth2/v1/tokeninfo (deprecated) to GET https://oauth2.googleapis.com/tokeninfo?access_token=TOKEN.

The canonical tokeninfo endpoint returns:

  • aud — the OAuth app ID (correct value for client_id)
  • scope — the full list of granted scopes, space-separated
  • expires_in — seconds until expiry, used to set expires_at
  • sub — unique user ID

User profile data (name, picture, locale) is fetched in a follow-up call to GET /oauth2/v2/userinfo with a Bearer header, keeping profile claims in the AccessToken without affecting token validation.

Updated tests: removed @pytest.mark.anyio (redundant with asyncio_mode = "auto"), updated mocks to use tokeninfo response shape, added coverage for expires_at, audclient_id, scope passthrough, and non-openid scopes like calendar.

…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
@marvin-context-protocol marvin-context-protocol Bot added the too-long Excessively verbose or unedited LLM output. Condense before triage. label Mar 24, 2026
@marvin-context-protocol
Copy link
Copy Markdown
Contributor

marvin-context-protocol Bot commented Mar 24, 2026

Test Failure Analysis

Summary: One test failed on Python 3.13 only — test_global_rate_limiting — but this failure is unrelated to this PR. The PR only changes src/fastmcp/server/auth/providers/google.py and its tests; the failing test is in tests/server/middleware/test_rate_limiting.py.

Root Cause: The test_global_rate_limiting test is timing-sensitive and relies on an assumption that the MCP client initialization sequence consumes exactly 5 protocol messages (1 init + 2 list_tools calls + 2 tool calls) to exhaust a burst_capacity=5 token bucket. On Python 3.13, the initialization sequence appears to consume fewer tokens (possibly due to asyncio scheduler differences), leaving enough capacity for the 3rd explicit call — so no ToolError is raised and the pytest.raises assertion fails.

Suggested Solution: This is a pre-existing flaky test, not introduced by this PR. The test should be fixed separately — the burst_capacity assumption is fragile and depends on the exact number of internal MCP protocol messages during connection setup. Options:

  • Reduce burst_capacity to 3 (so 1 init + 2 explicit calls exhausts it, without depending on list_tools count)
  • Or consume tokens explicitly before asserting, instead of relying on implicit initialization overhead

This PR can be merged independently of that fix.

Detailed Analysis

Failing test (only on Python 3.13):

FAILED tests/server/middleware/test_rate_limiting.py::TestRateLimitingMiddlewareIntegration::test_global_rate_limiting
  - Failed: DID NOT RAISE <class 'fastmcp.exceptions.ToolError'>

Test setup (test_rate_limiting.py:422):

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 ToolError

The comment # 1 init + 2 list_tools + 2 calls before limit reveals the fragility: the test assumes 3 protocol messages are consumed during connection setup. On Python 3.13 this assumption doesn't hold, leaving extra capacity.

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):

  • src/fastmcp/server/auth/providers/google.py
  • tests/server/auth/providers/test_google.py
Related Files
  • tests/server/middleware/test_rate_limiting.py:422test_global_rate_limiting, the flaky test that failed; not modified by this PR
  • src/fastmcp/server/auth/providers/google.py — file actually changed by this PR
  • tests/server/auth/providers/test_google.py — new test file added by this PR

Edited to reflect latest CI failure (workflow run 23537989917). Previous failure was a ruff formatting issue.

Copy link
Copy Markdown
Member

@jlowin jlowin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 v1

The 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.

shigechika and others added 4 commits March 25, 2026 20:09
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.
@jlowin jlowin merged commit c3f0223 into PrefectHQ:main Mar 25, 2026
7 checks passed
@jlowin jlowin added the bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality. label Mar 30, 2026
jlowin added a commit that referenced this pull request Mar 30, 2026
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)
shigechika added a commit to shigechika/jquants-mcp that referenced this pull request Apr 9, 2026
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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality. too-long Excessively verbose or unedited LLM output. Condense before triage.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GoogleProvider: replace /oauth2/v1/tokeninfo with /oauth2/v3/userinfo for token verification

2 participants