Skip to content

OIDCProxy with verify_id_token=True returns empty scopes when IdP issues identical access_token and id_token #3461

@chansongyou

Description

@chansongyou

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

  1. 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"],
        ...
    )
  2. Complete the OAuth flow successfully.
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    authRelated to authentication (Bearer, JWT, OAuth, WorkOS) for client or server.bugSomething isn't working. Reports of errors, unexpected behavior, or broken functionality.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions