fix(mcp,jwt): drop unneeded async helper + suppress misleading unscoped JWT warning

- _build_oauth_authorization_server_response: revert to sync (no awaits in body).
  The function only does dict construction and synchronous registry lookups;
  async added coroutine creation overhead per discovery call without need.
- _build_decode_kwargs: accept has_issuer_config so the global path's
  'JWT auth is unscoped' warning is suppressed when LiteLLM_JWTAuth.issuers
  provides per-issuer scoping. Previously the warning fired spuriously for
  admins who intentionally use only the new issuers config.
This commit is contained in:
mateo-berri 2026-05-21 03:42:08 +00:00
parent 31f8c56cb7
commit 7905e996bd
No known key found for this signature in database
4 changed files with 46 additions and 10 deletions

View file

@ -971,11 +971,16 @@ async def oauth_protected_resource_mcp(
)
async def _build_oauth_authorization_server_response(
def _build_oauth_authorization_server_response(
request: Request,
mcp_server_name: Optional[str],
) -> dict:
"""Build OAuth authorization server metadata response (gateway-as-AS shape)."""
"""Build OAuth authorization server metadata response (gateway-as-AS shape).
Synchronous because the body only does dict construction and synchronous
registry lookups; unlike :func:`_build_oauth_protected_resource_response`
it does not need to await any upstream IO.
"""
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
@ -1039,7 +1044,7 @@ async def oauth_authorization_server_mcp_standard(
Standard pattern: /mcp/{server_name}
Discovery path: /.well-known/oauth-authorization-server/mcp/{server_name}
"""
return await _build_oauth_authorization_server_response(
return _build_oauth_authorization_server_response(
request=request,
mcp_server_name=mcp_server_name,
)
@ -1058,7 +1063,7 @@ async def oauth_authorization_server_mcp(
Supports both legacy pattern (/{server_name}) and root endpoint.
"""
return await _build_oauth_authorization_server_response(
return _build_oauth_authorization_server_response(
request=request,
mcp_server_name=mcp_server_name,
)
@ -1129,7 +1134,7 @@ async def oauth_authorization_server_legacy(request: Request, mcp_server_name: s
"""
OAuth authorization server discovery for legacy /{server_name}/mcp pattern.
"""
return await _build_oauth_authorization_server_response(
return _build_oauth_authorization_server_response(
request=request,
mcp_server_name=mcp_server_name,
)

View file

@ -835,7 +835,7 @@ class JWTHandler:
_unscoped_jwt_warning_emitted = False
@classmethod
def _build_decode_kwargs(cls) -> dict:
def _build_decode_kwargs(cls, has_issuer_config: bool = False) -> dict:
"""Build the audience/issuer/options kwargs for ``jwt.decode``.
Setting ``JWT_AUDIENCE`` (and optionally ``JWT_ISSUER``) turns on the
@ -843,6 +843,11 @@ class JWTHandler:
minted by other applications that share the same IdP signing keys.
When both are unset PyJWT only checks the signature and expiry, which
is preserved for backward compatibility but logged once as a warning.
``has_issuer_config`` suppresses the warning when the caller has
configured per-issuer scoping via ``LiteLLM_JWTAuth.issuers``: this
global path is then just the fallback for tokens whose ``iss`` did not
match any configured issuer, not the only scoping mechanism.
"""
audience = os.getenv("JWT_AUDIENCE")
issuer = os.getenv("JWT_ISSUER")
@ -850,6 +855,7 @@ class JWTHandler:
if (
audience is None
and issuer is None
and not has_issuer_config
and not cls._unscoped_jwt_warning_emitted
):
verbose_proxy_logger.warning(
@ -1051,7 +1057,9 @@ class JWTHandler:
kid=kid,
)
decode_kwargs = self._build_decode_kwargs()
decode_kwargs = self._build_decode_kwargs(
has_issuer_config=bool(getattr(self.litellm_jwtauth, "issuers", None))
)
public_key = await self.get_public_key(kid=kid)

View file

@ -1592,7 +1592,7 @@ async def test_oauth_authorization_server_returns_empty_scopes_when_none():
mock_request.headers = {}
try:
response = await _build_oauth_authorization_server_response(
response = _build_oauth_authorization_server_response(
request=mock_request,
mcp_server_name="atlassian_mcp",
)
@ -1981,7 +1981,7 @@ async def test_discovery_root_includes_server_name_prefix():
try:
# Call with mcp_server_name=None (root discovery)
response = await _build_oauth_authorization_server_response(
response = _build_oauth_authorization_server_response(
request=mock_request,
mcp_server_name=None,
)
@ -2024,7 +2024,7 @@ async def test_discovery_root_does_not_expose_private_server_for_external_client
"litellm.proxy._experimental.mcp_server.discoverable_endpoints.IPAddressUtils.get_mcp_client_ip",
return_value="198.51.100.10",
):
authorization_response = await _build_oauth_authorization_server_response(
authorization_response = _build_oauth_authorization_server_response(
request=mock_request,
mcp_server_name=None,
)

View file

@ -2754,3 +2754,26 @@ def test_build_decode_kwargs_no_warning_when_scoped(
if "neither JWT_AUDIENCE nor JWT_ISSUER" in r.getMessage()
]
assert matching == []
def test_build_decode_kwargs_no_warning_when_issuer_config_scoped(
monkeypatch, _reset_unscoped_warning_flag, caplog
):
"""When per-issuer config (``LiteLLM_JWTAuth.issuers``) scopes the proxy,
the global path's unscoped-fallback warning must not fire — admins who
intentionally use issuer config without env-var scoping otherwise see a
misleading warning implying their JWT auth is unscoped."""
import logging
monkeypatch.delenv("JWT_AUDIENCE", raising=False)
monkeypatch.delenv("JWT_ISSUER", raising=False)
caplog.set_level(logging.WARNING)
JWTHandler._build_decode_kwargs(has_issuer_config=True)
matching = [
r
for r in caplog.records
if "neither JWT_AUDIENCE nor JWT_ISSUER" in r.getMessage()
]
assert matching == []