Add verify_id_token option to OIDCProxy#3248
Conversation
There was a problem hiding this comment.
💡 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".
| id_token = upstream_token_set.raw_token_data.get("id_token") | ||
| if id_token is None: |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
💡 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".
| if self._verify_id_token: | ||
| id_token = upstream_token_set.raw_token_data.get("id_token") |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
💡 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") |
There was a problem hiding this comment.
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 👍 / 👎.
Test Failure Analysis
Summary: A timing-sensitive test in Root Cause: Suggested Solution: Make the timing margins less fragile. In # 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 >= 2Alternatively, mock Detailed AnalysisFailure log excerpt: The ping loop implementation ( 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
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:
Related Files
🤖 Generated with Claude Code (marvin triage bot) |
There was a problem hiding this comment.
💡 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".
| token_verifier = self.get_token_verifier( | ||
| algorithm=algorithm, | ||
| audience=audience, | ||
| audience=verifier_audience, | ||
| required_scopes=required_scopes, |
There was a problem hiding this comment.
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 👍 / 👎.
| "scopes": upstream_token_set.scope.split() | ||
| if upstream_token_set.scope |
There was a problem hiding this comment.
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 👍 / 👎.
|
/tidy |
There was a problem hiding this comment.
💡 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".
| id_token = upstream_token_set.raw_token_data.get("id_token") | ||
| if id_token is None: |
There was a problem hiding this comment.
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 👍 / 👎.
| if verify_id_token and required_scopes: | ||
| self.required_scopes = required_scopes |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
💡 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".
| validated = validated.model_copy( | ||
| update={ | ||
| "token": upstream_token_set.access_token, | ||
| "scopes": upstream_token_set.scope.split() |
There was a problem hiding this comment.
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 👍 / 👎.
|
Codex review triage Addressed in commits:
Dismissed:
|
OIDCProxyalways verifies the upstreamaccess_token, but many OIDC providers (Clerk, Zoho, others) issue opaque access tokens that the defaultJWTVerifiercan't validate. The existing workaround — passing a customtoken_verifierthat hits/userinfoor the introspection endpoint — works but adds a network round-trip on every request. Theid_tokenis already sitting inraw_token_data, is always a standard JWT per the OIDC spec, and can be verified locally using the JWKS URI thatOIDCProxyalready discovers.This adds a
verify_id_tokenflag that tells the proxy to verify theid_tokeninstead of theaccess_token:The implementation adds a
_get_verification_tokenhook toOAuthProxyso subclasses can control which token gets passed to the verifier without duplicating the token swap flow. Three important edge cases are handled:id_token.audis the OAuthclient_id, not the API audience. Whenverify_id_token=True, the defaultJWTVerifierusesclient_idas its audience while theaudienceparameter still flows upstream for API access.raw_token_datais now merged (not replaced) on refresh, preserving theid_tokenwhen providers omit it from refresh responses.AccessTokencarries 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