mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-18 04:44:37 +00:00
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:
parent
49b5740be8
commit
8b1859c50d
4 changed files with 117 additions and 6 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue