Description
Summary
When using OIDCProxy with verify_id_token=True, the resulting AccessToken.scopes is always empty if the upstream identity provider returns the same JWT for both the access_token and id_token fields in the token response.
Root cause
In server/oauth_proxy/proxy.py, load_access_token has this logic to restore scopes after verifying the id_token:
if verification_token != upstream_token_set.access_token:
validated = validated.model_copy(
update={
"scopes": upstream_token_set.scope.split()
if upstream_token_set.scope
else validated.scopes,
}
)
The intent is: "if we verified the id_token (not the access_token), patch the returned AccessToken with the scopes stored at authorization time."
However, the condition verification_token != upstream_token_set.access_token uses token value equality to detect whether id_token verification was used. When an IdP issues the same JWT for both access_token and id_token, this condition evaluates to False, so the scope patch is never applied. Since neither the id_token nor the access_token from the IdP carries a scope/scp claim, JWTVerifier._extract_scopes() returns [], and the final AccessToken.scopes is empty — causing RequireAuthMiddleware to return 403 insufficient_scope.
To Reproduce
- Configure
OIDCProxy with verify_id_token=True any IdP that returns the same JWT for both access_token and id_token:
auth = OIDCProxy(
...
verify_id_token=True,
required_scopes=["openid", "offline_access"],
...
)
- Complete the OAuth flow successfully.
- Observe
403 insufficient_scope.
Expected behavior
When verify_id_token=True, scopes should always be populated from upstream_token_set.scope (the scopes recorded at authorization time), regardless of whether id_token and access_token happen to be the same value.
Actual behavior
AccessToken.scopes is [], causing all requests to fail with 403 insufficient_scope.
Suggested fix
Replace the value-equality check with an intent-based flag:
# Instead of:
if verification_token != upstream_token_set.access_token:
# Use:
if self._verify_id_token:
This directly reflects the design intent: when id_token verification is in use, always restore scopes from the stored upstream scope string.
Environment
- fastmcp version: 3.1.0
- Identity Provider: SAP Cloud Identity Services
- Python version: 3.13
Workaround
Override load_access_token to inject scopes from upstream_token_set when the result has empty scopes:
class PatchedOIDCProxy(OIDCProxy):
async def load_access_token(self, token):
result = await super().load_access_token(token)
if result and not result.scopes:
try:
payload = self.jwt_issuer.verify_token(token)
jti_mapping = await self._jti_mapping_store.get(key=payload["jti"])
if jti_mapping:
upstream = await self._upstream_token_store.get(key=jti_mapping.upstream_token_id)
if upstream and upstream.scope:
return result.model_copy(update={"scopes": upstream.scope.split()})
except Exception:
pass
return result
Example Code
Version Information
FastMCP version: 3.1.0
MCP version: 1.26.0
Python version: 3.13.6
Platform: macOS-26.3.1-arm64-arm-64bit-Mach-O
FastMCP root path: /path/to/repo/.venv/lib/python3.13/site-packages
Description
Summary
When using
OIDCProxywithverify_id_token=True, the resultingAccessToken.scopesis always empty if the upstream identity provider returns the same JWT for both theaccess_tokenandid_tokenfields in the token response.Root cause
In
server/oauth_proxy/proxy.py,load_access_tokenhas this logic to restore scopes after verifying the id_token:The intent is: "if we verified the id_token (not the access_token), patch the returned
AccessTokenwith the scopes stored at authorization time."However, the condition
verification_token != upstream_token_set.access_tokenuses token value equality to detect whether id_token verification was used. When an IdP issues the same JWT for bothaccess_tokenandid_token, this condition evaluates toFalse, so the scope patch is never applied. Since neither the id_token nor the access_token from the IdP carries ascope/scpclaim,JWTVerifier._extract_scopes()returns[], and the finalAccessToken.scopesis empty — causingRequireAuthMiddlewareto return403 insufficient_scope.To Reproduce
OIDCProxywithverify_id_token=Trueany IdP that returns the same JWT for bothaccess_tokenandid_token:403 insufficient_scope.Expected behavior
When
verify_id_token=True, scopes should always be populated fromupstream_token_set.scope(the scopes recorded at authorization time), regardless of whetherid_tokenandaccess_tokenhappen to be the same value.Actual behavior
AccessToken.scopesis[], causing all requests to fail with403 insufficient_scope.Suggested fix
Replace the value-equality check with an intent-based flag:
This directly reflects the design intent: when id_token verification is in use, always restore scopes from the stored upstream scope string.
Environment
Workaround
Override
load_access_tokento inject scopes fromupstream_token_setwhen the result has empty scopes:Example Code
Version Information