fix(mcp)!: cap the ID-JAG credential by its signed resource claim and refuse a resource the gateway does not serve (#47094)

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Yassin Kortam 2026-10-17 10:25:44 -07:00 • committed by GitHub
parent 49b5740be8
commit 8b1859c50d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 117 additions and 6 deletions

View file

@ -248,6 +248,7 @@ class SubjectTokenRefusal(LiteLLMBaseModel):
"invalid_grant",
"invalid_client",
"invalid_scope",
"invalid_target",
"temporarily_unavailable",
]
description: str = Field(min_length=1)
@ -1527,7 +1528,7 @@ def _subject_token_refusal_response(refusal: SubjectTokenRefusal) -> Response:
return _oauth_error(503, refusal.error, refusal.description)
case "invalid_client":
return _oauth_error(401, refusal.error, refusal.description)
case "unsupported_grant_type" | "invalid_request" | "invalid_grant" | "invalid_scope":
case "unsupported_grant_type" | "invalid_request" | "invalid_grant" | "invalid_scope" | "invalid_target":
return _oauth_error(400, refusal.error, refusal.description)
case _:
assert_never(refusal.error)

View file

@ -23,6 +23,7 @@ from urllib.parse import unquote_plus
import jwt
from fastapi import Request
from pydantic import TypeAdapter, ValidationError
from litellm._logging import verbose_proxy_logger
from litellm.models.user import LiteLLM_UserTable
@ -48,6 +49,7 @@ _INACTIVE_SUBJECT: Final = frozenset(("no_active_key", "unresolvable"))
_REPLAY_KEY_PREFIX: Final = "mcp_gateway_dcr:id_jag:"
SCOPE_MCP: Final = "litellm:mcp"
SCOPE_LLM: Final = "litellm:llm"
_RESOURCE_CLAIM_ADAPTER: Final = TypeAdapter(list[str])
SCOPE_ROUTE_GROUPS: Final = MappingProxyType(
{SCOPE_MCP: ("mcp_inference_routes",), SCOPE_LLM: ("llm_api_routes_without_mcp",)}
)
@ -65,9 +67,9 @@ class GrantedScope:
def granted_scope(claim: object, *, mcp_resource: bool) -> GrantedScope | SubjectTokenRefusal:
"""The routes the assertion buys. With ``aud`` the bare origin the ``scope`` claim decides and
no claim is the full credential. With ``aud`` the MCP resource (``{base}/mcp``) the identity
provider named that resource, so the credential is ceilinged to the MCP routes whatever the
claim says: ``litellm:llm`` cannot be granted and is refused."""
no claim is the full credential. With the MCP resource (``{base}/mcp``) as ``aud`` or as every
``resource``, the identity provider named that resource, so the credential is ceilinged to the
MCP routes whatever the claim says: ``litellm:llm`` cannot be granted and is refused."""
if claim is not None and not isinstance(claim, str):
return SubjectTokenRefusal(error="invalid_scope", description=_SCOPE_DESCRIPTION)
requested: Final = frozenset(claim.split()) if claim else frozenset()
@ -90,6 +92,32 @@ def names_mcp_resource(aud: object, base_url: str | None) -> bool:
return base_url is not None and f"{base_url}/mcp" in audiences and base_url not in audiences
def _resource_claim_values(claim: object) -> tuple[str, ...] | None:
if isinstance(claim, str):
return (claim,)
if not isinstance(claim, list):
return None
try:
values: Final = _RESOURCE_CLAIM_ADAPTER.validate_python(claim, strict=True)
except ValidationError:
return None
return tuple(values) if values else None
def resource_names_mcp(claims: Mapping[str, object], base_url: str | None) -> bool | SubjectTokenRefusal:
"""The signed RFC 8707 ``resource`` claim (a URI or an array of URIs) restricts the credential like an
``aud`` of ``{base}/mcp`` does."""
if "resource" not in claims:
return False
resources: Final = _resource_claim_values(claims["resource"])
if resources is None:
return SubjectTokenRefusal(error="invalid_target", description=_RESOURCE_DESCRIPTION)
served_resources: Final = frozenset(gateway_audiences(base_url))
if any(resource not in served_resources for resource in resources):
return SubjectTokenRefusal(error="invalid_target", description=_RESOURCE_DESCRIPTION)
return frozenset(resources) == frozenset((f"{base_url}/mcp",))
def _route_groups(scopes: Sequence[str]) -> Iterator[str]:
for scope in scopes:
yield from SCOPE_ROUTE_GROUPS[scope]
@ -98,8 +126,10 @@ def _route_groups(scopes: Sequence[str]) -> Iterator[str]:
_SCOPE_DESCRIPTION: Final = (
f"scope must be {SCOPE_MCP} (MCP gateway routes), {SCOPE_LLM} (LLM API routes), or both, space separated"
)
_RESOURCE_DESCRIPTION: Final = "the assertion's resource claim must name this gateway's origin or its /mcp resource"
_MCP_RESOURCE_DESCRIPTION: Final = (
f"the /mcp audience only grants MCP routes ({SCOPE_MCP}); use the bare origin as audience for LLM routes"
f"the /mcp audience or resource only grants MCP routes ({SCOPE_MCP}); use the bare origin as audience and "
"resource for LLM routes"
)
@ -450,8 +480,12 @@ async def identity_from_id_jag(
if isinstance(marker, SubjectTokenRefusal):
return marker
replay_key, replay_ttl_seconds = marker
mcp_resource: Final = resource_names_mcp(verified, prerequisites.base_url)
if isinstance(mcp_resource, SubjectTokenRefusal):
return mcp_resource
scope: Final = granted_scope(
verified.get("scope"), mcp_resource=names_mcp_resource(verified.get("aud"), prerequisites.base_url)
verified.get("scope"),
mcp_resource=mcp_resource or names_mcp_resource(verified.get("aud"), prerequisites.base_url),
)
if isinstance(scope, SubjectTokenRefusal):
return scope

View file

@ -3384,6 +3384,7 @@ async def test_jwt_bearer_grant_claims_the_jti_before_it_mints():
("invalid_client", 401),
("invalid_grant", 400),
("invalid_request", 400),
("invalid_target", 400),
("unsupported_grant_type", 400),
("temporarily_unavailable", 503),
],

View file

@ -33,6 +33,7 @@ from litellm.proxy._experimental.mcp_server.id_jag_grant import (
jwt_bearer_available,
names_mcp_resource,
proxy_user_lookup,
resource_names_mcp,
select_team,
subject_resolver,
)
@ -87,6 +88,16 @@ def _assertion(typ: str = ID_JAG_TYP, **claims: object) -> str:
)
def _unscoped_assertion(**claims: object) -> str:
unscoped_claims: Final = {key: value for key, value in _claims(**claims).items() if key != "scope"}
return jwt.encode(
unscoped_claims,
"unused-by-the-fake-verifier-but-long-enough-for-hs256",
algorithm="HS256",
headers={"typ": ID_JAG_TYP},
)
class _Verifier:
"""Stands in for the injected ``DecodeAssertion`` port; records each call as a new tuple."""
@ -714,6 +725,40 @@ def test_the_mcp_resource_audience_is_the_mcp_path_without_the_bare_origin(aud,
assert names_mcp_resource(aud, None) is False
@pytest.mark.parametrize(
"claims, expected",
[
({}, False),
({"resource": f"{GATEWAY}/mcp"}, True),
({"resource": [f"{GATEWAY}/mcp"]}, True),
({"resource": GATEWAY}, False),
({"resource": [GATEWAY, f"{GATEWAY}/mcp"]}, False),
({"resource": "https://other.example"}, None),
({"resource": [f"{GATEWAY}/mcp", "https://other.example"]}, None),
({"resource": f"{GATEWAY}/mcp/"}, None),
({"resource": None}, None),
({"resource": 7}, None),
({"resource": []}, None),
({"resource": [7]}, None),
],
)
def test_the_signed_resource_claim_must_name_only_gateway_resources(
claims: Mapping[str, object], expected: bool | None
) -> None:
result: Final = resource_names_mcp(claims, GATEWAY)
if isinstance(result, bool):
assert expected is result
return
assert expected is None
assert result.error == "invalid_target"
def test_the_signed_resource_claim_is_refused_without_a_gateway_base_url() -> None:
result: Final = resource_names_mcp({"resource": f"{GATEWAY}/mcp"}, None)
assert isinstance(result, SubjectTokenRefusal)
assert result.error == "invalid_target"
@pytest.mark.asyncio
async def test_redeeming_for_the_mcp_resource_narrows_the_identity_and_refuses_llm_scope():
"""End to end through identity_from_id_jag: the same verified claims, with ``aud`` the MCP
@ -736,3 +781,33 @@ async def test_redeeming_for_the_mcp_resource_narrows_the_identity_and_refuses_l
scoped = await _identity(_redemption(_assertion(aud=AUDIENCES[1], scope="litellm:llm")), _Verifier(), _Resolver())
assert isinstance(scoped, AssertionIdentity)
assert scoped.scope == "litellm:llm"
@pytest.mark.asyncio
async def test_redeeming_for_the_signed_resource_caps_the_identity_before_subject_lookup() -> None:
mcp_claim: Final = _unscoped_assertion(aud=GATEWAY, resource=f"{GATEWAY}/mcp")
narrowed: Final = await _identity(_redemption(mcp_claim), _Verifier(), _Resolver())
assert isinstance(narrowed, AssertionIdentity)
assert (narrowed.scope, narrowed.allowed_routes) == ("litellm:mcp", ("mcp_inference_routes",))
llm_claim: Final = _assertion(aud=GATEWAY, resource=f"{GATEWAY}/mcp", scope="litellm:llm")
refused_scope: Final = await _identity(_redemption(llm_claim), _Verifier(), _Resolver())
assert isinstance(refused_scope, SubjectTokenRefusal)
assert refused_scope.error == "invalid_scope"
other_resource: Final = _unscoped_assertion(aud=GATEWAY, resource="https://other.example")
resolver: Final = _Resolver()
refused_resource: Final = await _identity(_redemption(other_resource), _Verifier(), resolver)
assert isinstance(refused_resource, SubjectTokenRefusal)
assert refused_resource.error == "invalid_target"
assert resolver.calls == ()
origin_resource: Final = _unscoped_assertion(aud=GATEWAY, resource=GATEWAY)
full_resource: Final = await _identity(_redemption(origin_resource), _Verifier(), _Resolver())
assert isinstance(full_resource, AssertionIdentity)
assert (full_resource.scope, full_resource.allowed_routes) == (None, None)
no_resource: Final = _unscoped_assertion(aud=GATEWAY)
full_control: Final = await _identity(_redemption(no_resource), _Verifier(), _Resolver())
assert isinstance(full_control, AssertionIdentity)
assert (full_control.scope, full_control.allowed_routes) == (None, None)