Skip to content

Add verify_id_token option to OIDCProxy#3248

Merged
jlowin merged 12 commits intomainfrom
fix/oidc-verify-id-token
Feb 20, 2026
Merged

Add verify_id_token option to OIDCProxy#3248
jlowin merged 12 commits intomainfrom
fix/oidc-verify-id-token

Conversation

@jlowin
Copy link
Copy Markdown
Member

@jlowin jlowin commented Feb 20, 2026

OIDCProxy always verifies the upstream access_token, but many OIDC providers (Clerk, Zoho, others) issue opaque access tokens that the default JWTVerifier can't validate. The existing workaround — passing a custom token_verifier that hits /userinfo or the introspection endpoint — works but adds a network round-trip on every request. The id_token is already sitting in raw_token_data, is always a standard JWT per the OIDC spec, and can be verified locally using the JWKS URI that OIDCProxy already discovers.

This adds a verify_id_token flag that tells the proxy to verify the id_token instead of the access_token:

auth = OIDCProxy(
    config_url="https://provider.example/.well-known/openid-configuration",
    client_id="...",
    client_secret="...",
    base_url="https://my-server.example",
    verify_id_token=True,
)

The implementation adds a _get_verification_token hook to OAuthProxy so subclasses can control which token gets passed to the verifier without duplicating the token swap flow. Three important edge cases are handled:

  • Audience semantics: In OIDC, id_token.aud is the OAuth client_id, not the API audience. When verify_id_token=True, the default JWTVerifier uses client_id as its audience while the audience parameter still flows upstream for API access.
  • Token refresh: raw_token_data is now merged (not replaced) on refresh, preserving the id_token when providers omit it from refresh responses.
  • AccessToken identity: After id_token verification, the returned AccessToken carries the upstream access_token and its scopes — the id_token is used purely as a verification gate, not as the token of record.

Closes #3240

@jlowin jlowin added enhancement Improvement to existing functionality. For issues and smaller PR improvements. auth Related to authentication (Bearer, JWT, OAuth, WorkOS) for client or server. labels Feb 20, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a76930f78a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +402 to +403
id_token = upstream_token_set.raw_token_data.get("id_token")
if id_token is None:
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Handle refresh responses without id_token

When verify_id_token is enabled this method only pulls id_token from raw_token_data and returns None if it is missing, which causes load_access_token to reject the request. In the refresh flow, OAuthProxy.exchange_refresh_token overwrites raw_token_data with the refresh response, and many providers omit id_token there; after the first refresh, all newly issued FastMCP access tokens can become invalid until users re-authorize.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e9fde08596

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +401 to +402
if self._verify_id_token:
id_token = upstream_token_set.raw_token_data.get("id_token")
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Apply ID-token audience semantics in verify-id-token mode

When verify_id_token is enabled, this branch feeds id_token into the same verifier that was configured from the audience constructor argument, but in OIDCProxy that audience value is also sent upstream as the API/resource audience (final_authorize_params/final_token_params). In common OIDC flows (e.g., Auth0), id_token.aud is the OAuth client ID, not the API audience, so any deployment that sets audience to obtain API access tokens will have every load_access_token check fail with audience mismatch after this change.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 17abedf602

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

