From 61c9a2f5801b194debe4d39cf205a6850c4c66d6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 26 May 2026 19:53:54 +0000 Subject: [PATCH] fix(jwt+mcp): tighten issuer-scoped claim type handling, RFC-quote authorization_uri, surface MCP upstream auth errors, defense-in-depth on decode options - handle_jwt: when an issuer-scoped _litellm_team_ids claim exists but has an unexpected type, return [] instead of falling through to the global team_ids_jwt_field path (different claim semantically). - handle_jwt: _get_decode_options/_decode_jwt_with_public_key now take an explicit disable_audience_validation flag; passing audience=None without it raises, so audience checks can't silently disappear if the model validator is ever bypassed. _auth_jwt_with_issuer forwards the flag from JWTIssuerConfig. - mcp_server: quote the authorization_uri WWW-Authenticate parameter value (RFC 6750 / 9728 auth-param must be quoted-string), matching the pass-through path. - mcp_server: in _fetch_and_filter_server_tools, re-raise MCPUpstreamAuthError so the outer streamable-HTTP handler can surface a proper 401 + WWW-Authenticate challenge instead of returning an empty tool list. Co-authored-by: Yassin Kortam --- .../proxy/_experimental/mcp_server/server.py | 9 ++++++- litellm/proxy/auth/handle_jwt.py | 24 ++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 783d8d15055..2eb09010d2b 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -1456,6 +1456,13 @@ if MCP_AVAILABLE: f"Successfully fetched {len(tools)} tools from server {server.name}, {len(filtered_tools)} after filtering" ) return filtered_tools + except MCPUpstreamAuthError: + # Surface upstream 401/403 to the outer handler so the + # client receives a proper WWW-Authenticate challenge + # instead of a silently empty tool list. Without this + # re-raise the broad ``except Exception`` below would + # swallow the auth error. + raise except Exception as e: verbose_logger.exception( f"Error getting tools from server {server.name}: {str(e)}" @@ -2960,7 +2967,7 @@ if MCP_AVAILABLE: _as_url = f"{base_url}/.well-known/oauth-authorization-server/mcp/{server_name}" else: _as_url = f"{base_url}/.well-known/oauth-authorization-server/{server_name}" - authorization_uri = f"Bearer authorization_uri={_as_url}" + authorization_uri = f'Bearer authorization_uri="{_as_url}"' raise HTTPException( status_code=401, diff --git a/litellm/proxy/auth/handle_jwt.py b/litellm/proxy/auth/handle_jwt.py index 1409e61e339..de7971969c3 100644 --- a/litellm/proxy/auth/handle_jwt.py +++ b/litellm/proxy/auth/handle_jwt.py @@ -255,6 +255,11 @@ class JWTHandler: return issuer_team_ids if isinstance(issuer_team_ids, str): return [issuer_team_ids] + # Issuer-scoped claim exists but has an unexpected type + # (e.g. int/dict from an unusual upstream mapping). Don't silently + # fall through to the global ``team_ids_jwt_field`` path — that + # would read a semantically unrelated claim on the same token. + return [] if self.litellm_jwtauth.team_ids_jwt_field is not None: team_ids: Optional[List[str]] = get_nested_value( @@ -971,7 +976,18 @@ class JWTHandler: self, audience: Optional[Union[str, List[str]]], issuer: Optional[str] = None, + disable_audience_validation: bool = False, ) -> Optional[dict]: + # Disabling audience verification must be an explicit choice — never + # an implicit consequence of ``audience`` being None. Otherwise a + # caller that accidentally constructs a config with ``audience=None`` + # (bypassing the model validator) would silently lose audience + # validation. Require callers to opt in via + # ``disable_audience_validation=True``. + if audience is None and not disable_audience_validation: + raise ValueError( + "audience must be provided unless disable_audience_validation=True" + ) options: dict = {} if audience is None: options["verify_aud"] = False @@ -986,11 +1002,16 @@ class JWTHandler: audience: Optional[Union[str, List[str]]], issuer: Optional[str] = None, options: Optional[dict] = None, + disable_audience_validation: bool = False, ) -> dict: decode_options = ( options if options is not None - else self._get_decode_options(audience=audience, issuer=issuer) + else self._get_decode_options( + audience=audience, + issuer=issuer, + disable_audience_validation=disable_audience_validation, + ) ) if isinstance(public_key, dict): @@ -1035,6 +1056,7 @@ class JWTHandler: public_key=public_key, audience=issuer_config.audience, issuer=issuer_config.issuer, + disable_audience_validation=issuer_config.disable_audience_validation, ) except jwt.ExpiredSignatureError: raise Exception("Token Expired")