From 8b1859c50d337b9b8410dca35e58e293c87dd1a5 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Sat, 17 Oct 2026 10:25:44 -0700 Subject: [PATCH] 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> --- .../mcp_server/gateway_dcr_flow.py | 3 +- .../_experimental/mcp_server/id_jag_grant.py | 44 +++++++++-- .../mcp_server/test_gateway_dcr_flow.py | 1 + .../mcp_server/test_id_jag_grant.py | 75 +++++++++++++++++++ 4 files changed, 117 insertions(+), 6 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/gateway_dcr_flow.py b/litellm/proxy/_experimental/mcp_server/gateway_dcr_flow.py index 90289fd4c20..70e4bac01b5 100644 --- a/litellm/proxy/_experimental/mcp_server/gateway_dcr_flow.py +++ b/litellm/proxy/_experimental/mcp_server/gateway_dcr_flow.py @@ -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) diff --git a/litellm/proxy/_experimental/mcp_server/id_jag_grant.py b/litellm/proxy/_experimental/mcp_server/id_jag_grant.py index 155d5280f5a..f2177df5a26 100644 --- a/litellm/proxy/_experimental/mcp_server/id_jag_grant.py +++ b/litellm/proxy/_experimental/mcp_server/id_jag_grant.py @@ -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 diff --git a/tests/unit/proxy/_experimental/mcp_server/test_gateway_dcr_flow.py b/tests/unit/proxy/_experimental/mcp_server/test_gateway_dcr_flow.py index ef95df23ff8..530667fdfdd 100644 --- a/tests/unit/proxy/_experimental/mcp_server/test_gateway_dcr_flow.py +++ b/tests/unit/proxy/_experimental/mcp_server/test_gateway_dcr_flow.py @@ -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), ], diff --git a/tests/unit/proxy/_experimental/mcp_server/test_id_jag_grant.py b/tests/unit/proxy/_experimental/mcp_server/test_id_jag_grant.py index fdfa0544ad1..f4fa51d57ba 100644 --- a/tests/unit/proxy/_experimental/mcp_server/test_id_jag_grant.py +++ b/tests/unit/proxy/_experimental/mcp_server/test_id_jag_grant.py @@ -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)