upstream token response instead of the access_token.
"""
if self._verify_id_token:
id_token = upstream_token_set.raw_token_data.get("id_token")
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Handle expired persisted id_token in verify-id-token mode

With verify_id_token=True, this method always returns raw_token_data["id_token"], and OAuthProxy.exchange_refresh_token now preserves that original value when refresh responses omit id_token; for providers that do not reissue id_token on refresh, the verifier keeps checking the initial JWT until its exp is reached, after which load_access_token rejects every refreshed FastMCP token even though the upstream access token is still being refreshed successfully. This creates periodic forced re-authentication tied to id_token lifetime rather than refresh-token lifetime.

Useful? React with 👍 / 👎.

@marvin-context-protocol
Copy link
Copy Markdown
Contributor

marvin-context-protocol Bot commented Feb 20, 2026

Test Failure Analysis

(Edited to reflect latest CI failure on main post-merge — previous analysis was for a different flaky test in this PR's CI run)

Summary: A timing-sensitive test in tests/server/middleware/test_ping.py failed on Python 3.10 (ubuntu-latest) because only 1 ping was sent during a 150ms window where ≥2 were expected.

Root Cause: _ping_loop in src/fastmcp/server/middleware/ping.py sleeps first then pings (sleep → ping → sleep → ping). With interval_ms=50 and a 150ms deadline, only 2 complete cycles fit in theory (at t≈50ms and t≈100ms), leaving almost no margin before the deadline. Under CI load on Python 3.10, the asyncio task scheduler introduces enough overhead that only 1 ping completes before move_on_after(0.15) expires.

Suggested Solution: Make the timing margins less fragile. In tests/server/middleware/test_ping.py:161-176, increase the timeout relative to the interval to give a comfortable buffer:

# Before (flaky):
middleware = PingMiddleware(interval_ms=50)
with anyio.move_on_after(0.15):   # only ~3x the interval
    await middleware._ping_loop(mock_session, session_id)
assert mock_session.send_ping.call_count >= 2

# After (robust):
middleware = PingMiddleware(interval_ms=50)
with anyio.move_on_after(0.5):    # 10x the interval — plenty of headroom
    await middleware._ping_loop(mock_session, session_id)
assert mock_session.send_ping.call_count >= 2

Alternatively, mock anyio.sleep entirely to remove the dependency on real time.

Detailed Analysis

Failure log excerpt:

FAILED tests/server/middleware/test_ping.py::TestPingLoop::test_ping_loop_sends_pings_at_interval
AssertionError: assert 1 >= 2
 +  where 1 = <AsyncMock name='mock.send_ping' id='...'>.call_count

The ping loop implementation (ping.py:63-70):

async def _ping_loop(self, session: Any, session_id: int) -> None:
    try:
        while True:
            await anyio.sleep(self.interval_ms / 1000)  # sleep FIRST
            await session.send_ping()
    finally:
        self._active_sessions.discard(session_id)

Because the loop sleeps before the first ping, with interval_ms=50 (50ms) and a 150ms window:

  • t=0ms: loop enters
  • t≈50ms: first send_ping() call
  • t≈100ms: second send_ping() call
  • t=150ms: move_on_after deadline — third sleep interrupted

In theory there's time for 2 pings, but the margin is razor-thin. Python 3.10's asyncio scheduling is slightly less efficient with anyio cancel scopes than 3.13, and CI runners can have variable scheduling delays. This is why it passes on Python 3.13 and Python 3.10/Windows but fails intermittently on Python 3.10/Linux.

Jobs status for run 22237842881:

Job Result
Tests: Python 3.10 on ubuntu-latest ❌ FAILED
Tests: Python 3.13 on ubuntu-latest ✅ passed
Tests: Python 3.10 on windows-latest ✅ passed
Tests with lowest-direct dependencies ✅ passed
Integration tests ✅ passed
Related Files
  • tests/server/middleware/test_ping.py:161-176 — the flaky test (timing assertions too tight)
  • src/fastmcp/server/middleware/ping.py:63-70_ping_loop implementation (sleep-first design)

🤖 Generated with Claude Code (marvin triage bot)

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9058ad6e3f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/fastmcp/server/auth/oidc_proxy.py Outdated
Comment on lines 342 to 345
token_verifier = self.get_token_verifier(
algorithm=algorithm,
audience=audience,
audience=verifier_audience,
required_scopes=required_scopes,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Disable verifier scope checks in verify-id-token mode

When verify_id_token=True, this still builds JWTVerifier with required_scopes, but JWTVerifier only reads scopes from JWT scope/scp claims and many IdPs omit those on id_token. In deployments that set required_scopes, load_access_token will reject otherwise valid sessions because scope validation runs against the ID token instead of the access token. This regression is specific to verify-id-token mode and providers whose ID tokens do not carry scopes.

Useful? React with 👍 / 👎.

Comment on lines +1440 to +1441
"scopes": upstream_token_set.scope.split()
if upstream_token_set.scope
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Return scopes from current refresh state, not stored original

In the alternate-token branch, scopes are rebuilt from upstream_token_set.scope, but that field is not updated in exchange_refresh_token when refresh requests are down-scoped or the provider narrows scopes. In verify-id-token mode this can cause FastMCP to keep authorizing stale broader scopes after refresh, even though the upstream access token has reduced permissions.

Useful? React with 👍 / 👎.

@jlowin
Copy link
Copy Markdown
Member Author

jlowin commented Feb 20, 2026

/tidy

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 014792a7ba

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +416 to +417
id_token = upstream_token_set.raw_token_data.get("id_token")
if id_token is None:
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Require openid scope when enabling id_token verification

With verify_id_token=True, this code path depends on raw_token_data["id_token"], but the proxy does not enforce requesting openid upstream (authorization scopes are still taken from client/requested scopes). In flows where clients request only API scopes or no scope, many OIDC providers return no id_token; _get_verification_token then returns None, and load_access_token rejects all FastMCP access tokens for that session. This turns verify-id-token mode into a hard auth failure unless callers manually add openid every time.

Useful? React with 👍 / 👎.

Comment on lines +404 to +405
if verify_id_token and required_scopes:
self.required_scopes = required_scopes
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Recompute derived scope settings after restoring scopes

Restoring self.required_scopes here is incomplete because OAuthProxy.__init__ has already derived _default_scope_str and client_registration_options.valid_scopes from the verifier scopes (which were intentionally stripped to [] in verify-id-token mode). That leaves scope-derived defaults stale: clients registered without an explicit scope still inherit an empty scope string, and metadata can continue advertising no valid scopes despite self.required_scopes being reset.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6f6a6c7442

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1437 to +1440
validated = validated.model_copy(
update={
"token": upstream_token_set.access_token,
"scopes": upstream_token_set.scope.split()
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve access-token claim semantics after id-token verification

When verify_id_token is enabled, this branch rewrites token, scopes, and expires_at but leaves validated.claims (and derived identity fields) from the id_token verifier result. That means consumers like TokenClaim/CurrentAccessToken read claim data from a different token than AccessToken.token, so claim-based authorization can silently diverge whenever id_token and access-token claims differ.

Useful? React with 👍 / 👎.

@jlowin
Copy link
Copy Markdown
Member Author

jlowin commented Feb 20, 2026

Codex review triage

Addressed in commits:

  • Refresh without id_token — fixed via raw_token_data merge (old id_token preserved when refresh omits it)
  • Audience semanticsclient_id used as verifier audience per OIDC Core §2
  • Scope enforcement on verifier — verifier gets no required_scopes (id_tokens don't carry scope claims); scopes still advertised/enforced at the FastMCP token level
  • Derived scope state_default_scope_str, valid_scopes, and CIMD default_scope all recomputed after restoring required_scopes

Dismissed:

  • Expired id_token after refresh — If the provider doesn't return a fresh id_token on refresh and the old one expires, failing auth is correct. The user's identity is no longer verified. Skipping exp validation would be a security hole, not a fix. This is a known OIDC tradeoff.
  • Require openid scope — Silently injecting scopes is surprising. If verify_id_token=True and the provider doesn't yield an id_token, _get_verification_token returns None with a clear warning on first auth attempt. That's the right signal for misconfiguration.
  • Claims divergence between id_token and access_token — This is the intended design. The id_token provides identity claims (sub, email, etc.), the access_token is opaque and used for upstream API calls. There's no divergence, there's complementary data.

@jlowin jlowin merged commit f84b2da into main Feb 20, 2026
11 of 12 checks passed
@jlowin jlowin deleted the fix/oidc-verify-id-token branch February 20, 2026 19:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auth Related to authentication (Bearer, JWT, OAuth, WorkOS) for client or server. enhancement Improvement to existing functionality. For issues and smaller PR improvements.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OIDCProxy: Support id token verification for providers with opaque access tokens

1 participant