mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-17 04:34:25 +00:00
feat(mcp): accept an ID-JAG as an RFC 7523 jwt-bearer grant on the gateway token endpoint (#46748)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
1dfceccf10
commit
bca7650e8f
21 changed files with 2535 additions and 126 deletions
|
|
@ -1345,7 +1345,9 @@ class MCPRequestHandler:
|
|||
if auth is None:
|
||||
return None
|
||||
refreshed = auth.model_copy()
|
||||
if auth.mcp_admitted_user_subject and auth.user_id:
|
||||
if (auth.mcp_admitted_user_subject or auth.is_session_token) and auth.user_id:
|
||||
# A session credential (``lite login`` or a redeemed ID-JAG) has no key row, its authority is
|
||||
# sealed in the token: refresh the live user policy and keep the token's team, routes and models.
|
||||
current = await MCPRequestHandler.reload_admitted_user(auth.user_id, requires_fresh_policy=True)
|
||||
refreshed.object_permission = current.object_permission
|
||||
refreshed.object_permission_id = current.object_permission_id
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ from litellm.proxy._experimental.mcp_server.faults import (
|
|||
render_token_fault,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.gateway_dcr_flow import (
|
||||
JWT_BEARER_GRANT_TYPE,
|
||||
VendorCredentialState,
|
||||
aggregate_authorize,
|
||||
aggregate_token,
|
||||
|
|
@ -71,6 +72,11 @@ from litellm.proxy._experimental.mcp_server.gateway_dcr_flow import (
|
|||
revoke_refresh_token,
|
||||
supported_grant_types,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.id_jag_grant import (
|
||||
basic_client_id,
|
||||
jwt_bearer_available,
|
||||
redeem_id_jag_assertion,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.idp_token_exchange import (
|
||||
exchange_idp_subject_token,
|
||||
token_exchange_available,
|
||||
|
|
@ -2396,7 +2402,7 @@ async def token_endpoint(
|
|||
grant_type: str = Form(...),
|
||||
code: str = Form(None),
|
||||
redirect_uri: str = Form(None),
|
||||
client_id: str = Form(...),
|
||||
client_id: str | None = Form(None),
|
||||
client_secret: str | None = Form(None),
|
||||
code_verifier: str = Form(None),
|
||||
refresh_token: str | None = Form(None),
|
||||
|
|
@ -2405,6 +2411,7 @@ async def token_endpoint(
|
|||
subject_token: str | None = Form(None),
|
||||
subject_token_type: str | None = Form(None),
|
||||
requested_token_type: str | None = Form(None),
|
||||
assertion: str | None = Form(None),
|
||||
mcp_server_name: str | None = None,
|
||||
):
|
||||
"""
|
||||
|
|
@ -2416,7 +2423,14 @@ async def token_endpoint(
|
|||
3. Return the token
|
||||
4. Return a virtual key in this response
|
||||
"""
|
||||
if mcp_server_name is None and is_gateway_dcr_client_id(client_id):
|
||||
presented_client_id: Final = client_id or basic_client_id(request.headers.get("authorization"))
|
||||
if presented_client_id is None:
|
||||
raise HTTPException(
|
||||
status_code=400, detail={"error": "invalid_request", "error_description": "client_id is required"}
|
||||
)
|
||||
if mcp_server_name is None and (
|
||||
is_gateway_dcr_client_id(presented_client_id) or grant_type == JWT_BEARER_GRANT_TYPE
|
||||
):
|
||||
from litellm.proxy.proxy_server import ( # noqa: PLC0415 # circular import at module load
|
||||
master_key,
|
||||
user_api_key_cache,
|
||||
|
|
@ -2427,7 +2441,7 @@ async def token_endpoint(
|
|||
grant_type=grant_type,
|
||||
code=code,
|
||||
redirect_uri=redirect_uri,
|
||||
client_id=client_id,
|
||||
client_id=presented_client_id,
|
||||
code_verifier=code_verifier,
|
||||
refresh_token=refresh_token,
|
||||
master_key=master_key,
|
||||
|
|
@ -2439,9 +2453,12 @@ async def token_endpoint(
|
|||
subject_token_type=subject_token_type,
|
||||
requested_token_type=requested_token_type,
|
||||
exchange_subject_token=exchange_idp_subject_token,
|
||||
assertion=assertion,
|
||||
client_secret=client_secret,
|
||||
redeem_assertion=redeem_id_jag_assertion,
|
||||
)
|
||||
|
||||
lookup_name: Final = mcp_server_name or client_id
|
||||
lookup_name: Final = mcp_server_name or presented_client_id
|
||||
client_ip: Final = IPAddressUtils.get_mcp_client_ip(request)
|
||||
mcp_server = _resolve_mcp_server_by_name_or_id(lookup_name, client_ip)
|
||||
if mcp_server is None and mcp_server_name is None:
|
||||
|
|
@ -2454,7 +2471,7 @@ async def token_endpoint(
|
|||
grant_type=grant_type,
|
||||
code=code,
|
||||
redirect_uri=redirect_uri,
|
||||
client_id=client_id,
|
||||
client_id=presented_client_id,
|
||||
client_secret=client_secret,
|
||||
code_verifier=code_verifier,
|
||||
refresh_token=refresh_token,
|
||||
|
|
@ -2568,7 +2585,8 @@ async def native_client_auth_discovery(request: Request) -> JSONResponse:
|
|||
"""The versioned contract a native client (``lite login --pkce``, or a CLI in any other
|
||||
language) reads to sign a user in through the browser and obtain a proxy credential."""
|
||||
return JSONResponse(
|
||||
native_client_auth_contract(request, token_exchange_available()), headers=TOKEN_NO_CACHE_HEADERS
|
||||
native_client_auth_contract(request, token_exchange_available(), jwt_bearer_available()),
|
||||
headers=TOKEN_NO_CACHE_HEADERS,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -3135,7 +3153,7 @@ def _aggregate_header_scopes(request: Request) -> tuple[str, ...]:
|
|||
|
||||
|
||||
def _build_aggregate_authorization_server_response(
|
||||
request: Request, token_exchange_available: bool
|
||||
request: Request, token_exchange_available: bool, jwt_bearer_available: bool = False
|
||||
) -> dict[str, object]:
|
||||
"""RFC 8414 metadata for the gateway as the aggregate authorization server.
|
||||
|
||||
|
|
@ -3155,9 +3173,13 @@ def _build_aggregate_authorization_server_response(
|
|||
"registration_endpoint": f"{request_base_url}/register",
|
||||
"response_types_supported": ["code"],
|
||||
"scopes_supported": [],
|
||||
"grant_types_supported": supported_grant_types(token_exchange_available),
|
||||
"grant_types_supported": supported_grant_types(token_exchange_available, jwt_bearer_available),
|
||||
"code_challenge_methods_supported": ["S256"],
|
||||
"token_endpoint_auth_methods_supported": ["none", "client_secret_post"],
|
||||
"token_endpoint_auth_methods_supported": (
|
||||
["none", "client_secret_post", "client_secret_basic"]
|
||||
if jwt_bearer_available
|
||||
else ["none", "client_secret_post"]
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -3193,7 +3215,7 @@ async def oauth_authorization_server_aggregate(request: Request):
|
|||
per-server row win here instead would serve an issuer of {base} against a resource that
|
||||
advertised {base}/mcp, which fails the RFC 8414 issuer check and breaks the front door.
|
||||
"""
|
||||
return _build_aggregate_authorization_server_response(request, token_exchange_available())
|
||||
return _build_aggregate_authorization_server_response(request, token_exchange_available(), jwt_bearer_available())
|
||||
|
||||
|
||||
# Standard MCP pattern: /.well-known/oauth-protected-resource/mcp/{server_name}
|
||||
|
|
@ -3273,6 +3295,10 @@ def _build_oauth_authorization_server_response(
|
|||
if explicitly_named
|
||||
else request_base_url
|
||||
)
|
||||
# With no upstream resolved the document is the gateway's own: its issuer is the bare origin
|
||||
# and its /token serves every grant the gateway does, including the ID-JAG jwt-bearer grant.
|
||||
gateway_owned: Final = mcp_server_name is None and issuer_path is None
|
||||
jwt_bearer: Final = gateway_owned and jwt_bearer_available()
|
||||
|
||||
return {
|
||||
"issuer": issuer,
|
||||
|
|
@ -3280,9 +3306,15 @@ def _build_oauth_authorization_server_response(
|
|||
"token_endpoint": token_endpoint,
|
||||
"response_types_supported": ["code"],
|
||||
"scopes_supported": (mcp_server.scopes if mcp_server and mcp_server.scopes else []),
|
||||
"grant_types_supported": ["authorization_code", "refresh_token"],
|
||||
"grant_types_supported": (
|
||||
list(supported_grant_types(token_exchange_available(), jwt_bearer))
|
||||
if gateway_owned
|
||||
else ["authorization_code", "refresh_token"]
|
||||
),
|
||||
"code_challenge_methods_supported": ["S256"],
|
||||
"token_endpoint_auth_methods_supported": ["client_secret_post"],
|
||||
"token_endpoint_auth_methods_supported": (
|
||||
["none", "client_secret_post", "client_secret_basic"] if jwt_bearer else ["client_secret_post"]
|
||||
),
|
||||
# Claude expects a registration endpoint, even if we just fake it
|
||||
"registration_endpoint": (
|
||||
f"{request_base_url}/{mcp_server_name}/register" if mcp_server_name else f"{request_base_url}/register"
|
||||
|
|
|
|||
|
|
@ -198,20 +198,26 @@ class MintProxyCredential(Protocol):
|
|||
and mints the same credential ``lite login`` mints."""
|
||||
|
||||
def __call__(
|
||||
self, user_id: str, team_id: str | None, /
|
||||
self, user_id: str, team_id: str | None, /, *, allowed_routes: Sequence[str] | None = None
|
||||
) -> Awaitable[MintedProxyCredential | ProxyCredentialMintFailure]: ...
|
||||
|
||||
|
||||
TOKEN_EXCHANGE_GRANT_TYPE: Final = "urn:ietf:params:oauth:grant-type:token-exchange"
|
||||
JWT_BEARER_GRANT_TYPE: Final = "urn:ietf:params:oauth:grant-type:jwt-bearer"
|
||||
|
||||
|
||||
def supported_grant_types(token_exchange_available: bool) -> tuple[str, ...]:
|
||||
def supported_grant_types(token_exchange_available: bool, jwt_bearer_available: bool = False) -> tuple[str, ...]:
|
||||
"""The grants ``/token`` can serve on this deployment. The RFC 8693 exchange is listed
|
||||
only where the JWT auth that proves a subject token is on, backed by a database, and
|
||||
licensed, so a client never selects a grant the gateway would then refuse."""
|
||||
if token_exchange_available:
|
||||
return ("authorization_code", "refresh_token", TOKEN_EXCHANGE_GRANT_TYPE)
|
||||
return ("authorization_code", "refresh_token")
|
||||
licensed, so a client never selects a grant the gateway would then refuse. The RFC 7523
|
||||
jwt-bearer grant (an ID-JAG from the identity provider) is listed only where, on top of
|
||||
that, the operator has allowlisted at least one requesting app."""
|
||||
return (
|
||||
"authorization_code",
|
||||
"refresh_token",
|
||||
*((TOKEN_EXCHANGE_GRANT_TYPE,) if token_exchange_available else ()),
|
||||
*((JWT_BEARER_GRANT_TYPE,) if jwt_bearer_available else ()),
|
||||
)
|
||||
|
||||
|
||||
"""RFC 8693: a native client that already holds a token from the customer's identity
|
||||
|
|
@ -236,10 +242,29 @@ class SubjectIdentity(LiteLLMBaseModel):
|
|||
|
||||
class SubjectTokenRefusal(LiteLLMBaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
error: Literal["unsupported_grant_type", "invalid_request", "temporarily_unavailable"]
|
||||
error: Literal[
|
||||
"unsupported_grant_type",
|
||||
"invalid_request",
|
||||
"invalid_grant",
|
||||
"invalid_client",
|
||||
"invalid_scope",
|
||||
"temporarily_unavailable",
|
||||
]
|
||||
description: str = Field(min_length=1)
|
||||
|
||||
|
||||
class AssertionIdentity(SubjectIdentity):
|
||||
"""The litellm user an RFC 7523 assertion (an ID-JAG) stands for, plus what the grant
|
||||
tail needs from the assertion itself: the single-use key for its ``jti``, how long that
|
||||
marker must outlive it, the ``scope`` the credential is issued for, and the route groups that
|
||||
scope narrows the credential to (``None`` leaves it the full ``lite login`` credential)."""
|
||||
|
||||
replay_key: str = Field(min_length=1)
|
||||
replay_ttl_seconds: int = Field(gt=0)
|
||||
scope: str | None = None
|
||||
allowed_routes: tuple[str, ...] | None = None
|
||||
|
||||
|
||||
class ExchangeSubjectToken(Protocol):
|
||||
"""Injected RFC 8693 subject-token verifier ``(subject_token, request)``: proves the
|
||||
IdP token the way the proxy's own JWT auth does and names the litellm user and team it
|
||||
|
|
@ -272,7 +297,9 @@ class LookupConsentTeams(Protocol):
|
|||
def __call__(self, user_id: str, /) -> Awaitable[tuple[ConsentTeam, ...] | ReloadUserFailure]: ...
|
||||
|
||||
|
||||
async def _refuse_proxy_credential(user_id: str, team_id: str | None) -> ProxyCredentialMintFailure:
|
||||
async def _refuse_proxy_credential(
|
||||
user_id: str, team_id: str | None, *, allowed_routes: Sequence[str] | None = None
|
||||
) -> ProxyCredentialMintFailure:
|
||||
return "unresolvable"
|
||||
|
||||
|
||||
|
|
@ -282,6 +309,25 @@ async def _refuse_subject_token(subject_token: str, request: Request) -> Subject
|
|||
)
|
||||
|
||||
|
||||
class RedeemAssertion(Protocol):
|
||||
"""Injected RFC 7523 assertion verifier ``(assertion, client_id, client_secret, request)``:
|
||||
proves an ID-JAG the identity provider minted for the requesting app ``client_id``,
|
||||
authenticates that app, and names the litellm user the assertion stands for, or says why
|
||||
this gateway will not take it."""
|
||||
|
||||
def __call__(
|
||||
self, assertion: str, client_id: str, client_secret: str | None, request: Request, /
|
||||
) -> Awaitable[AssertionIdentity | SubjectTokenRefusal]: ...
|
||||
|
||||
|
||||
async def _refuse_assertion(
|
||||
assertion: str, client_id: str, client_secret: str | None, request: Request
|
||||
) -> SubjectTokenRefusal:
|
||||
return SubjectTokenRefusal(
|
||||
error="unsupported_grant_type", description="this gateway is not configured to redeem identity assertions"
|
||||
)
|
||||
|
||||
|
||||
async def _unavailable_vendor_credential(
|
||||
user_id: str, server_id: str, requested_scopes: tuple[str, ...] = ()
|
||||
) -> VendorCredentialState:
|
||||
|
|
@ -797,7 +843,9 @@ class NativeClientAuthContract(TypedDict):
|
|||
revocation_endpoint_auth_methods_supported: ReadOnly[tuple[str, ...]]
|
||||
|
||||
|
||||
def native_client_auth_contract(request: Request, token_exchange_available: bool) -> NativeClientAuthContract:
|
||||
def native_client_auth_contract(
|
||||
request: Request, token_exchange_available: bool, jwt_bearer_available: bool = False
|
||||
) -> NativeClientAuthContract:
|
||||
"""The versioned discovery document at ``/.well-known/litellm-cli-auth``: everything a
|
||||
native client (in any language) needs to run the sign-in without reading LiteLLM
|
||||
source. ``resource`` is the exact value to send as the RFC 8707 ``resource`` parameter
|
||||
|
|
@ -812,7 +860,7 @@ def native_client_auth_contract(request: Request, token_exchange_available: bool
|
|||
"revocation_endpoint": f"{base_url}/revoke",
|
||||
"resource": base_url,
|
||||
"response_types_supported": ("code",),
|
||||
"grant_types_supported": supported_grant_types(token_exchange_available),
|
||||
"grant_types_supported": supported_grant_types(token_exchange_available, jwt_bearer_available),
|
||||
"code_challenge_methods_supported": ("S256",),
|
||||
"token_endpoint_auth_methods_supported": ("none",),
|
||||
"revocation_endpoint_auth_methods_supported": ("none",),
|
||||
|
|
@ -1403,10 +1451,11 @@ class _ProxyCredentialTokenResponse(TypedDict):
|
|||
access_token: ReadOnly[str]
|
||||
token_type: ReadOnly[Literal["Bearer"]]
|
||||
expires_in: ReadOnly[int]
|
||||
refresh_token: ReadOnly[str]
|
||||
refresh_token: NotRequired[ReadOnly[str]]
|
||||
user_id: ReadOnly[str]
|
||||
team_id: ReadOnly[str | None]
|
||||
issued_token_type: NotRequired[ReadOnly[_IssuedTokenType]]
|
||||
scope: NotRequired[ReadOnly[str]]
|
||||
|
||||
|
||||
def proxy_credential_response(
|
||||
|
|
@ -1415,6 +1464,7 @@ def proxy_credential_response(
|
|||
signing: SessionSigning,
|
||||
issued_token_type: _IssuedTokenType | None = None,
|
||||
family: str | None = None,
|
||||
scope: str | None = None,
|
||||
) -> Response:
|
||||
"""The proxy-API token response: the access token is the very credential ``lite
|
||||
login`` stores (accepted on every proxy route with user and team attribution), and
|
||||
|
|
@ -1426,7 +1476,7 @@ def proxy_credential_response(
|
|||
refresh: Final = mint_session_refresh_token(bound_principal, signing.keys, signing.now, family=family)
|
||||
if not isinstance(refresh, MintedSessionToken):
|
||||
return _oauth_error(500, "server_error", "failed to mint the session credential")
|
||||
credential: Final[_ProxyCredentialTokenResponse] = {
|
||||
renewable: Final[_ProxyCredentialTokenResponse] = {
|
||||
"access_token": minted.key,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": minted.expires_in,
|
||||
|
|
@ -1434,12 +1484,27 @@ def proxy_credential_response(
|
|||
"user_id": minted.user_id,
|
||||
"team_id": minted.team_id,
|
||||
}
|
||||
credential: Final[_ProxyCredentialTokenResponse] = renewable if scope is None else {**renewable, "scope": scope}
|
||||
if issued_token_type is None:
|
||||
return JSONResponse(status_code=200, content=credential, headers=TOKEN_NO_CACHE_HEADERS)
|
||||
exchanged: Final[_ProxyCredentialTokenResponse] = {**credential, "issued_token_type": issued_token_type}
|
||||
return JSONResponse(status_code=200, content=exchanged, headers=TOKEN_NO_CACHE_HEADERS)
|
||||
|
||||
|
||||
def assertion_credential_response(minted: MintedProxyCredential, scope: str | None) -> Response:
|
||||
"""The proxy-API token response for an assertion grant: the same access token, with the
|
||||
scope the assertion carried, and no refresh token."""
|
||||
unscoped: Final[_ProxyCredentialTokenResponse] = {
|
||||
"access_token": minted.key,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": minted.expires_in,
|
||||
"user_id": minted.user_id,
|
||||
"team_id": minted.team_id,
|
||||
}
|
||||
credential: Final[_ProxyCredentialTokenResponse] = unscoped if scope is None else {**unscoped, "scope": scope}
|
||||
return JSONResponse(status_code=200, content=credential, headers=TOKEN_NO_CACHE_HEADERS)
|
||||
|
||||
|
||||
def _reload_failure_response(failure: ReloadUserFailure) -> Response:
|
||||
"""Map the live-user revalidation failure onto its OAuth error, exhaustively, so a new
|
||||
``ReloadUserFailure`` member is a type error here rather than silently 400ing."""
|
||||
|
|
@ -1460,7 +1525,9 @@ def _subject_token_refusal_response(refusal: SubjectTokenRefusal) -> Response:
|
|||
match refusal.error:
|
||||
case "temporarily_unavailable":
|
||||
return _oauth_error(503, refusal.error, refusal.description)
|
||||
case "unsupported_grant_type" | "invalid_request":
|
||||
case "invalid_client":
|
||||
return _oauth_error(401, refusal.error, refusal.description)
|
||||
case "unsupported_grant_type" | "invalid_request" | "invalid_grant" | "invalid_scope":
|
||||
return _oauth_error(400, refusal.error, refusal.description)
|
||||
case _:
|
||||
assert_never(refusal.error)
|
||||
|
|
@ -1515,6 +1582,9 @@ async def aggregate_token(
|
|||
subject_token_type: str | None = None,
|
||||
requested_token_type: str | None = None,
|
||||
exchange_subject_token: ExchangeSubjectToken = _refuse_subject_token,
|
||||
assertion: str | None = None,
|
||||
client_secret: str | None = None,
|
||||
redeem_assertion: RedeemAssertion = _refuse_assertion,
|
||||
) -> Response:
|
||||
"""The aggregate token verb: authorization_code and refresh_token grants for the
|
||||
identity-only session pair, or for the proxy-API credential when the grant was issued
|
||||
|
|
@ -1561,10 +1631,19 @@ async def aggregate_token(
|
|||
exchange_subject_token=exchange_subject_token,
|
||||
issue=issue,
|
||||
)
|
||||
if grant_type == JWT_BEARER_GRANT_TYPE:
|
||||
return await _jwt_bearer_grant(
|
||||
request=request,
|
||||
assertion=assertion,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
redeem_assertion=redeem_assertion,
|
||||
issue=issue,
|
||||
)
|
||||
return _oauth_error(
|
||||
400,
|
||||
"unsupported_grant_type",
|
||||
f"grant_type must be authorization_code, refresh_token, or {TOKEN_EXCHANGE_GRANT_TYPE}",
|
||||
f"grant_type must be authorization_code, refresh_token, {TOKEN_EXCHANGE_GRANT_TYPE}, or {JWT_BEARER_GRANT_TYPE}",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1690,6 +1769,48 @@ class _GrantIssuer:
|
|||
return _mint_failure_response(minted)
|
||||
return proxy_credential_response(minted, principal, self._signing, issued_token_type=ACCESS_TOKEN_TOKEN_TYPE)
|
||||
|
||||
async def redeem_assertion(
|
||||
self, assertion: str, client_id: str, client_secret: str | None, redeem_assertion: RedeemAssertion
|
||||
) -> Response:
|
||||
"""The RFC 7523 tail: prove the assertion and the requesting app, claim the assertion's
|
||||
``jti`` before anything is minted (RFC 7523 section 3 makes a replayed ``jti`` a
|
||||
rejection, and the identity provider mints a fresh assertion cheaply, so a mint that
|
||||
fails after the claim costs the client one more round trip, never a replay window),
|
||||
then mint the proxy credential the user is revalidated for. No refresh token comes
|
||||
with it (RFC 7521 section 4.1): the app renews by asking its identity provider for a
|
||||
fresh assertion, which keeps the user's standing there in the loop."""
|
||||
target_refusal: Final = self._assertion_target_refusal()
|
||||
if target_refusal is not None:
|
||||
return target_refusal
|
||||
identity: Final = await redeem_assertion(assertion, client_id, client_secret, self._request)
|
||||
if isinstance(identity, SubjectTokenRefusal):
|
||||
return _subject_token_refusal_response(identity)
|
||||
refused: Final = _claim_refusal(
|
||||
await self._guard.claim(identity.replay_key, identity.replay_ttl_seconds),
|
||||
_oauth_error(400, "invalid_grant", "the assertion has already been redeemed"),
|
||||
)
|
||||
if refused is not None:
|
||||
return refused
|
||||
minted: Final = await self._mint_proxy_credential(
|
||||
identity.user_id, identity.team_id, allowed_routes=identity.allowed_routes
|
||||
)
|
||||
if not isinstance(minted, MintedProxyCredential):
|
||||
return _mint_failure_response(minted)
|
||||
return assertion_credential_response(minted, identity.scope)
|
||||
|
||||
def _assertion_target_refusal(self) -> Response | None:
|
||||
"""An ID-JAG names the proxy origin or its MCP resource (``{base}/mcp``) as ``aud``, and an RFC
|
||||
8707 client sends that same value as ``resource``, so both are this grant's targets."""
|
||||
if self._resource is None or is_proxy_api_resource(self._request, self._resource):
|
||||
return None
|
||||
if self._resource.rstrip("/") == f"{get_request_base_url(self._request)}/mcp":
|
||||
return None
|
||||
return _oauth_error(
|
||||
400,
|
||||
"invalid_target",
|
||||
"resource does not match the proxy API or MCP resource this gateway issues credentials for",
|
||||
)
|
||||
|
||||
def _proxy_api_target_refusal(self) -> Response | None:
|
||||
if self._resource is None or is_proxy_api_resource(self._request, self._resource):
|
||||
return None
|
||||
|
|
@ -1807,6 +1928,19 @@ async def refresh_proxy_credential(
|
|||
)
|
||||
|
||||
|
||||
async def _jwt_bearer_grant(
|
||||
request: Request,
|
||||
assertion: str | None,
|
||||
client_id: str,
|
||||
client_secret: str | None,
|
||||
redeem_assertion: RedeemAssertion,
|
||||
issue: _GrantIssuer,
|
||||
) -> Response:
|
||||
if not assertion:
|
||||
return _oauth_error(400, "invalid_request", "assertion is required")
|
||||
return await issue.redeem_assertion(assertion, client_id, client_secret, redeem_assertion)
|
||||
|
||||
|
||||
async def _token_exchange_grant(
|
||||
subject_token: str | None,
|
||||
subject_token_type: str | None,
|
||||
|
|
|
|||
540
litellm/proxy/_experimental/mcp_server/id_jag_grant.py
Normal file
540
litellm/proxy/_experimental/mcp_server/id_jag_grant.py
Normal file
|
|
@ -0,0 +1,540 @@
|
|||
"""The RFC 7523 jwt-bearer grant on ``POST /token``: a requesting app that obtained an
|
||||
ID-JAG (Identity Assertion Authorization Grant, the cross-app access profile) from the
|
||||
customer's identity provider redeems it for the same proxy-API credential ``lite login``
|
||||
stores. The assertion is proven with the JWT auth issuer and JWKS plumbing the proxy
|
||||
already trusts, its claims are checked the way the ID-JAG draft prescribes, the requesting
|
||||
app must be one the operator allowlisted, and the ``jti`` is single-use until the
|
||||
assertion expires."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import hmac
|
||||
import math
|
||||
from collections.abc import Awaitable, Iterator, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from functools import partial
|
||||
from types import MappingProxyType
|
||||
from typing import Final, Protocol
|
||||
from urllib.parse import unquote_plus
|
||||
|
||||
import jwt
|
||||
from fastapi import Request
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.models.user import LiteLLM_UserTable
|
||||
from litellm.proxy._experimental.mcp_server.gateway_dcr_flow import (
|
||||
AssertionIdentity,
|
||||
ConsentTeam,
|
||||
SubjectIdentity,
|
||||
SubjectTokenRefusal,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.idp_token_exchange import (
|
||||
gateway_outage_refusal,
|
||||
log_line,
|
||||
read_token_exchange_prerequisites,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.oauth_utils import proxy_base_url_from_env
|
||||
from litellm.proxy._types import IdJagRequestingApp
|
||||
from litellm.proxy.auth.handle_jwt import ID_JAG_TYP, AssertionRejection, JWTHandler
|
||||
|
||||
ID_JAG_REQUIRED_CLAIMS: Final = ("iss", "sub", "aud", "exp", "iat", "jti", "client_id")
|
||||
REJECTED_ASSERTION: Final = "assertion was rejected"
|
||||
TEAM_HEADER: Final = "x-litellm-team-id"
|
||||
_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"
|
||||
SCOPE_ROUTE_GROUPS: Final = MappingProxyType(
|
||||
{SCOPE_MCP: ("mcp_inference_routes",), SCOPE_LLM: ("llm_api_routes_without_mcp",)}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GrantedScope:
|
||||
"""What an assertion's ``scope`` claim buys: the scope echoed in the token response, exactly the
|
||||
tokens enforced, and the route groups the credential is narrowed to. No claim means the full
|
||||
credential, so both are ``None``."""
|
||||
|
||||
scope: str | None
|
||||
allowed_routes: tuple[str, ...] | None
|
||||
|
||||
|
||||
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."""
|
||||
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()
|
||||
if not requested <= SCOPE_ROUTE_GROUPS.keys():
|
||||
return SubjectTokenRefusal(error="invalid_scope", description=_SCOPE_DESCRIPTION)
|
||||
if mcp_resource:
|
||||
if SCOPE_LLM in requested:
|
||||
return SubjectTokenRefusal(error="invalid_scope", description=_MCP_RESOURCE_DESCRIPTION)
|
||||
return GrantedScope(scope=SCOPE_MCP, allowed_routes=SCOPE_ROUTE_GROUPS[SCOPE_MCP])
|
||||
if not requested:
|
||||
return GrantedScope(scope=None, allowed_routes=None)
|
||||
granted: Final = tuple(scope for scope in SCOPE_ROUTE_GROUPS if scope in requested)
|
||||
return GrantedScope(scope=" ".join(granted), allowed_routes=tuple(_route_groups(granted)))
|
||||
|
||||
|
||||
def names_mcp_resource(aud: object, base_url: str | None) -> bool:
|
||||
"""Whether the verified ``aud`` names only the MCP resource, ``{base}/mcp``, and not the
|
||||
authorization server itself (the bare origin)."""
|
||||
audiences: Final = frozenset((aud,) if isinstance(aud, str) else aud if isinstance(aud, list) else ())
|
||||
return base_url is not None and f"{base_url}/mcp" in audiences and base_url not in audiences
|
||||
|
||||
|
||||
def _route_groups(scopes: Sequence[str]) -> Iterator[str]:
|
||||
for scope in scopes:
|
||||
yield from SCOPE_ROUTE_GROUPS[scope]
|
||||
|
||||
|
||||
_SCOPE_DESCRIPTION: Final = (
|
||||
f"scope must be {SCOPE_MCP} (MCP gateway routes), {SCOPE_LLM} (LLM API routes), or both, space separated"
|
||||
)
|
||||
_MCP_RESOURCE_DESCRIPTION: Final = (
|
||||
f"the /mcp audience only grants MCP routes ({SCOPE_MCP}); use the bare origin as audience for LLM routes"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IdJagPrerequisites:
|
||||
"""The deployment-level gates for redeeming an ID-JAG: the JWT auth whose issuer plumbing
|
||||
proves the assertion must be on, backed by a database, and licensed, not mapping IdP tokens to
|
||||
virtual keys (whose blocks and budgets a minted credential would sidestep), with a shared Redis
|
||||
so an assertion is redeemed once across every worker and replica (the in-memory marker is per
|
||||
process, so a second worker would mint again), ``PROXY_BASE_URL``
|
||||
must fix the gateway URL the assertion's ``aud`` has to name (a request's ``Host`` is the
|
||||
caller's to choose), and the operator must have allowlisted the requesting apps. Discovery advertises the grant only when every gate
|
||||
holds, and a redemption is refused naming the first one that does not."""
|
||||
|
||||
jwt_auth_enabled: bool
|
||||
has_database: bool
|
||||
licensed: bool
|
||||
maps_jwts_to_virtual_keys: bool
|
||||
shared_replay_store: bool
|
||||
base_url: str | None
|
||||
requesting_apps: tuple[IdJagRequestingApp, ...]
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return (
|
||||
self.jwt_auth_enabled
|
||||
and self.has_database
|
||||
and self.licensed
|
||||
and not self.maps_jwts_to_virtual_keys
|
||||
and self.shared_replay_store
|
||||
and self.base_url is not None
|
||||
and bool(self.requesting_apps)
|
||||
)
|
||||
|
||||
def refusal(self) -> SubjectTokenRefusal | None:
|
||||
if not self.jwt_auth_enabled:
|
||||
return SubjectTokenRefusal(
|
||||
error="unsupported_grant_type",
|
||||
description="JWT auth is not enabled on this gateway, so it cannot redeem identity assertions",
|
||||
)
|
||||
if not self.has_database:
|
||||
return SubjectTokenRefusal(
|
||||
error="unsupported_grant_type",
|
||||
description="this gateway has no database, so it cannot redeem identity assertions",
|
||||
)
|
||||
if not self.licensed:
|
||||
return SubjectTokenRefusal(
|
||||
error="unsupported_grant_type",
|
||||
description="JWT auth is an enterprise only feature; no license is set",
|
||||
)
|
||||
if self.maps_jwts_to_virtual_keys:
|
||||
return SubjectTokenRefusal(
|
||||
error="unsupported_grant_type",
|
||||
description="this gateway maps IdP tokens to virtual keys, which the jwt-bearer grant does not serve",
|
||||
)
|
||||
if not self.shared_replay_store:
|
||||
return SubjectTokenRefusal(
|
||||
error="unsupported_grant_type",
|
||||
description="configure Redis (cache_params or coordination_redis) so this gateway can redeem an identity assertion exactly once across its workers",
|
||||
)
|
||||
if self.base_url is None:
|
||||
return SubjectTokenRefusal(
|
||||
error="unsupported_grant_type",
|
||||
description="set PROXY_BASE_URL to this gateway's public URL, the audience an identity assertion must name",
|
||||
)
|
||||
if not self.requesting_apps:
|
||||
return SubjectTokenRefusal(
|
||||
error="unsupported_grant_type",
|
||||
description="this gateway has no requesting apps allowlisted for identity assertions",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def read_id_jag_prerequisites() -> IdJagPrerequisites:
|
||||
from litellm.proxy.proxy_server import ( # noqa: PLC0415 # rebound after startup, so read them per call
|
||||
jwt_handler,
|
||||
redis_usage_cache,
|
||||
)
|
||||
|
||||
shared: Final = read_token_exchange_prerequisites()
|
||||
return IdJagPrerequisites(
|
||||
jwt_auth_enabled=shared.jwt_auth_enabled,
|
||||
has_database=shared.has_database,
|
||||
licensed=shared.licensed,
|
||||
maps_jwts_to_virtual_keys=shared.maps_jwts_to_virtual_keys,
|
||||
shared_replay_store=redis_usage_cache is not None,
|
||||
base_url=proxy_base_url_from_env(),
|
||||
requesting_apps=_requesting_apps(jwt_handler),
|
||||
)
|
||||
|
||||
|
||||
def _requesting_apps(jwt_handler: JWTHandler) -> tuple[IdJagRequestingApp, ...]:
|
||||
if not hasattr(jwt_handler, "litellm_jwtauth"):
|
||||
return ()
|
||||
return tuple(jwt_handler.litellm_jwtauth.id_jag_requesting_apps or ())
|
||||
|
||||
|
||||
def jwt_bearer_available() -> bool:
|
||||
return read_id_jag_prerequisites().available
|
||||
|
||||
|
||||
def gateway_audiences(base_url: str | None) -> tuple[str, ...]:
|
||||
"""The issuer values this gateway answers to, which an ID-JAG's ``aud`` must name: its MCP
|
||||
resource and its authorization server, both under the fixed ``PROXY_BASE_URL``."""
|
||||
return () if base_url is None else (f"{base_url}/mcp", base_url)
|
||||
|
||||
|
||||
class DecodeAssertion(Protocol):
|
||||
"""Injected signature and claim verification ``(assertion, audience, required_claims)``:
|
||||
``JWTHandler.decode_trusted_assertion`` in production, which returns the verified claims,
|
||||
a rejection, or raises when the gateway could not reach the issuer's keys."""
|
||||
|
||||
def __call__(
|
||||
self, assertion: str, audience: Sequence[str], required_claims: Sequence[str], /
|
||||
) -> Awaitable[Mapping[str, object] | AssertionRejection]: ...
|
||||
|
||||
|
||||
class ClaimReader(Protocol):
|
||||
"""The JWT auth user mapping as ``JWTHandler`` exposes it: the litellm user id and email the
|
||||
verified claims name through ``user_id_jwt_field`` and ``user_email_jwt_field``."""
|
||||
|
||||
def get_user_id(self, token: dict[str, object], default_value: str | None) -> str | None: ...
|
||||
|
||||
def get_user_email(self, token: dict[str, object], default_value: str | None) -> str | None: ...
|
||||
|
||||
def get_all_jwt_team_ids(self, token: dict[str, object]) -> list[str]: ...
|
||||
|
||||
def is_allowed_domain(self, user_email: str) -> bool: ...
|
||||
|
||||
|
||||
class ResolveSubject(Protocol):
|
||||
"""Injected subject lookup ``(claims, team_header)``: the litellm user the verified claims
|
||||
name through the JWT auth user mapping (``user_id_jwt_field`` or ``user_email_jwt_field``,
|
||||
else ``sub``) and the team the credential is minted for (see ``select_team``), ``None`` when
|
||||
the gateway knows no such user, or a refusal when the team cannot be settled. Raises on a
|
||||
database outage."""
|
||||
|
||||
def __call__(
|
||||
self, claims: Mapping[str, object], team_header: str | None, /
|
||||
) -> Awaitable[SubjectIdentity | SubjectTokenRefusal | None]: ...
|
||||
|
||||
|
||||
class LookupUser(Protocol):
|
||||
"""Injected ``(user_id, user_email)`` lookup: the stored id of the existing litellm user the
|
||||
claims name, ``None`` when the gateway knows no such user. Raises on a database outage."""
|
||||
|
||||
def __call__(self, user_id: str, user_email: str | None, /) -> Awaitable[str | None]: ...
|
||||
|
||||
|
||||
class LookupTeams(Protocol):
|
||||
"""Injected lookup of the teams a user is on, or the failure text when they cannot be read."""
|
||||
|
||||
def __call__(self, user_id: str, /) -> Awaitable[Sequence[ConsentTeam] | str]: ...
|
||||
|
||||
|
||||
class GetUser(Protocol):
|
||||
def __call__(
|
||||
self, *, user_id: str, user_email: str | None, sso_user_id: str
|
||||
) -> Awaitable[LiteLLM_UserTable | None]: ...
|
||||
|
||||
|
||||
def proxy_user_lookup(get_user: GetUser) -> LookupUser:
|
||||
"""``LookupUser`` over the proxy's user table. The assertion's subject is matched as the
|
||||
local ``user_id`` and as the ``sso_user_id`` an SSO login linked, the way the JWT and SSO
|
||||
login paths match it, so a linked account needs no email claim."""
|
||||
|
||||
async def lookup_user(user_id: str, user_email: str | None) -> str | None:
|
||||
user: Final = await get_user(user_id=user_id, user_email=user_email, sso_user_id=user_id)
|
||||
return None if user is None else user.user_id
|
||||
|
||||
return lookup_user
|
||||
|
||||
|
||||
def subject_resolver(reader: ClaimReader, lookup_user: LookupUser, lookup_teams: LookupTeams) -> ResolveSubject:
|
||||
"""``ResolveSubject`` over the JWT auth claim mapping and the proxy's user and team lookups."""
|
||||
|
||||
async def resolve_subject(
|
||||
claims: Mapping[str, object], team_header: str | None
|
||||
) -> SubjectIdentity | SubjectTokenRefusal | None:
|
||||
token: Final = dict(claims)
|
||||
subject: Final = claims.get("sub")
|
||||
claimed_user_id: Final = reader.get_user_id(token=token, default_value=None) or (
|
||||
subject if isinstance(subject, str) else None
|
||||
)
|
||||
if claimed_user_id is None:
|
||||
return None
|
||||
user_email: Final = reader.get_user_email(token=token, default_value=None)
|
||||
if not reader.is_allowed_domain(user_email or ""):
|
||||
return SubjectTokenRefusal(
|
||||
error="invalid_grant",
|
||||
description="the assertion's subject email is missing or outside the allowed domain",
|
||||
)
|
||||
user_id: Final = await lookup_user(claimed_user_id, user_email)
|
||||
if user_id is None:
|
||||
return None
|
||||
teams: Final = await lookup_teams(user_id)
|
||||
if teams in _INACTIVE_SUBJECT:
|
||||
return SubjectTokenRefusal(
|
||||
error="invalid_grant", description="the assertion's subject is not an active user"
|
||||
)
|
||||
if isinstance(teams, str):
|
||||
return SubjectTokenRefusal(
|
||||
error="temporarily_unavailable", description="the gateway could not read this user's teams; retry"
|
||||
)
|
||||
team: Final = select_team(team_header, tuple(reader.get_all_jwt_team_ids(token=token)), teams)
|
||||
if isinstance(team, SubjectTokenRefusal):
|
||||
return team
|
||||
return SubjectIdentity(user_id=user_id, team_id=team)
|
||||
|
||||
return resolve_subject
|
||||
|
||||
|
||||
def select_team(
|
||||
team_header: str | None, claim_teams: Sequence[str], teams: Sequence[ConsentTeam]
|
||||
) -> str | None | SubjectTokenRefusal:
|
||||
"""The team an ID-JAG redemption mints for, settled before the assertion is consumed since
|
||||
the minter refuses a teamless credential for a user who has a team. The ``x-litellm-team-id``
|
||||
header (a team id or alias) wins and must name one of the user's teams, then the assertion's
|
||||
mapped team claim, then the user's only team. A user on several teams must name one."""
|
||||
if team_header is not None:
|
||||
named: Final = next((team for team in teams if team_header in (team.team_id, team.team_alias)), None)
|
||||
if named is None:
|
||||
return SubjectTokenRefusal(
|
||||
error="invalid_request", description=f"{TEAM_HEADER} does not name one of this user's teams"
|
||||
)
|
||||
return named.team_id
|
||||
asserted: Final = tuple(team.team_id for team in teams if team.team_id in claim_teams)
|
||||
if len(asserted) == 1:
|
||||
return asserted[0]
|
||||
if len(asserted) > 1:
|
||||
return SubjectTokenRefusal(
|
||||
error="invalid_request",
|
||||
description=f"the assertion names several of this user's teams; send the {TEAM_HEADER} header naming the team for this credential",
|
||||
)
|
||||
if len(teams) == 1:
|
||||
return teams[0].team_id
|
||||
if teams:
|
||||
return SubjectTokenRefusal(
|
||||
error="invalid_request",
|
||||
description=f"this user belongs to several teams; send the {TEAM_HEADER} header naming the team for this credential",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AssertionRedemption:
|
||||
"""One ``jwt-bearer`` request as the grant sees it: the assertion, how the requesting app
|
||||
identified and authenticated itself, the team the credential is asked for, the issuer
|
||||
values this gateway answers to (what the assertion's ``aud`` must name), the clock, and the
|
||||
leeway the verifier grants ``exp``, so the replay marker outlives every moment the assertion
|
||||
would still verify."""
|
||||
|
||||
assertion: str
|
||||
client_id: str
|
||||
client_secret: str | None
|
||||
authorization_header: str | None
|
||||
team_id: str | None
|
||||
audiences: tuple[str, ...]
|
||||
now: float
|
||||
leeway_seconds: float
|
||||
|
||||
|
||||
async def redeem_id_jag_assertion(
|
||||
assertion: str, client_id: str, client_secret: str | None, request: Request
|
||||
) -> AssertionIdentity | SubjectTokenRefusal:
|
||||
from litellm.proxy._experimental.mcp_server.proxy_api_credentials import ( # noqa: PLC0415 # circular import at module load
|
||||
lookup_consent_teams_from_database,
|
||||
)
|
||||
from litellm.proxy.auth.auth_checks import get_user_object # noqa: PLC0415 # circular import at module load
|
||||
from litellm.proxy.proxy_server import ( # noqa: PLC0415 # rebound after startup, so read them per call
|
||||
jwt_handler,
|
||||
prisma_client,
|
||||
proxy_logging_obj,
|
||||
user_api_key_cache,
|
||||
)
|
||||
|
||||
resolve_subject: Final = subject_resolver(
|
||||
_claim_reader(jwt_handler),
|
||||
proxy_user_lookup(
|
||||
partial(
|
||||
get_user_object,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
user_id_upsert=False,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
check_db_only=True,
|
||||
)
|
||||
),
|
||||
lookup_consent_teams_from_database,
|
||||
)
|
||||
prerequisites: Final = read_id_jag_prerequisites()
|
||||
return await identity_from_id_jag(
|
||||
AssertionRedemption(
|
||||
assertion=assertion,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
authorization_header=request.headers.get("authorization"),
|
||||
team_id=request.headers.get(TEAM_HEADER),
|
||||
audiences=gateway_audiences(prerequisites.base_url),
|
||||
now=datetime.now(timezone.utc).timestamp(),
|
||||
leeway_seconds=jwt_handler.leeway,
|
||||
),
|
||||
prerequisites=prerequisites,
|
||||
decode=jwt_handler.decode_trusted_assertion,
|
||||
resolve_subject=resolve_subject,
|
||||
)
|
||||
|
||||
|
||||
def _claim_reader(handler: ClaimReader) -> ClaimReader:
|
||||
return handler
|
||||
|
||||
|
||||
async def identity_from_id_jag(
|
||||
redemption: AssertionRedemption,
|
||||
prerequisites: IdJagPrerequisites,
|
||||
decode: DecodeAssertion,
|
||||
resolve_subject: ResolveSubject,
|
||||
) -> AssertionIdentity | SubjectTokenRefusal:
|
||||
"""Prove an ID-JAG for the grant tail. The requesting app is checked first, the way a token
|
||||
endpoint authenticates its client before it looks at the grant, so an app the operator never
|
||||
allowlisted learns nothing about the assertion. Everything wrong with the assertion itself is
|
||||
``invalid_grant`` (RFC 7523 section 3.1), a check the gateway could not complete is
|
||||
``temporarily_unavailable``, and the reason stays in the proxy log because this endpoint is
|
||||
public and the verifier's wording can quote the assertion."""
|
||||
unmet: Final = prerequisites.refusal()
|
||||
if unmet is not None:
|
||||
return unmet
|
||||
app: Final = next((app for app in prerequisites.requesting_apps if app.client_id == redemption.client_id), None)
|
||||
if app is None:
|
||||
return SubjectTokenRefusal(error="invalid_client", description="client_id is not an allowlisted requesting app")
|
||||
if not _client_authenticated(app, redemption.client_secret, redemption.authorization_header):
|
||||
return SubjectTokenRefusal(error="invalid_client", description="client authentication failed")
|
||||
try:
|
||||
header: Final = jwt.get_unverified_header(redemption.assertion)
|
||||
except jwt.PyJWTError:
|
||||
return SubjectTokenRefusal(error="invalid_grant", description="assertion is not a JWT")
|
||||
if header.get("typ") != ID_JAG_TYP:
|
||||
return SubjectTokenRefusal(error="invalid_grant", description=f"assertion typ must be {ID_JAG_TYP}")
|
||||
try:
|
||||
verified: Final = await decode(redemption.assertion, redemption.audiences, ID_JAG_REQUIRED_CLAIMS)
|
||||
except Exception as denied: # noqa: BLE001 # the verifier raises its 503 for a JWKS outage, and anything else is a bad assertion
|
||||
return gateway_outage_refusal(denied, denied, what="an assertion", parameter="assertion") or _rejected(
|
||||
str(denied)
|
||||
)
|
||||
if isinstance(verified, AssertionRejection):
|
||||
return _rejected(verified.reason)
|
||||
marker: Final = _replay_marker(verified, redemption)
|
||||
if isinstance(marker, SubjectTokenRefusal):
|
||||
return marker
|
||||
replay_key, replay_ttl_seconds = marker
|
||||
scope: Final = granted_scope(
|
||||
verified.get("scope"), mcp_resource=names_mcp_resource(verified.get("aud"), prerequisites.base_url)
|
||||
)
|
||||
if isinstance(scope, SubjectTokenRefusal):
|
||||
return scope
|
||||
try:
|
||||
subject: Final = await resolve_subject(verified, redemption.team_id)
|
||||
except Exception as failed: # noqa: BLE001 # the user lookup raises for a missing user and for a database outage alike
|
||||
return gateway_outage_refusal(
|
||||
failed, failed, what="an assertion's subject", parameter="assertion"
|
||||
) or _rejected(f"subject lookup failed: {failed}")
|
||||
if subject is None:
|
||||
return _rejected("the assertion names no user the gateway knows")
|
||||
if isinstance(subject, SubjectTokenRefusal):
|
||||
return subject
|
||||
return AssertionIdentity(
|
||||
user_id=subject.user_id,
|
||||
team_id=subject.team_id,
|
||||
replay_key=replay_key,
|
||||
replay_ttl_seconds=replay_ttl_seconds,
|
||||
scope=scope.scope,
|
||||
allowed_routes=scope.allowed_routes,
|
||||
)
|
||||
|
||||
|
||||
def _replay_marker(
|
||||
verified: Mapping[str, object], redemption: AssertionRedemption
|
||||
) -> tuple[str, int] | SubjectTokenRefusal:
|
||||
"""The single-use key and its lifetime for a verified assertion, once the assertion is bound to
|
||||
the presenting app, carries no proof-of-possession the gateway cannot check, and is still alive."""
|
||||
if verified.get("client_id") != redemption.client_id:
|
||||
return _rejected("the assertion's client_id is not the requesting app that presented it")
|
||||
if "cnf" in verified:
|
||||
return _rejected("the assertion is proof-bound and this gateway does not check proof of possession")
|
||||
issuer: Final = verified.get("iss")
|
||||
jti: Final = verified.get("jti")
|
||||
exp: Final = verified.get("exp")
|
||||
if not isinstance(issuer, str) or not isinstance(jti, str) or not jti or not isinstance(exp, (int, float)):
|
||||
return _rejected("the assertion's iss, jti, or exp claim has the wrong shape")
|
||||
replay_ttl_seconds: Final = math.ceil(exp + redemption.leeway_seconds - redemption.now)
|
||||
if replay_ttl_seconds <= 0:
|
||||
return _rejected("the assertion has expired")
|
||||
return _REPLAY_KEY_PREFIX + hashlib.sha256(f"{issuer}\n{jti}".encode()).hexdigest(), replay_ttl_seconds
|
||||
|
||||
|
||||
def _rejected(reason: str) -> SubjectTokenRefusal:
|
||||
verbose_proxy_logger.warning("jwt-bearer grant refused an assertion: %s", log_line(reason))
|
||||
return SubjectTokenRefusal(error="invalid_grant", description=REJECTED_ASSERTION)
|
||||
|
||||
|
||||
def _client_authenticated(app: IdJagRequestingApp, form_secret: str | None, authorization_header: str | None) -> bool:
|
||||
"""A public app (no secret configured) is identified by ``client_id`` alone. A confidential
|
||||
app must present its secret, by HTTP Basic (RFC 6749 section 2.3.1, the credentials
|
||||
form-urlencoded inside the base64) or the ``client_secret`` form field, compared in constant
|
||||
time."""
|
||||
if app.client_secret is None:
|
||||
return True
|
||||
basic: Final = _basic_credentials(authorization_header)
|
||||
if basic is not None:
|
||||
return basic[0] == app.client_id and _same_secret(basic[1], app.client_secret)
|
||||
return form_secret is not None and _same_secret(form_secret, app.client_secret)
|
||||
|
||||
|
||||
def _same_secret(presented: str, expected: str) -> bool:
|
||||
return hmac.compare_digest(presented.encode(), expected.encode())
|
||||
|
||||
|
||||
def basic_client_id(authorization_header: str | None) -> str | None:
|
||||
"""The ``client_id`` an HTTP Basic ``Authorization`` header carries, for a confidential app
|
||||
that leaves the form field out as RFC 6749 section 2.3.1 allows."""
|
||||
credentials: Final = _basic_credentials(authorization_header)
|
||||
return None if credentials is None else credentials[0]
|
||||
|
||||
|
||||
def _basic_credentials(authorization_header: str | None) -> tuple[str, str] | None:
|
||||
if authorization_header is None:
|
||||
return None
|
||||
scheme, _, encoded = authorization_header.strip().partition(" ")
|
||||
if scheme.lower() != "basic" or not encoded:
|
||||
return None
|
||||
try:
|
||||
decoded: Final = base64.b64decode(encoded.strip(), validate=True).decode()
|
||||
except (binascii.Error, UnicodeDecodeError):
|
||||
return None
|
||||
client_id, separator, secret = decoded.partition(":")
|
||||
if not separator:
|
||||
return None
|
||||
return unquote_plus(client_id), unquote_plus(secret)
|
||||
|
|
@ -20,13 +20,15 @@ from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler
|
|||
|
||||
EXCHANGE_ROUTE: Final = "/token"
|
||||
REJECTED_SUBJECT_TOKEN: Final = "subject_token was rejected by the gateway's JWT auth"
|
||||
SUBJECT_TOKEN_CHECK_UNAVAILABLE: Final = (
|
||||
"the gateway could not verify subject_token because its identity provider or database is unavailable; retry"
|
||||
_CHECK_UNAVAILABLE: Final = (
|
||||
"the gateway could not verify {parameter} because its identity provider or database is unavailable; retry"
|
||||
)
|
||||
SUBJECT_TOKEN_CHECK_FAULTED: Final = (
|
||||
"the gateway could not verify subject_token because its database reported a fault that is not a transient "
|
||||
_CHECK_FAULTED: Final = (
|
||||
"the gateway could not verify {parameter} because its database reported a fault that is not a transient "
|
||||
"outage; retrying will not help until the gateway deployment is repaired"
|
||||
)
|
||||
SUBJECT_TOKEN_CHECK_UNAVAILABLE: Final = _CHECK_UNAVAILABLE.format(parameter="subject_token")
|
||||
SUBJECT_TOKEN_CHECK_FAULTED: Final = _CHECK_FAULTED.format(parameter="subject_token")
|
||||
GatewayOutage = Literal["retryable", "faulted"]
|
||||
|
||||
|
||||
|
|
@ -182,20 +184,37 @@ async def identity_from_subject_token(
|
|||
|
||||
|
||||
def _refusal_for(denied: Exception, reason: object) -> SubjectTokenRefusal:
|
||||
outage: Final = gateway_outage_refusal(denied, reason, what="a subject_token", parameter="subject_token")
|
||||
if outage is not None:
|
||||
return outage
|
||||
verbose_proxy_logger.warning("token exchange refused a subject_token: %s", log_line(reason))
|
||||
return SubjectTokenRefusal(error="invalid_request", description=REJECTED_SUBJECT_TOKEN)
|
||||
|
||||
|
||||
def gateway_outage_refusal(denied: Exception, reason: object, what: str, parameter: str) -> SubjectTokenRefusal | None:
|
||||
"""The ``temporarily_unavailable`` refusal when ``denied`` is the gateway failing to check
|
||||
``what`` (its database or the IdP's JWKS down), naming the grant ``parameter`` it could not verify,
|
||||
else ``None`` because the token itself was bad."""
|
||||
outage: Final = _gateway_could_not_verify(denied)
|
||||
if outage is None:
|
||||
verbose_proxy_logger.warning("token exchange refused a subject_token: %s", reason)
|
||||
return SubjectTokenRefusal(error="invalid_request", description=REJECTED_SUBJECT_TOKEN)
|
||||
verbose_proxy_logger.error("token exchange could not verify a subject_token, %s: %s", outage, reason)
|
||||
return SubjectTokenRefusal(error="temporarily_unavailable", description=_check_unavailable_description(outage))
|
||||
return None
|
||||
verbose_proxy_logger.error("token endpoint could not verify %s, %s: %s", what, outage, log_line(reason))
|
||||
return SubjectTokenRefusal(
|
||||
error="temporarily_unavailable", description=_check_unavailable_description(outage, parameter)
|
||||
)
|
||||
|
||||
|
||||
def _check_unavailable_description(outage: GatewayOutage) -> str:
|
||||
def log_line(reason: object) -> str:
|
||||
"""``reason`` as one log line, so text a caller controls cannot forge log entries."""
|
||||
return str(reason).replace("\r", " ").replace("\n", " ")
|
||||
|
||||
|
||||
def _check_unavailable_description(outage: GatewayOutage, parameter: str) -> str:
|
||||
match outage:
|
||||
case "retryable":
|
||||
return SUBJECT_TOKEN_CHECK_UNAVAILABLE
|
||||
return _CHECK_UNAVAILABLE.format(parameter=parameter)
|
||||
case "faulted":
|
||||
return SUBJECT_TOKEN_CHECK_FAULTED
|
||||
return _CHECK_FAULTED.format(parameter=parameter)
|
||||
case _:
|
||||
assert_never(outage)
|
||||
|
||||
|
|
|
|||
|
|
@ -128,6 +128,11 @@ def build_upstream_authorize_url(authorization_url: str, params: Mapping[str, st
|
|||
return urlunparse(parsed._replace(query=urlencode(merged)))
|
||||
|
||||
|
||||
def proxy_base_url_from_env() -> str | None:
|
||||
"""The gateway's fixed public URL from ``PROXY_BASE_URL``, ``None`` when unset or malformed."""
|
||||
return _resolve_proxy_base_url_env()
|
||||
|
||||
|
||||
def _resolve_proxy_base_url_env() -> str | None:
|
||||
global _warned_invalid_proxy_base_url
|
||||
configured: Final = os.environ.get("PROXY_BASE_URL", "").strip()
|
||||
|
|
|
|||
|
|
@ -25,7 +25,16 @@ from litellm.proxy.management_endpoints.ui_sso import (
|
|||
|
||||
|
||||
async def lookup_consent_teams(user_id: str) -> tuple[ConsentTeam, ...] | ReloadUserFailure:
|
||||
user: Final = await load_active_user_by_id(user_id)
|
||||
return await _consent_teams(await load_active_user_by_id(user_id))
|
||||
|
||||
|
||||
async def lookup_consent_teams_from_database(user_id: str) -> tuple[ConsentTeam, ...] | ReloadUserFailure:
|
||||
"""The same teams read from the database row, for a grant that is consumed once it is settled: the
|
||||
membership the minter will re-check must be the membership the team was picked from."""
|
||||
return await _consent_teams(await load_active_user_by_id(user_id, source="database"))
|
||||
|
||||
|
||||
async def _consent_teams(user: LiteLLM_UserTable | ReloadUserFailure) -> tuple[ConsentTeam, ...] | ReloadUserFailure:
|
||||
if isinstance(user, str):
|
||||
return user
|
||||
details: Final = await _team_details(user.teams)
|
||||
|
|
@ -39,7 +48,7 @@ async def lookup_consent_teams(user_id: str) -> tuple[ConsentTeam, ...] | Reload
|
|||
|
||||
|
||||
async def mint_proxy_credential(
|
||||
user_id: str, team_id: str | None
|
||||
user_id: str, team_id: str | None, *, allowed_routes: Sequence[str] | None = None
|
||||
) -> MintedProxyCredential | ProxyCredentialMintFailure:
|
||||
"""Mint the ``lite login`` credential for a consented grant. Membership is checked
|
||||
live against the database row, so a team the user left between consent and redemption (or between refreshes)
|
||||
|
|
@ -53,7 +62,9 @@ async def mint_proxy_credential(
|
|||
user row handed to the minter carries no team list, exactly like ``lite login``'s, so
|
||||
the minter's own first-team fallback stays inert. The credential carries the role the
|
||||
proxy already enforces for the user on every request, so a row with no role (JWT auth's
|
||||
upsert writes none) mints as an internal user instead of being refused."""
|
||||
upsert writes none) mints as an internal user instead of being refused. ``allowed_routes``
|
||||
narrows the credential to those route groups, the way a virtual key's ``allowed_routes`` does,
|
||||
for a grant whose scope asked for less than the full credential."""
|
||||
user: Final = await load_active_user_by_id(user_id, source="database")
|
||||
if isinstance(user, str):
|
||||
return user
|
||||
|
|
@ -75,6 +86,7 @@ async def mint_proxy_credential(
|
|||
team_alias=selected.team_alias,
|
||||
team_models=selected.team_models,
|
||||
team_model_aliases=selected.team_model_aliases,
|
||||
allowed_routes=allowed_routes,
|
||||
)
|
||||
return MintedProxyCredential(
|
||||
key=key,
|
||||
|
|
|
|||
|
|
@ -155,18 +155,13 @@ LAZY_FEATURES: Final[tuple[LazyFeature, ...]] = (
|
|||
module_path="litellm.proxy.management_endpoints.mcp_management_endpoints",
|
||||
path_prefixes=("/v1/mcp/",),
|
||||
),
|
||||
LazyFeature(
|
||||
# Also serves /.well-known/oauth-* (OAuth metadata discovery).
|
||||
# No /mcp/oauth prefix here: the mounted /mcp sub-app would
|
||||
# shadow it, and there are no actual routes there anyway.
|
||||
name="mcp_byok_oauth",
|
||||
module_path="litellm.proxy._experimental.mcp_server.byok_oauth_endpoints",
|
||||
path_prefixes=("/v1/mcp/oauth", "/.well-known/oauth-"),
|
||||
),
|
||||
LazyFeature(
|
||||
# Serves OAuth dance endpoints (/authorize, /token, /callback,
|
||||
# /register) plus several /.well-known/ discovery URLs at the proxy
|
||||
# root — needed for MCP-over-OAuth flows even before /mcp is hit.
|
||||
# Registered before mcp_byok_oauth, which also claims the root
|
||||
# /.well-known/oauth-authorization-server: the root document is the
|
||||
# gateway's own authorization server metadata (token endpoint /token).
|
||||
name="mcp_discoverable",
|
||||
module_path="litellm.proxy._experimental.mcp_server.discoverable_endpoints",
|
||||
path_prefixes=(
|
||||
|
|
@ -185,6 +180,14 @@ LAZY_FEATURES: Final[tuple[LazyFeature, ...]] = (
|
|||
# Catches the /{mcp_server_name}/authorize|token|register variants.
|
||||
path_suffixes=("/authorize", "/token", "/register"),
|
||||
),
|
||||
LazyFeature(
|
||||
# Also serves /.well-known/oauth-* (OAuth metadata discovery).
|
||||
# No /mcp/oauth prefix here: the mounted /mcp sub-app would
|
||||
# shadow it, and there are no actual routes there anyway.
|
||||
name="mcp_byok_oauth",
|
||||
module_path="litellm.proxy._experimental.mcp_server.byok_oauth_endpoints",
|
||||
path_prefixes=("/v1/mcp/oauth", "/.well-known/oauth-"),
|
||||
),
|
||||
LazyFeature(
|
||||
name="mcp_rest",
|
||||
module_path="litellm.proxy._experimental.mcp_server.rest_endpoints",
|
||||
|
|
|
|||
|
|
@ -35771,7 +35771,7 @@
|
|||
"/.well-known/oauth-authorization-server": {
|
||||
"get": {
|
||||
"description": "OAuth authorization server discovery endpoint.\n\nSupports both legacy pattern (/{server_name}) and root endpoint.",
|
||||
"operationId": "oauth_authorization_server_mcp__well_known_oauth_authorization_server_get",
|
||||
"operationId": "oauth_authorization_server_mcp__well_known_oauth_authorization_server_get_2",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
|
|
@ -35819,7 +35819,7 @@
|
|||
"/.well-known/oauth-authorization-server/mcp": {
|
||||
"get": {
|
||||
"description": "OAuth authorization server discovery for the aggregate /mcp endpoint, the RFC 8414\npath-inserted form for a client that treats {base}/mcp as its authorization base URL.\n\nThe single-segment /mcp is reserved for the aggregate so the discovery chain stays\nconsistent: the aggregate protected-resource document advertises {base}/mcp as its\nauthorization server, so the document served here must have issuer {base}/mcp. A server\nliterally named ``mcp`` therefore does not take this route; it keeps its standard\ntwo-segment discovery at /.well-known/oauth-authorization-server/mcp/mcp. Letting the\nper-server row win here instead would serve an issuer of {base} against a resource that\nadvertised {base}/mcp, which fails the RFC 8414 issuer check and breaks the front door.",
|
||||
"operationId": "oauth_authorization_server_aggregate__well_known_oauth_authorization_server_mcp_get",
|
||||
"operationId": "oauth_authorization_server_aggregate__well_known_oauth_authorization_server_mcp_get_2",
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
|
|
@ -35839,7 +35839,7 @@
|
|||
"/.well-known/oauth-authorization-server/mcp/{mcp_server_name}": {
|
||||
"get": {
|
||||
"description": "OAuth authorization server discovery endpoint using standard MCP URL pattern.\n\nStandard pattern: /mcp/{server_name}\nDiscovery path: /.well-known/oauth-authorization-server/mcp/{server_name}",
|
||||
"operationId": "oauth_authorization_server_mcp_standard__well_known_oauth_authorization_server_mcp__mcp_server_name__get",
|
||||
"operationId": "oauth_authorization_server_mcp_standard__well_known_oauth_authorization_server_mcp__mcp_server_name__get_2",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
|
|
@ -35880,7 +35880,7 @@
|
|||
"/.well-known/oauth-authorization-server/{mcp_server_name}": {
|
||||
"get": {
|
||||
"description": "OAuth authorization server discovery endpoint.\n\nSupports both legacy pattern (/{server_name}) and root endpoint.",
|
||||
"operationId": "oauth_authorization_server_mcp__well_known_oauth_authorization_server__mcp_server_name__get",
|
||||
"operationId": "oauth_authorization_server_mcp__well_known_oauth_authorization_server__mcp_server_name__get_2",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
|
|
@ -35928,7 +35928,7 @@
|
|||
"/.well-known/oauth-authorization-server/{mcp_server_name}/mcp": {
|
||||
"get": {
|
||||
"description": "OAuth authorization server discovery for legacy /{server_name}/mcp pattern.",
|
||||
"operationId": "oauth_authorization_server_legacy__well_known_oauth_authorization_server__mcp_server_name__mcp_get",
|
||||
"operationId": "oauth_authorization_server_legacy__well_known_oauth_authorization_server__mcp_server_name__mcp_get_2",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
|
|
@ -35968,7 +35968,7 @@
|
|||
},
|
||||
"/.well-known/oauth-protected-resource": {
|
||||
"get": {
|
||||
"operationId": "oauth_protected_resource_root__well_known_oauth_protected_resource_get",
|
||||
"operationId": "oauth_protected_resource_root__well_known_oauth_protected_resource_get_2",
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
|
|
@ -36004,7 +36004,7 @@
|
|||
"/.well-known/oauth-protected-resource/mcp": {
|
||||
"get": {
|
||||
"description": "OAuth protected resource discovery for the aggregate /mcp endpoint.\n\nThe single-segment ``/mcp`` path does not collide with any per-server PRM pattern\n(those are two-segment: ``/mcp/{server}`` or ``/{server}/mcp``), so this unambiguously\ndescribes the aggregate resource.",
|
||||
"operationId": "oauth_protected_resource_aggregate__well_known_oauth_protected_resource_mcp_get",
|
||||
"operationId": "oauth_protected_resource_aggregate__well_known_oauth_protected_resource_mcp_get_2",
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
|
|
@ -36024,7 +36024,7 @@
|
|||
"/.well-known/oauth-protected-resource/mcp/{mcp_server_name}": {
|
||||
"get": {
|
||||
"description": "OAuth protected resource discovery endpoint using standard MCP URL pattern.\n\nStandard pattern: /mcp/{server_name}\nDiscovery path: /.well-known/oauth-protected-resource/mcp/{server_name}\n\nThis endpoint is compliant with MCP specification and works with standard\nMCP clients like mcp-inspector and VSCode Copilot.",
|
||||
"operationId": "oauth_protected_resource_mcp_standard__well_known_oauth_protected_resource_mcp__mcp_server_name__get",
|
||||
"operationId": "oauth_protected_resource_mcp_standard__well_known_oauth_protected_resource_mcp__mcp_server_name__get_2",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
|
|
@ -36065,7 +36065,7 @@
|
|||
"/.well-known/oauth-protected-resource/{mcp_server_name}/mcp": {
|
||||
"get": {
|
||||
"description": "OAuth protected resource discovery endpoint using LiteLLM legacy URL pattern.\n\nLegacy pattern: /{server_name}/mcp\nDiscovery path: /.well-known/oauth-protected-resource/{server_name}/mcp\n\nThis endpoint is kept for backward compatibility. New integrations should\nuse the standard MCP pattern (/mcp/{server_name}) instead.",
|
||||
"operationId": "oauth_protected_resource_mcp__well_known_oauth_protected_resource__mcp_server_name__mcp_get",
|
||||
"operationId": "oauth_protected_resource_mcp__well_known_oauth_protected_resource__mcp_server_name__mcp_get_2",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
|
|
@ -36194,9 +36194,27 @@
|
|||
},
|
||||
"Body_token_endpoint__mcp_server_name__token_post": {
|
||||
"properties": {
|
||||
"assertion": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Assertion"
|
||||
},
|
||||
"client_id": {
|
||||
"title": "Client Id",
|
||||
"type": "string"
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Client Id"
|
||||
},
|
||||
"client_secret": {
|
||||
"anyOf": [
|
||||
|
|
@ -36293,17 +36311,34 @@
|
|||
}
|
||||
},
|
||||
"required": [
|
||||
"grant_type",
|
||||
"client_id"
|
||||
"grant_type"
|
||||
],
|
||||
"title": "Body_token_endpoint__mcp_server_name__token_post",
|
||||
"type": "object"
|
||||
},
|
||||
"Body_token_endpoint_token_post": {
|
||||
"properties": {
|
||||
"assertion": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Assertion"
|
||||
},
|
||||
"client_id": {
|
||||
"title": "Client Id",
|
||||
"type": "string"
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Client Id"
|
||||
},
|
||||
"client_secret": {
|
||||
"anyOf": [
|
||||
|
|
@ -36400,8 +36435,7 @@
|
|||
}
|
||||
},
|
||||
"required": [
|
||||
"grant_type",
|
||||
"client_id"
|
||||
"grant_type"
|
||||
],
|
||||
"title": "Body_token_endpoint_token_post",
|
||||
"type": "object"
|
||||
|
|
@ -38151,7 +38185,7 @@
|
|||
"/.well-known/oauth-authorization-server": {
|
||||
"get": {
|
||||
"description": "OAuth authorization server discovery endpoint.\n\nSupports both legacy pattern (/{server_name}) and root endpoint.",
|
||||
"operationId": "oauth_authorization_server_mcp__well_known_oauth_authorization_server_get_2",
|
||||
"operationId": "oauth_authorization_server_mcp__well_known_oauth_authorization_server_get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
|
|
@ -38199,7 +38233,7 @@
|
|||
"/.well-known/oauth-authorization-server/mcp": {
|
||||
"get": {
|
||||
"description": "OAuth authorization server discovery for the aggregate /mcp endpoint, the RFC 8414\npath-inserted form for a client that treats {base}/mcp as its authorization base URL.\n\nThe single-segment /mcp is reserved for the aggregate so the discovery chain stays\nconsistent: the aggregate protected-resource document advertises {base}/mcp as its\nauthorization server, so the document served here must have issuer {base}/mcp. A server\nliterally named ``mcp`` therefore does not take this route; it keeps its standard\ntwo-segment discovery at /.well-known/oauth-authorization-server/mcp/mcp. Letting the\nper-server row win here instead would serve an issuer of {base} against a resource that\nadvertised {base}/mcp, which fails the RFC 8414 issuer check and breaks the front door.",
|
||||
"operationId": "oauth_authorization_server_aggregate__well_known_oauth_authorization_server_mcp_get_2",
|
||||
"operationId": "oauth_authorization_server_aggregate__well_known_oauth_authorization_server_mcp_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
|
|
@ -38219,7 +38253,7 @@
|
|||
"/.well-known/oauth-authorization-server/mcp/{mcp_server_name}": {
|
||||
"get": {
|
||||
"description": "OAuth authorization server discovery endpoint using standard MCP URL pattern.\n\nStandard pattern: /mcp/{server_name}\nDiscovery path: /.well-known/oauth-authorization-server/mcp/{server_name}",
|
||||
"operationId": "oauth_authorization_server_mcp_standard__well_known_oauth_authorization_server_mcp__mcp_server_name__get_2",
|
||||
"operationId": "oauth_authorization_server_mcp_standard__well_known_oauth_authorization_server_mcp__mcp_server_name__get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
|
|
@ -38260,7 +38294,7 @@
|
|||
"/.well-known/oauth-authorization-server/{mcp_server_name}": {
|
||||
"get": {
|
||||
"description": "OAuth authorization server discovery endpoint.\n\nSupports both legacy pattern (/{server_name}) and root endpoint.",
|
||||
"operationId": "oauth_authorization_server_mcp__well_known_oauth_authorization_server__mcp_server_name__get_2",
|
||||
"operationId": "oauth_authorization_server_mcp__well_known_oauth_authorization_server__mcp_server_name__get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
|
|
@ -38308,7 +38342,7 @@
|
|||
"/.well-known/oauth-authorization-server/{mcp_server_name}/mcp": {
|
||||
"get": {
|
||||
"description": "OAuth authorization server discovery for legacy /{server_name}/mcp pattern.",
|
||||
"operationId": "oauth_authorization_server_legacy__well_known_oauth_authorization_server__mcp_server_name__mcp_get_2",
|
||||
"operationId": "oauth_authorization_server_legacy__well_known_oauth_authorization_server__mcp_server_name__mcp_get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
|
|
@ -38348,7 +38382,7 @@
|
|||
},
|
||||
"/.well-known/oauth-protected-resource": {
|
||||
"get": {
|
||||
"operationId": "oauth_protected_resource_root__well_known_oauth_protected_resource_get_2",
|
||||
"operationId": "oauth_protected_resource_root__well_known_oauth_protected_resource_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
|
|
@ -38384,7 +38418,7 @@
|
|||
"/.well-known/oauth-protected-resource/mcp": {
|
||||
"get": {
|
||||
"description": "OAuth protected resource discovery for the aggregate /mcp endpoint.\n\nThe single-segment ``/mcp`` path does not collide with any per-server PRM pattern\n(those are two-segment: ``/mcp/{server}`` or ``/{server}/mcp``), so this unambiguously\ndescribes the aggregate resource.",
|
||||
"operationId": "oauth_protected_resource_aggregate__well_known_oauth_protected_resource_mcp_get_2",
|
||||
"operationId": "oauth_protected_resource_aggregate__well_known_oauth_protected_resource_mcp_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
|
|
@ -38404,7 +38438,7 @@
|
|||
"/.well-known/oauth-protected-resource/mcp/{mcp_server_name}": {
|
||||
"get": {
|
||||
"description": "OAuth protected resource discovery endpoint using standard MCP URL pattern.\n\nStandard pattern: /mcp/{server_name}\nDiscovery path: /.well-known/oauth-protected-resource/mcp/{server_name}\n\nThis endpoint is compliant with MCP specification and works with standard\nMCP clients like mcp-inspector and VSCode Copilot.",
|
||||
"operationId": "oauth_protected_resource_mcp_standard__well_known_oauth_protected_resource_mcp__mcp_server_name__get_2",
|
||||
"operationId": "oauth_protected_resource_mcp_standard__well_known_oauth_protected_resource_mcp__mcp_server_name__get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
|
|
@ -38445,7 +38479,7 @@
|
|||
"/.well-known/oauth-protected-resource/{mcp_server_name}/mcp": {
|
||||
"get": {
|
||||
"description": "OAuth protected resource discovery endpoint using LiteLLM legacy URL pattern.\n\nLegacy pattern: /{server_name}/mcp\nDiscovery path: /.well-known/oauth-protected-resource/{server_name}/mcp\n\nThis endpoint is kept for backward compatibility. New integrations should\nuse the standard MCP pattern (/mcp/{server_name}) instead.",
|
||||
"operationId": "oauth_protected_resource_mcp__well_known_oauth_protected_resource__mcp_server_name__mcp_get_2",
|
||||
"operationId": "oauth_protected_resource_mcp__well_known_oauth_protected_resource__mcp_server_name__mcp_get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
|
|
|
|||
|
|
@ -551,6 +551,7 @@ class LiteLLMRoutes(enum.Enum):
|
|||
+ list(agent_inference_routes)
|
||||
+ model_info_routes
|
||||
)
|
||||
llm_api_routes_without_mcp = sorted(set(llm_api_routes) - set(mcp_inference_routes))
|
||||
info_routes = [
|
||||
"/key/info",
|
||||
"/key/health",
|
||||
|
|
@ -4762,6 +4763,26 @@ class JWTIssuerConfig(LiteLLMBaseModel):
|
|||
DEFAULT_JWKS_STALE_TTL: Final = 3600
|
||||
|
||||
|
||||
class IdJagRequestingApp(LiteLLMBaseModel):
|
||||
"""A requesting application the operator lets redeem an ID-JAG (Identity Assertion
|
||||
Authorization Grant) at the MCP gateway's ``POST /token``. ``client_id`` is the value
|
||||
the identity provider puts in the assertion's ``client_id`` claim and the app sends as
|
||||
the ``client_id`` form field. A ``client_secret`` makes the app a confidential client
|
||||
that must authenticate with it (HTTP Basic or the ``client_secret`` form field); without
|
||||
one the app is a public client."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
client_id: str = Field(
|
||||
min_length=1, description="The requesting app's client_id as the identity provider asserts it."
|
||||
)
|
||||
client_secret: str | None = Field(
|
||||
default=None,
|
||||
min_length=1,
|
||||
description="Shared secret the app must present at the gateway's token endpoint. Omit for a public client.",
|
||||
)
|
||||
|
||||
|
||||
class LiteLLM_JWTAuth(LiteLLMPydanticObjectBase):
|
||||
"""
|
||||
A class to define the roles and permissions for a LiteLLM Proxy w/ JWT Auth.
|
||||
|
|
@ -4942,6 +4963,10 @@ class LiteLLM_JWTAuth(LiteLLMPydanticObjectBase):
|
|||
default=None,
|
||||
description="Optional issuer-bound JWT validation rules. When a token's `iss` matches a configured issuer, validation uses that issuer's JWKS, audience, and claim mappings. Tokens with an unlisted `iss` fall back to the global JWT_AUDIENCE/JWT_ISSUER validation path — this is additive routing, not an allow-list.",
|
||||
)
|
||||
id_jag_requesting_apps: tuple[IdJagRequestingApp, ...] | None = Field(
|
||||
default=None,
|
||||
description="Requesting apps allowed to redeem an ID-JAG at the MCP gateway's POST /token with the RFC 7523 jwt-bearer grant. Unset or empty turns that grant off.",
|
||||
)
|
||||
#########################################################
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
|
|
|
|||
|
|
@ -3960,6 +3960,7 @@ class ExperimentalUIJWTToken:
|
|||
team_models: Sequence[str] | None = None,
|
||||
team_model_aliases: Mapping[str, str] | None = None,
|
||||
max_budget: float | None = None,
|
||||
allowed_routes: Sequence[str] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Generate a JWT token for CLI authentication with configurable expiration.
|
||||
|
|
@ -3973,6 +3974,8 @@ class ExperimentalUIJWTToken:
|
|||
team_alias: Team alias for the selected team, if available
|
||||
team_models: Model allowlist granted by the selected team
|
||||
team_model_aliases: Team model aliases for the selected team
|
||||
allowed_routes: Route groups the session may call, enforced like a virtual key's
|
||||
``allowed_routes`` on every route; None leaves every route the role allows
|
||||
|
||||
Returns:
|
||||
Encrypted JWT token string
|
||||
|
|
@ -4017,6 +4020,7 @@ class ExperimentalUIJWTToken:
|
|||
max_parallel_requests=None,
|
||||
user_role=LitellmUserRoles(user_info.user_role),
|
||||
is_session_token=True,
|
||||
allowed_routes=list(allowed_routes) if allowed_routes is not None else None,
|
||||
)
|
||||
|
||||
return encrypt_bearer_token(valid_token.model_dump_json(exclude_none=True), prefix=LITELLM_SESSION_TOKEN_PREFIX)
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ from litellm.llms.custom_httpx.httpx_handler import HTTPHandler
|
|||
from litellm.proxy._types import (
|
||||
DEFAULT_JWKS_STALE_TTL,
|
||||
RBAC_ROLES,
|
||||
IdJagRequestingApp,
|
||||
JWKKeyValue,
|
||||
JWTAuthBuilderResult,
|
||||
JWTIssuerConfig,
|
||||
|
|
@ -99,6 +100,10 @@ class NoMatchingJWTPublicKeyError(Exception):
|
|||
"""Raised when a JWKS endpoint returns no key matching the requested ``kid``."""
|
||||
|
||||
|
||||
class IdJagIsNotABearerTokenError(Exception):
|
||||
"""Raised when bearer auth is handed an ID-JAG, which only the token endpoint may redeem."""
|
||||
|
||||
|
||||
class JWKSUnreachableError(Exception):
|
||||
"""Raised when an IdP's JWKS / OIDC discovery endpoint is unreachable and no cached copy is left to fall back on."""
|
||||
|
||||
|
|
@ -114,9 +119,23 @@ _CACHED_VALUE: Final = TypeAdapter(object)
|
|||
_CachedValueT = TypeVar("_CachedValueT", bound=JWKKeyValue | str)
|
||||
|
||||
|
||||
ID_JAG_TYP: Final = "oauth-id-jag+jwt"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AssertionRejection:
|
||||
"""Why a JWT assertion failed verification. The reason is for the proxy log only, since
|
||||
PyJWT's wording can quote the token's claims."""
|
||||
|
||||
reason: str
|
||||
|
||||
|
||||
class _JWTAuthSettings(Protocol):
|
||||
"""The JWT auth settings block this handler reads back through ``getattr``, when one is configured."""
|
||||
|
||||
@property
|
||||
def id_jag_requesting_apps(self) -> Sequence[IdJagRequestingApp] | None: ...
|
||||
|
||||
@property
|
||||
def issuers(self) -> Sequence[JWTIssuerConfig] | None: ...
|
||||
|
||||
|
|
@ -282,7 +301,7 @@ class JWTHandler:
|
|||
return is_jwt_token(token=token)
|
||||
|
||||
@staticmethod
|
||||
def get_unverified_claims(token: str) -> dict | None:
|
||||
def get_unverified_claims(token: str) -> dict[str, object] | None:
|
||||
"""
|
||||
Decode JWT claims without signature verification.
|
||||
Used for routing decisions before selecting validation path.
|
||||
|
|
@ -949,7 +968,7 @@ class JWTHandler:
|
|||
keys: Final = response_json["keys"] if "keys" in response_json else response_json
|
||||
return cast(JWKKeyValue, keys) # cast-ok: JWTKeyItem declares only `kid`, validating would drop key material
|
||||
|
||||
async def _get_public_key_from_jwks_url(self, jwks_url: str, kid: str | None) -> dict:
|
||||
async def _get_public_key_from_jwks_url(self, jwks_url: str, kid: str | None) -> JWTKeyItem:
|
||||
resolved_jwks_url: Final = await self._resolve_jwks_url(jwks_url)
|
||||
keys: Final = await self._cached_with_stale_fallback(
|
||||
cache_key=f"litellm_jwt_auth_keys_{resolved_jwks_url}",
|
||||
|
|
@ -960,11 +979,11 @@ class JWTHandler:
|
|||
|
||||
public_key: Final = self.parse_keys(keys=keys, kid=kid)
|
||||
if public_key is not None:
|
||||
return cast(dict, public_key)
|
||||
return public_key
|
||||
|
||||
raise NoMatchingJWTPublicKeyError(f"No matching public key found. keys={resolved_jwks_url}, kid={kid}")
|
||||
|
||||
async def get_public_key(self, kid: str | None) -> dict:
|
||||
async def get_public_key(self, kid: str | None) -> JWTKeyItem:
|
||||
keys_url: Final = os.getenv("JWT_PUBLIC_KEY_URL")
|
||||
|
||||
if keys_url is None:
|
||||
|
|
@ -1151,7 +1170,7 @@ class JWTHandler:
|
|||
# its jwks_uri, matching JWTIssuerConfig.jwks_url's documented fallback.
|
||||
return f"{issuer_config.issuer.rstrip('/')}/.well-known/openid-configuration"
|
||||
|
||||
def _get_claim_value_for_issuer_mapping(self, token: dict, claim_field: str) -> object:
|
||||
def _get_claim_value_for_issuer_mapping(self, token: Mapping[str, object], claim_field: str) -> object:
|
||||
"""Resolve a mapped claim from ``token``.
|
||||
|
||||
Returns ``None`` when the field is absent or empty so that mapped claims
|
||||
|
|
@ -1168,8 +1187,10 @@ class JWTHandler:
|
|||
return None
|
||||
return claim_value
|
||||
|
||||
def _apply_issuer_claim_mappings(self, token: dict, issuer_config: JWTIssuerConfig) -> dict:
|
||||
normalized: Final[dict] = {k: v for k, v in token.items() if k not in self.LITELLM_INTERNAL_CLAIMS}
|
||||
def _apply_issuer_claim_mappings(
|
||||
self, token: Mapping[str, object], issuer_config: JWTIssuerConfig
|
||||
) -> dict[str, object]:
|
||||
normalized: Final[dict[str, object]] = {k: v for k, v in token.items() if k not in self.LITELLM_INTERNAL_CLAIMS}
|
||||
normalized[self.LITELLM_JWT_ISSUER_CLAIM] = issuer_config.issuer
|
||||
claim_mappings: Final = [
|
||||
(issuer_config.user_id_jwt_field, self.LITELLM_USER_ID_CLAIM),
|
||||
|
|
@ -1192,8 +1213,8 @@ class JWTHandler:
|
|||
|
||||
return normalized
|
||||
|
||||
def _get_jwk_from_public_key(self, public_key: dict) -> dict:
|
||||
jwk: Final = {}
|
||||
def _get_jwk_from_public_key(self, public_key: Mapping[str, object]) -> dict[str, object]:
|
||||
jwk: Final[dict[str, object]] = {}
|
||||
for key in ["kty", "kid", "n", "e", "x", "y", "crv"]:
|
||||
if key in public_key:
|
||||
jwk[key] = public_key[key]
|
||||
|
|
@ -1223,12 +1244,12 @@ class JWTHandler:
|
|||
def _decode_jwt_with_public_key(
|
||||
self,
|
||||
token: str,
|
||||
public_key: dict | str,
|
||||
public_key: JWTKeyItem | str,
|
||||
audience: str | list[str] | None,
|
||||
issuer: str | None = None,
|
||||
options: dict | None = None,
|
||||
disable_audience_validation: bool = False,
|
||||
) -> dict:
|
||||
) -> dict[str, object]:
|
||||
decode_options: Final = (
|
||||
options
|
||||
if options is not None
|
||||
|
|
@ -1298,11 +1319,64 @@ class JWTHandler:
|
|||
issuer_config=issuer_config,
|
||||
)
|
||||
|
||||
async def decode_trusted_assertion(
|
||||
self, token: str, audience: Sequence[str], required_claims: Sequence[str]
|
||||
) -> Mapping[str, object] | AssertionRejection:
|
||||
"""Verify a JWT assertion handed to the gateway's token endpoint with the issuer plumbing
|
||||
JWT auth already trusts, a matching ``issuers`` entry or ``JWT_ISSUER`` with
|
||||
``JWT_PUBLIC_KEY_URL``, but against the caller's audience instead of the API audience.
|
||||
An assertion whose ``iss`` nothing trusts is refused rather than checked unscoped. A JWKS
|
||||
outage propagates as the 503 ``ProxyException`` so the caller does not call the token bad."""
|
||||
try:
|
||||
header: Final = jwt.get_unverified_header(token)
|
||||
except jwt.PyJWTError as malformed:
|
||||
return AssertionRejection(reason=f"malformed assertion: {malformed}")
|
||||
claims: Final = self.get_unverified_claims(token=token)
|
||||
issuer_config: Final = self._get_configured_issuer(token=token)
|
||||
trusted_issuer: Final = issuer_config.issuer if issuer_config is not None else os.getenv("JWT_ISSUER")
|
||||
if not trusted_issuer or claims is None or claims.get("iss") != trusted_issuer:
|
||||
return AssertionRejection(reason="iss is not an issuer this gateway trusts")
|
||||
kid: Final = header.get("kid")
|
||||
try:
|
||||
public_key: Final = (
|
||||
await self._get_public_key_from_jwks_url(self._get_jwks_url_for_issuer(issuer_config), kid)
|
||||
if issuer_config is not None
|
||||
else await self.get_public_key(kid=kid)
|
||||
)
|
||||
except JWKSUnreachableError as e:
|
||||
raise jwks_unavailable_exception(e) from e
|
||||
except Exception as no_key: # noqa: BLE001 # the key lookups raise plain Exceptions for a missing key
|
||||
return AssertionRejection(reason=f"no signing key for the assertion: {no_key}")
|
||||
try:
|
||||
payload: Final = self._decode_jwt_with_public_key(
|
||||
token=token,
|
||||
public_key=public_key,
|
||||
audience=list(audience),
|
||||
issuer=trusted_issuer,
|
||||
options={"require": list(required_claims)},
|
||||
)
|
||||
except jwt.PyJWTError as invalid:
|
||||
return AssertionRejection(reason=f"assertion failed verification: {invalid}")
|
||||
verified: Final = (
|
||||
payload
|
||||
if issuer_config is None
|
||||
else self._apply_issuer_claim_mappings(token=payload, issuer_config=issuer_config)
|
||||
)
|
||||
validate: Final = self.litellm_jwtauth.custom_validate
|
||||
if validate is not None and not validate(dict(verified)):
|
||||
return AssertionRejection(reason="custom_validate rejected the assertion's claims")
|
||||
return verified
|
||||
|
||||
async def auth_jwt(self, token: str) -> dict:
|
||||
header: Final = jwt.get_unverified_header(token)
|
||||
|
||||
verbose_proxy_logger.debug("header: %s", header)
|
||||
|
||||
if header.get("typ") == ID_JAG_TYP:
|
||||
raise IdJagIsNotABearerTokenError(
|
||||
"an ID-JAG is an authorization grant, not a bearer token; redeem it at the token endpoint"
|
||||
)
|
||||
|
||||
kid: Final = header.get("kid", None)
|
||||
|
||||
issuer_config: Final = self._get_configured_issuer(token=token)
|
||||
|
|
|
|||
|
|
@ -69,7 +69,8 @@ _PROXY_ADMIN_VIEW_ONLY_BLOCKED_ROUTES: Final = frozenset(
|
|||
# paths directly because the request route carries the resolved key id.
|
||||
_PROXY_ADMIN_VIEW_ONLY_BLOCKED_KEY_SUFFIXES: Final = ("/regenerate", "/reset_spend")
|
||||
|
||||
_AUTH_ENFORCED_PASS_THROUGH_ROUTE_GROUPS: Final = frozenset(("openai_routes", "llm_api_routes"))
|
||||
_LLM_API_ROUTE_GROUPS: Final = frozenset(("llm_api_routes", "llm_api_routes_without_mcp"))
|
||||
_AUTH_ENFORCED_PASS_THROUGH_ROUTE_GROUPS: Final = frozenset(("openai_routes", *_LLM_API_ROUTE_GROUPS))
|
||||
|
||||
# Method-scoped because the generic credential CRUD handlers share these paths:
|
||||
# the route check cannot admit PATCH/DELETE on a name like "user_connections"
|
||||
|
|
@ -154,7 +155,7 @@ class RouteChecks:
|
|||
################################################
|
||||
# For llm_api_routes, also check registered pass-through endpoints
|
||||
################################################
|
||||
if allowed_route == "llm_api_routes":
|
||||
if allowed_route in _LLM_API_ROUTE_GROUPS:
|
||||
if route == "/auto_router/session" and RouteChecks.get_request_method(request) == "GET":
|
||||
return True
|
||||
|
||||
|
|
@ -185,7 +186,9 @@ class RouteChecks:
|
|||
# headers, env, credentials). POST/PUT/DELETE on
|
||||
# these paths are admin-only management writes and
|
||||
# are intentionally not covered.
|
||||
if RouteChecks._is_get_mcp_server_discovery_route(route=route, request=request):
|
||||
if allowed_route == "llm_api_routes" and RouteChecks._is_get_mcp_server_discovery_route(
|
||||
route=route, request=request
|
||||
):
|
||||
return True
|
||||
|
||||
# Agent registry CRUD moved from llm_api_routes into
|
||||
|
|
@ -195,7 +198,7 @@ class RouteChecks:
|
|||
# could reach these paths, so keep them reachable here;
|
||||
# the handlers in agent_endpoints/endpoints.py still
|
||||
# enforce proxy-admin on writes and scope reads by role.
|
||||
if RouteChecks.check_route_access(
|
||||
if allowed_route == "llm_api_routes" and RouteChecks.check_route_access(
|
||||
route=route,
|
||||
allowed_routes=LiteLLMRoutes.agent_management_routes.value,
|
||||
):
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import os
|
|||
from collections.abc import Sequence
|
||||
from collections.abc import Set as AbstractSet
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from typing import Final, Literal
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
|
@ -12,6 +13,7 @@ from fastapi import HTTPException
|
|||
from fastapi.testclient import TestClient
|
||||
from starlette.datastructures import Headers
|
||||
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.constants import MCP_ALL_TOOLS_WILDCARD
|
||||
from litellm.models.access_group import LiteLLM_AccessGroupTable
|
||||
from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
|
||||
|
|
@ -23,6 +25,7 @@ from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
|
|||
from litellm.proxy._types import (
|
||||
LiteLLM_ObjectPermissionTable,
|
||||
LiteLLM_TeamTable,
|
||||
LiteLLM_UserTable,
|
||||
ProxyException,
|
||||
SpecialHeaders,
|
||||
SpecialMCPServerNames,
|
||||
|
|
@ -11549,3 +11552,117 @@ class TestAccessGroupToolOwnershipAndFaults:
|
|||
patch("litellm.proxy.auth.auth_checks.get_access_object", self._loader(groups, missing={"typo-group"})),
|
||||
):
|
||||
assert await MCPRequestHandler.get_allowed_tools_for_server("srv", auth) == ["read"]
|
||||
|
||||
|
||||
class _EmptyTables:
|
||||
"""Storage edge for a session credential's catalog refresh: the user row the real
|
||||
``reload_admitted_user`` reads, and every other table (keys, MCP servers, config) empty, so the
|
||||
real ``_reload_admitted_key`` fails closed, since a session credential has no key row."""
|
||||
|
||||
def __init__(self, live_user: LiteLLM_UserTable) -> None:
|
||||
self.litellm_usertable = SimpleNamespace(find_unique=AsyncMock(return_value=live_user))
|
||||
|
||||
def __getattr__(self, table: str) -> SimpleNamespace:
|
||||
empty: Final = SimpleNamespace(
|
||||
find_unique=AsyncMock(return_value=None),
|
||||
find_first=AsyncMock(return_value=None),
|
||||
find_many=AsyncMock(return_value=[]),
|
||||
count=AsyncMock(return_value=0),
|
||||
)
|
||||
setattr(self, table, empty)
|
||||
return empty
|
||||
|
||||
|
||||
def _session_credential_storage(live_user: LiteLLM_UserTable) -> SimpleNamespace:
|
||||
tables: Final = _EmptyTables(live_user)
|
||||
return SimpleNamespace(db=tables, writer_db=tables)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_catalog_refresh_reloads_session_credential_through_its_user_and_keeps_token_grants(monkeypatch):
|
||||
"""A ``/token``-minted or ``lite login`` credential has no key row: the catalog refresh must read the live
|
||||
user policy, never the key table, and keep the team, routes and models sealed in the token."""
|
||||
from litellm.proxy import proxy_server
|
||||
|
||||
database = _session_credential_storage(
|
||||
LiteLLM_UserTable(user_id="session-user", organization_id="live-org", user_role="internal_user", teams=[])
|
||||
)
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", database)
|
||||
monkeypatch.setattr(proxy_server, "user_api_key_cache", DualCache())
|
||||
caller = UserAPIKeyAuth(
|
||||
api_key="session-token-hash",
|
||||
user_id="session-user",
|
||||
team_id="token-team",
|
||||
org_id="stale-org",
|
||||
user_role="proxy_admin",
|
||||
models=["token-model"],
|
||||
allowed_routes=["mcp_inference_routes"],
|
||||
is_session_token=True,
|
||||
)
|
||||
caller.via_virtual_key = True
|
||||
refreshed = await MCPRequestHandler.refresh_catalog_authority(caller)
|
||||
assert database.writer_db.litellm_usertable.find_unique.await_count >= 1
|
||||
database.writer_db.litellm_verificationtoken.find_unique.assert_not_awaited()
|
||||
assert (refreshed.object_permission, refreshed.object_permission_id) == (None, None)
|
||||
assert (refreshed.user_role, refreshed.org_id) == ("internal_user", "live-org")
|
||||
assert (refreshed.team_id, refreshed.models, refreshed.allowed_routes) == (
|
||||
"token-team",
|
||||
["token-model"],
|
||||
["mcp_inference_routes"],
|
||||
)
|
||||
assert refreshed.is_session_token and refreshed.requires_fresh_policy
|
||||
assert caller.org_id == "stale-org" and not caller.requires_fresh_policy
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("scope", ["litellm:mcp litellm:llm", "litellm:mcp", None])
|
||||
async def test_token_minted_credential_lists_tools_for_every_scope_shape(monkeypatch, scope):
|
||||
"""``tools/list`` with the credential ``/token`` mints (a session token the shared auth marks
|
||||
``via_virtual_key`` at the key-cache boundary) returns the tool list instead of the JSON-RPC
|
||||
credential error, for the combined, MCP-only and unscoped ID-JAG shapes."""
|
||||
from mcp.types import ListToolsRequest, ListToolsResult, Tool
|
||||
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy._experimental.mcp_server import operations
|
||||
from litellm.proxy._experimental.mcp_server.id_jag_grant import SubjectTokenRefusal, granted_scope
|
||||
from litellm.proxy._experimental.mcp_server.operations import GatewayOperations, prepare_context
|
||||
from litellm.proxy._types import MCPTransport
|
||||
from litellm.proxy.auth.auth_checks import ExperimentalUIJWTToken
|
||||
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
||||
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-session-credential-tools-list")
|
||||
granted = granted_scope(scope, mcp_resource=False)
|
||||
assert not isinstance(granted, SubjectTokenRefusal)
|
||||
user = LiteLLM_UserTable(user_id="okta-user-1", user_email="okta@example.com", user_role="internal_user")
|
||||
minted = ExperimentalUIJWTToken.get_cli_jwt_auth_token(
|
||||
user, team_id="team-eng", team_alias="eng", allowed_routes=granted.allowed_routes
|
||||
)
|
||||
credential = ExperimentalUIJWTToken.get_key_object_from_ui_hash_key(minted)
|
||||
assert credential is not None and credential.is_session_token
|
||||
credential.api_key = "hash-of-" + minted[:16]
|
||||
credential.via_virtual_key = True
|
||||
server = MCPServer(
|
||||
server_id="eng-server",
|
||||
name="eng_server",
|
||||
server_name="eng_server",
|
||||
alias="eng_server",
|
||||
url="https://upstream.example.com/mcp",
|
||||
transport=MCPTransport.http,
|
||||
)
|
||||
database = _session_credential_storage(user)
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", database)
|
||||
monkeypatch.setattr(proxy_server, "user_api_key_cache", DualCache())
|
||||
tool = Tool(name="eng_server-list_repos", description="list repos", inputSchema={"type": "object"})
|
||||
with (
|
||||
patch.object(operations, "_get_allowed_mcp_servers", AsyncMock(return_value=[server])),
|
||||
patch.object(
|
||||
operations.global_mcp_server_manager,
|
||||
"get_tools_page",
|
||||
AsyncMock(return_value=ListToolsResult(tools=[tool])),
|
||||
),
|
||||
):
|
||||
result = await GatewayOperations().execute(ListToolsRequest(), prepare_context(user_api_key_auth=credential))
|
||||
assert [listed.name for listed in result.tools] == ["eng_server-list_repos"]
|
||||
assert database.writer_db.litellm_usertable.find_unique.await_count >= 1
|
||||
database.writer_db.litellm_verificationtoken.find_unique.assert_not_awaited()
|
||||
assert credential.team_id == "team-eng" and credential.allowed_routes == list(granted.allowed_routes or ())
|
||||
|
|
|
|||
|
|
@ -102,9 +102,8 @@ def isolate_global_mcp_registry(monkeypatch):
|
|||
restore, entries leak into other test modules sharing the same worker and break
|
||||
assertions over the full registry contents.
|
||||
"""
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.catalog import CatalogSnapshots
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
|
||||
|
||||
monkeypatch.setattr(global_mcp_server_manager, "catalog", CatalogSnapshots(global_mcp_server_manager))
|
||||
snapshot = dict(global_mcp_server_manager.registry)
|
||||
|
|
@ -8133,9 +8132,9 @@ async def test_resolve_active_litellm_key_resolves_key_without_user_id(proxy_glo
|
|||
blocked and expiry, and the key hash (not the user) is what the mint seals. The per-user token
|
||||
store still gets no user for such a key, since there is none to key a stored credential by."""
|
||||
from litellm.proxy._experimental.mcp_server.bridge_token_flow import (
|
||||
extract_user_id_from_request,
|
||||
_ResolvedKey,
|
||||
_resolve_active_litellm_key,
|
||||
_ResolvedKey,
|
||||
extract_user_id_from_request,
|
||||
)
|
||||
from litellm.proxy._types import UserAPIKeyAuth, hash_token
|
||||
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
||||
|
|
@ -8417,8 +8416,8 @@ async def test_load_active_user_by_id_serves_a_cached_row_without_a_database_rea
|
|||
cached row answers without a database read, and only a caller that asks for the database row pays for
|
||||
one."""
|
||||
from litellm.proxy._experimental.mcp_server.bridge_token_flow import (
|
||||
reload_active_user_by_id,
|
||||
load_active_user_by_id,
|
||||
reload_active_user_by_id,
|
||||
)
|
||||
from litellm.proxy._types import LiteLLM_UserTable
|
||||
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
||||
|
|
@ -11428,11 +11427,10 @@ async def test_upstream_resource_sent_on_dcr_bridge_relay_authorize():
|
|||
"""The DCR-bridge relay arm builds its own upstream authorize params, so it needs the resource
|
||||
too. Without it the relayed authorize would omit the resource while the gateway's token leg
|
||||
still sent one, which is itself an invalid_target."""
|
||||
from litellm.types.mcp import MCPAuth
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
_dcr_bridge_relays_client_registration,
|
||||
)
|
||||
from litellm.types.mcp import MCPAuth
|
||||
|
||||
server = _resource_server(
|
||||
client_id=None,
|
||||
|
|
@ -11693,7 +11691,7 @@ def _native_client_app(monkeypatch):
|
|||
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", _NATIVE_CLIENT_MASTER_KEY, raising=False)
|
||||
minted = []
|
||||
|
||||
async def fake_mint(user_id, team_id):
|
||||
async def fake_mint(user_id, team_id, allowed_routes=None):
|
||||
minted.append((user_id, team_id))
|
||||
return MintedProxyCredential(key=f"sk-cli-{len(minted)}", expires_in=3600, user_id=user_id, team_id=team_id)
|
||||
|
||||
|
|
@ -12136,7 +12134,9 @@ def test_introspect_route_answers_for_authenticated_caller(monkeypatch):
|
|||
async def test_identity_bound_authorization_carries_nonce_and_caller_through_callback(monkeypatch):
|
||||
from http.cookies import SimpleCookie
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
_oauth_state_cookie_name,
|
||||
authorize_with_server,
|
||||
|
|
@ -12211,6 +12211,7 @@ async def test_identity_bound_authorization_carries_nonce_and_caller_through_cal
|
|||
@pytest.mark.asyncio
|
||||
async def test_enforced_login_warms_verified_token_readable_without_database_lookup(monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy._experimental.mcp_server import db, mcp_server_manager
|
||||
|
|
@ -12279,9 +12280,10 @@ async def test_enforced_login_warms_verified_token_readable_without_database_loo
|
|||
async def test_dcr_refusal_is_actionable_without_upstream_body(
|
||||
upstream_status: int, auth_type: MCPAuth, dcr_bridge: bool, flow: str, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
import httpx
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
mint_ephemeral_dcr_client,
|
||||
register_client_with_server,
|
||||
|
|
@ -12522,8 +12524,8 @@ async def test_oauth_exchange_stores_token_for_validated_jwt_user(
|
|||
import litellm
|
||||
from litellm.caching.llm_caching_handler import LLMClientCache
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.models.user import LiteLLM_UserTable
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper
|
||||
from litellm.types.llms.custom_http import httpxSpecialProvider
|
||||
|
||||
|
|
@ -12618,8 +12620,8 @@ async def test_oauth_jwt_identity_rejects_untrusted_or_inactive_owner(
|
|||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy._experimental.mcp_server import mcp_server_manager
|
||||
from litellm.proxy._experimental.mcp_server.bridge_token_flow import (
|
||||
extract_user_id_from_request,
|
||||
authorize_oauth_credential_request,
|
||||
extract_user_id_from_request,
|
||||
)
|
||||
|
||||
allowed_servers: Final = AsyncMock(return_value=["server-a"])
|
||||
|
|
@ -12688,7 +12690,7 @@ async def test_oauth_jwt_uses_configured_virtual_key_owner(
|
|||
) -> None:
|
||||
from litellm.models.user import LiteLLM_UserTable
|
||||
from litellm.proxy._experimental.mcp_server.bridge_token_flow import extract_user_id_from_request
|
||||
from litellm.proxy._types import UserAPIKeyAuth, UnregisteredJWTClientBehavior, hash_token
|
||||
from litellm.proxy._types import UnregisteredJWTClientBehavior, UserAPIKeyAuth, hash_token
|
||||
from litellm.proxy.auth.auth_checks import jwt_key_mapping_cache_key
|
||||
|
||||
handler, signing_key = jwt_oauth_identity
|
||||
|
|
@ -13069,8 +13071,8 @@ async def test_signed_oauth_callback_honors_credential_write_policy(
|
|||
cimd: bool,
|
||||
) -> None:
|
||||
import httpx
|
||||
import litellm
|
||||
|
||||
import litellm
|
||||
from litellm.caching.llm_caching_handler import LLMClientCache
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
|
||||
from litellm.proxy import proxy_server
|
||||
|
|
@ -13186,17 +13188,16 @@ async def test_identity_bound_authorize_preserves_presented_jwt_permissions(
|
|||
allowed: bool,
|
||||
credential: str,
|
||||
) -> None:
|
||||
import jwt
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import jwt
|
||||
|
||||
from litellm.models.user import LiteLLM_UserTable
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy._types import JWTIssuerConfig, UserAPIKeyAuth, hash_token
|
||||
from litellm.proxy.auth.auth_checks import ExperimentalUIJWTToken
|
||||
|
||||
from litellm.proxy._experimental.mcp_server import discoverable_endpoints, mcp_server_manager
|
||||
from litellm.proxy._types import MCPTransport
|
||||
from litellm.proxy._types import JWTIssuerConfig, MCPTransport, UserAPIKeyAuth, hash_token
|
||||
from litellm.proxy.auth.auth_checks import ExperimentalUIJWTToken
|
||||
from litellm.types.mcp_server.mcp_server_manager import MCPOAuthIdentityBinding, MCPServer
|
||||
|
||||
handler, signing_key = jwt_oauth_identity
|
||||
|
|
@ -13330,9 +13331,10 @@ async def test_identity_bound_authorize_unrelated_bearer_uses_browser_session(
|
|||
credential: str,
|
||||
cookie_state: str,
|
||||
) -> None:
|
||||
import jwt
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import jwt
|
||||
|
||||
from litellm.models.user import LiteLLM_UserTable
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy._experimental.mcp_server import discoverable_endpoints, mcp_server_manager
|
||||
|
|
@ -13664,6 +13666,7 @@ def test_persisted_client_is_not_applied_to_another_upstream(issuer, url):
|
|||
async def test_registration_does_not_write_client_after_upstream_edit(monkeypatch):
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", "test-mcp-registration-salt")
|
||||
from prisma import models
|
||||
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import _persist_dcr_client_registration
|
||||
|
||||
|
|
@ -13750,6 +13753,7 @@ async def test_callback_does_not_forward_error_from_another_issuer(monkeypatch):
|
|||
@pytest.mark.parametrize("operation", ["authorize", "token"])
|
||||
async def test_bound_client_cannot_be_sent_to_a_different_issuer(monkeypatch, operation):
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy._experimental.mcp_server import discoverable_endpoints as endpoints
|
||||
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
||||
|
||||
|
|
@ -13796,6 +13800,7 @@ async def test_bound_client_cannot_be_sent_to_a_different_issuer(monkeypatch, op
|
|||
@pytest.mark.asyncio
|
||||
async def test_failed_registration_preserves_cached_credentials(monkeypatch):
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy._experimental.mcp_server import discoverable_endpoints as endpoints
|
||||
|
||||
server = _dcr_redirect_test_server("old-client").model_copy(
|
||||
|
|
@ -13819,8 +13824,13 @@ async def test_failed_registration_preserves_cached_credentials(monkeypatch):
|
|||
request.headers = {}
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await endpoints.register_client_with_server(
|
||||
request=request, mcp_server=server, client_name="app", grant_types=["authorization_code"],
|
||||
response_types=["code"], token_endpoint_auth_method="none", replace_stale_client=True,
|
||||
request=request,
|
||||
mcp_server=server,
|
||||
client_name="app",
|
||||
grant_types=["authorization_code"],
|
||||
response_types=["code"],
|
||||
token_endpoint_auth_method="none",
|
||||
replace_stale_client=True,
|
||||
)
|
||||
assert error.value.status_code == 503
|
||||
assert "could not be saved" in error.value.detail
|
||||
|
|
@ -13835,6 +13845,7 @@ async def test_failed_registration_preserves_cached_credentials(monkeypatch):
|
|||
@pytest.mark.asyncio
|
||||
async def test_registration_without_database_keeps_client_in_temporary_server(monkeypatch):
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy._experimental.mcp_server import discoverable_endpoints as endpoints
|
||||
|
||||
|
|
@ -13850,8 +13861,13 @@ async def test_registration_without_database_keeps_client_in_temporary_server(mo
|
|||
request.base_url = "https://gateway.example/"
|
||||
request.headers = {}
|
||||
response = await endpoints.register_client_with_server(
|
||||
request=request, mcp_server=server, client_name="app", grant_types=["authorization_code"],
|
||||
response_types=["code"], token_endpoint_auth_method="none", replace_stale_client=True,
|
||||
request=request,
|
||||
mcp_server=server,
|
||||
client_name="app",
|
||||
grant_types=["authorization_code"],
|
||||
response_types=["code"],
|
||||
token_endpoint_auth_method="none",
|
||||
replace_stale_client=True,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert json.loads(response.body)["client_id"] == "temporary-client"
|
||||
|
|
@ -13864,9 +13880,10 @@ async def test_registration_without_database_keeps_client_in_temporary_server(mo
|
|||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("config_store", [False, True])
|
||||
async def test_registration_does_not_overwrite_credentials_after_failed_identity_read(monkeypatch, config_store):
|
||||
from litellm.proxy._experimental.mcp_server import discoverable_endpoints as endpoints, db
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
|
||||
from litellm.proxy import utils
|
||||
from litellm.proxy._experimental.mcp_server import db
|
||||
from litellm.proxy._experimental.mcp_server import discoverable_endpoints as endpoints
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
|
||||
|
||||
server = _dcr_redirect_test_server(None)
|
||||
monkeypatch.setattr(utils, "get_prisma_client_or_throw", lambda _: MagicMock())
|
||||
|
|
@ -13895,9 +13912,10 @@ async def test_registration_does_not_overwrite_credentials_after_failed_identity
|
|||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("winner_available", [False, True])
|
||||
async def test_registration_losing_conditional_write_reuses_only_a_matching_winner(monkeypatch, winner_available):
|
||||
from litellm.proxy._experimental.mcp_server import discoverable_endpoints as endpoints, db
|
||||
from litellm.proxy._types import LiteLLM_MCPServerTable
|
||||
from litellm.proxy import utils
|
||||
from litellm.proxy._experimental.mcp_server import db
|
||||
from litellm.proxy._experimental.mcp_server import discoverable_endpoints as endpoints
|
||||
from litellm.proxy._types import LiteLLM_MCPServerTable
|
||||
|
||||
server = _dcr_redirect_test_server(None).model_copy(update={"url": "https://upstream.example/mcp"})
|
||||
row = LiteLLM_MCPServerTable.model_validate(server.model_dump(exclude_none=True))
|
||||
|
|
@ -14503,7 +14521,8 @@ async def test_managed_authorize_retains_the_callers_previous_grant(
|
|||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy._experimental.mcp_server import db, discoverable_endpoints as endpoints
|
||||
from litellm.proxy._experimental.mcp_server import db
|
||||
from litellm.proxy._experimental.mcp_server import discoverable_endpoints as endpoints
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
|
||||
|
||||
_, signing_key = jwt_oauth_identity
|
||||
|
|
@ -14548,7 +14567,8 @@ async def test_managed_code_exchange_preserves_requested_scope_only_when_omitted
|
|||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy._experimental.mcp_server import db, discoverable_endpoints as endpoints
|
||||
from litellm.proxy._experimental.mcp_server import db
|
||||
from litellm.proxy._experimental.mcp_server import discoverable_endpoints as endpoints
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
|
||||
|
||||
_, signing_key = jwt_oauth_identity
|
||||
|
|
@ -14692,7 +14712,8 @@ async def test_managed_refresh_retains_only_the_actual_grant(
|
|||
from types import SimpleNamespace
|
||||
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy._experimental.mcp_server import db, discoverable_endpoints as endpoints
|
||||
from litellm.proxy._experimental.mcp_server import db
|
||||
from litellm.proxy._experimental.mcp_server import discoverable_endpoints as endpoints
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
|
||||
|
||||
_, signing_key = jwt_oauth_identity
|
||||
|
|
@ -14774,7 +14795,8 @@ async def test_managed_resource_metadata_excludes_refresh_permission(monkeypatch
|
|||
],
|
||||
)
|
||||
async def test_connect_requires_the_requested_vendor_grant(monkeypatch, granted, required, expected):
|
||||
from litellm.proxy._experimental.mcp_server import db, discoverable_endpoints as endpoints
|
||||
from litellm.proxy._experimental.mcp_server import db
|
||||
from litellm.proxy._experimental.mcp_server import discoverable_endpoints as endpoints
|
||||
|
||||
credential = {**_stored_grant(), "scopes": granted}
|
||||
read = AsyncMock(return_value=credential)
|
||||
|
|
@ -14836,3 +14858,137 @@ def test_aggregate_protected_resource_advertises_header_server_scopes(
|
|||
)
|
||||
with patch.object(mcp_server_manager, "global_mcp_server_manager", manager):
|
||||
assert _build_aggregate_protected_resource_response(request)["scopes_supported"] == expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_endpoint_routes_the_jwt_bearer_grant_to_the_aggregate_gateway():
|
||||
"""A requesting app presents its identity provider's client_id, never a gateway DCR one, so
|
||||
the aggregate dispatch keys on the grant type for RFC 7523 and hands the assertion to the
|
||||
ID-JAG redeemer. A per-server token endpoint never serves that grant."""
|
||||
from litellm.proxy._experimental.mcp_server import discoverable_endpoints
|
||||
from litellm.proxy._experimental.mcp_server.gateway_dcr_flow import JWT_BEARER_GRANT_TYPE
|
||||
from litellm.proxy._experimental.mcp_server.id_jag_grant import IdJagPrerequisites
|
||||
|
||||
request = _mock_callback_request("https://litellm.example.com/")
|
||||
with patch("litellm.proxy.proxy_server.master_key", "sk-test-salt-for-lit9670"):
|
||||
response = await discoverable_endpoints.token_endpoint(
|
||||
request=request,
|
||||
grant_type=JWT_BEARER_GRANT_TYPE,
|
||||
client_id="0oa-requesting-app",
|
||||
client_secret="app-secret",
|
||||
assertion="eyJ.assertion.sig",
|
||||
resource=None,
|
||||
)
|
||||
assert response.status_code == 400
|
||||
refused = json.loads(bytes(response.body))
|
||||
assert refused["error"] == "unsupported_grant_type"
|
||||
unmet = IdJagPrerequisites(
|
||||
jwt_auth_enabled=False,
|
||||
has_database=False,
|
||||
licensed=False,
|
||||
maps_jwts_to_virtual_keys=False,
|
||||
shared_replay_store=False,
|
||||
base_url=None,
|
||||
requesting_apps=(),
|
||||
).refusal()
|
||||
assert unmet is not None and refused["error_description"] == unmet.description
|
||||
with pytest.raises(HTTPException):
|
||||
await discoverable_endpoints.token_endpoint(
|
||||
request=request,
|
||||
grant_type=JWT_BEARER_GRANT_TYPE,
|
||||
client_id="0oa-requesting-app",
|
||||
assertion="eyJ.assertion.sig",
|
||||
mcp_server_name="no-such-server",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_endpoint_takes_the_client_id_from_http_basic_when_the_form_leaves_it_out():
|
||||
"""RFC 6749 section 2.3.1: a confidential app may authenticate with HTTP Basic alone, so the
|
||||
aggregate dispatch reads client_id from that header. With neither the form field nor the
|
||||
header the request is a 400 invalid_request, not a 422."""
|
||||
from base64 import b64encode
|
||||
|
||||
from litellm.proxy._experimental.mcp_server import discoverable_endpoints
|
||||
from litellm.proxy._experimental.mcp_server.gateway_dcr_flow import JWT_BEARER_GRANT_TYPE
|
||||
|
||||
request = _mock_callback_request("https://litellm.example.com/")
|
||||
request.headers = {"authorization": "Basic " + b64encode(b"0oa-requesting-app:app-secret").decode()}
|
||||
with patch("litellm.proxy.proxy_server.master_key", "sk-test-salt-for-lit9670"):
|
||||
response = await discoverable_endpoints.token_endpoint(
|
||||
request=request,
|
||||
grant_type=JWT_BEARER_GRANT_TYPE,
|
||||
client_id=None,
|
||||
assertion="eyJ.assertion.sig",
|
||||
resource=None,
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert json.loads(bytes(response.body))["error"] == "unsupported_grant_type"
|
||||
request.headers = {}
|
||||
with pytest.raises(HTTPException) as refused:
|
||||
await discoverable_endpoints.token_endpoint(
|
||||
request=request, grant_type=JWT_BEARER_GRANT_TYPE, client_id=None, assertion="eyJ.assertion.sig"
|
||||
)
|
||||
assert refused.value.status_code == 400
|
||||
assert refused.value.detail["error"] == "invalid_request"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("available", [True, False])
|
||||
def test_root_metadata_is_the_gateways_own_and_lists_the_jwt_bearer_grant_when_it_is_served(available):
|
||||
"""With no OAuth2 MCP server resolving at the root, the bare-origin document is the gateway's
|
||||
own authorization server metadata: issuer is the origin an ID-JAG names as ``aud``, the token
|
||||
endpoint is ``{base}/token``, and the jwt-bearer grant (with HTTP Basic client auth) is listed
|
||||
exactly when the gateway serves it, under the same gates as the aggregate ``/mcp`` document."""
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy._experimental.mcp_server import discoverable_endpoints
|
||||
from litellm.proxy._experimental.mcp_server.gateway_dcr_flow import JWT_BEARER_GRANT_TYPE
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
|
||||
|
||||
global_mcp_server_manager.registry.clear()
|
||||
request = MagicMock(spec=Request)
|
||||
request.base_url = "https://llm.example.com/"
|
||||
request.headers = {}
|
||||
with (
|
||||
patch.object(discoverable_endpoints, "jwt_bearer_available", return_value=available), # test-quality-ok: deployment gate seam
|
||||
patch.object(discoverable_endpoints, "token_exchange_available", return_value=True), # test-quality-ok: deployment gate seam
|
||||
):
|
||||
document = discoverable_endpoints._build_oauth_authorization_server_response(request=request, mcp_server_name=None)
|
||||
assert document["issuer"] == "https://llm.example.com"
|
||||
assert document["token_endpoint"] == "https://llm.example.com/token"
|
||||
assert (JWT_BEARER_GRANT_TYPE in document["grant_types_supported"]) is available
|
||||
assert ("client_secret_basic" in document["token_endpoint_auth_methods_supported"]) is available
|
||||
assert ("none" in document["token_endpoint_auth_methods_supported"]) is available
|
||||
assert "urn:ietf:params:oauth:grant-type:token-exchange" in document["grant_types_supported"]
|
||||
|
||||
|
||||
def test_root_metadata_owned_by_a_resolved_oauth2_server_never_lists_the_gateways_grant():
|
||||
"""When exactly one OAuth2 MCP server resolves at the root, the root document describes that
|
||||
upstream's token endpoint, which cannot redeem an ID-JAG, so the jwt-bearer grant is not
|
||||
advertised there even though the gateway serves it; a requesting app uses the aggregate
|
||||
``/.well-known/oauth-authorization-server/mcp`` document in that deployment."""
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy._experimental.mcp_server import discoverable_endpoints
|
||||
from litellm.proxy._experimental.mcp_server.gateway_dcr_flow import JWT_BEARER_GRANT_TYPE
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
|
||||
|
||||
server = _create_oauth2_server()
|
||||
global_mcp_server_manager.registry.clear()
|
||||
global_mcp_server_manager.registry[server.server_id] = server
|
||||
request = MagicMock(spec=Request)
|
||||
request.base_url = "https://llm.example.com/"
|
||||
request.headers = {}
|
||||
try:
|
||||
with (
|
||||
patch.object(discoverable_endpoints, "jwt_bearer_available", return_value=True), # test-quality-ok: deployment gate seam
|
||||
patch.object(discoverable_endpoints, "token_exchange_available", return_value=True), # test-quality-ok: deployment gate seam
|
||||
patch.object(global_mcp_server_manager, "get_mcp_server_by_name", return_value=server), # test-quality-ok: resolver seam
|
||||
):
|
||||
document = discoverable_endpoints._build_oauth_authorization_server_response(request=request, mcp_server_name=None)
|
||||
finally:
|
||||
global_mcp_server_manager.registry.clear()
|
||||
assert document["token_endpoint"] == "https://llm.example.com/test_oauth/token"
|
||||
assert JWT_BEARER_GRANT_TYPE not in document["grant_types_supported"]
|
||||
assert document["grant_types_supported"] == ["authorization_code", "refresh_token"]
|
||||
assert document["token_endpoint_auth_methods_supported"] == ["client_secret_post"]
|
||||
|
|
|
|||
|
|
@ -20,10 +20,12 @@ from litellm.proxy._experimental.mcp_server.gateway_dcr_flow import (
|
|||
CONNECT_FLOW_COOKIE_PREFIX,
|
||||
GATEWAY_AUTH_CODE_PREFIX,
|
||||
GATEWAY_AUTH_CODE_TTL_SECONDS,
|
||||
JWT_BEARER_GRANT_TYPE,
|
||||
MANUAL_DELIVERY_AUTH_CODE_TTL_SECONDS,
|
||||
MAX_CLIENT_ID_LENGTH,
|
||||
SUBJECT_TOKEN_TYPES,
|
||||
TOKEN_EXCHANGE_GRANT_TYPE,
|
||||
AssertionIdentity,
|
||||
ConsentTeam,
|
||||
MintedProxyCredential,
|
||||
SubjectIdentity,
|
||||
|
|
@ -44,6 +46,7 @@ from litellm.proxy._experimental.mcp_server.gateway_dcr_flow import (
|
|||
open_gateway_dcr_client,
|
||||
register_aggregate_client,
|
||||
revoke_refresh_token,
|
||||
supported_grant_types,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.session_credentials import (
|
||||
SessionBearerAdmitted,
|
||||
|
|
@ -1771,10 +1774,12 @@ CONSENT_TEAMS = (ConsentTeam(team_id="team-a", team_alias="Team A"), ConsentTeam
|
|||
class _Minter:
|
||||
def __init__(self, result=None):
|
||||
self.calls = []
|
||||
self.allowed_routes = []
|
||||
self.result = result
|
||||
|
||||
async def __call__(self, user_id, team_id):
|
||||
async def __call__(self, user_id, team_id, allowed_routes=None):
|
||||
self.calls.append((user_id, team_id))
|
||||
self.allowed_routes.append(allowed_routes)
|
||||
if self.result is not None:
|
||||
return self.result
|
||||
return MintedProxyCredential(key=f"sk-cli-{user_id}", expires_in=3600, user_id=user_id, team_id=team_id)
|
||||
|
|
@ -3296,3 +3301,200 @@ async def test_gateway_scope_still_narrows_when_the_resource_does_not_resolve()
|
|||
_, cookies = _flow_cookie_from(response)
|
||||
sealed_flow: Final = _sealed_wire_json(next(iter(cookies.values())), "", "gateway_connect_flow")
|
||||
assert sealed_flow["resource_server_id"] == "alpha-id"
|
||||
|
||||
|
||||
ID_JAG = "eyJhbGciOiJSUzI1NiIsInR5cCI6Im9hdXRoLWlkLWphZytqd3QifQ.eyJzdWIiOiJ1MSJ9.idp-signature"
|
||||
REQUESTING_APP = "0oa-requesting-app"
|
||||
ASSERTED = AssertionIdentity(
|
||||
user_id="u1",
|
||||
team_id="team-b",
|
||||
replay_key="mcp_gateway_dcr:id_jag:jti-1",
|
||||
replay_ttl_seconds=300,
|
||||
scope="litellm:mcp",
|
||||
allowed_routes=("mcp_inference_routes",),
|
||||
)
|
||||
|
||||
|
||||
class _Redeemer:
|
||||
def __init__(self, result=None):
|
||||
self.calls = []
|
||||
self.result = result
|
||||
|
||||
async def __call__(self, assertion, client_id, client_secret, request):
|
||||
self.calls.append((assertion, client_id, client_secret, request.url.path))
|
||||
return self.result if self.result is not None else ASSERTED
|
||||
|
||||
|
||||
async def _redeem_assertion_native(minter, redeemer, cache=None, client_id=REQUESTING_APP, **overrides):
|
||||
arguments = {
|
||||
"grant_type": JWT_BEARER_GRANT_TYPE,
|
||||
"assertion": ID_JAG,
|
||||
"client_secret": "app-secret",
|
||||
"redeem_assertion": redeemer,
|
||||
}
|
||||
return await _redeem_native(None, client_id, minter, cache=cache, **{**arguments, **overrides})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jwt_bearer_grant_mints_the_proxy_credential_for_the_asserted_user_once_per_assertion():
|
||||
"""RFC 7523: an allowlisted requesting app redeems the ID-JAG its identity provider minted for
|
||||
the same credential the consent flow mints, attributed to the user the assertion names and
|
||||
the team asked for, with the assertion's scope stated and no refresh token (RFC 7521 section
|
||||
4.1, the app renews with a fresh assertion). The assertion's jti is single-use: presenting it again is a replay and mints
|
||||
nothing, while a fresh assertion for the same user is served."""
|
||||
minter, redeemer, cache = _Minter(), _Redeemer(), DualCache()
|
||||
response = await _redeem_assertion_native(minter, redeemer, cache=cache)
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
body = json.loads(response.body)
|
||||
assert redeemer.calls == [(ID_JAG, REQUESTING_APP, "app-secret", "/token")]
|
||||
assert minter.calls == [("u1", "team-b")]
|
||||
assert body["access_token"] == "sk-cli-u1"
|
||||
assert body["token_type"] == "Bearer"
|
||||
assert body["scope"] == "litellm:mcp"
|
||||
assert "issued_token_type" not in body
|
||||
assert (body["user_id"], body["team_id"]) == ("u1", "team-b")
|
||||
assert "refresh_token" not in body
|
||||
replayed = await _redeem_assertion_native(minter, redeemer, cache=cache)
|
||||
assert replayed.status_code == 400
|
||||
assert json.loads(replayed.body)["error"] == "invalid_grant"
|
||||
assert minter.calls == [("u1", "team-b")]
|
||||
fresh = _Redeemer(ASSERTED.model_copy(update={"replay_key": "mcp_gateway_dcr:id_jag:jti-2", "scope": None}))
|
||||
again = await _redeem_assertion_native(minter, fresh, cache=cache)
|
||||
assert again.status_code == 200
|
||||
assert "scope" not in json.loads(again.body)
|
||||
assert minter.calls == [("u1", "team-b"), ("u1", "team-b")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jwt_bearer_grant_claims_the_jti_before_it_mints():
|
||||
"""A mint the gateway could not complete still consumed the assertion: the client gets the
|
||||
503 and comes back with a fresh assertion, never with the same one."""
|
||||
minter, cache = _Minter(result="unavailable"), DualCache()
|
||||
first = await _redeem_assertion_native(minter, _Redeemer(), cache=cache)
|
||||
assert first.status_code == 503
|
||||
retried = await _redeem_assertion_native(_Minter(), _Redeemer(), cache=cache)
|
||||
assert retried.status_code == 400
|
||||
assert json.loads(retried.body)["error"] == "invalid_grant"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error, status",
|
||||
[
|
||||
("invalid_client", 401),
|
||||
("invalid_grant", 400),
|
||||
("invalid_request", 400),
|
||||
("unsupported_grant_type", 400),
|
||||
("temporarily_unavailable", 503),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_jwt_bearer_grant_refusals_keep_their_oauth_error_and_status(error, status):
|
||||
minter, cache = _Minter(), DualCache()
|
||||
refusal = SubjectTokenRefusal(error=error, description="why")
|
||||
response = await _redeem_assertion_native(minter, _Redeemer(refusal), cache=cache)
|
||||
assert response.status_code == status
|
||||
assert json.loads(response.body) == {"error": error, "error_description": "why"}
|
||||
assert minter.calls == []
|
||||
served = await _redeem_assertion_native(minter, _Redeemer(), cache=cache)
|
||||
assert served.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jwt_bearer_grant_requires_an_assertion_and_the_proxy_api_target():
|
||||
minter, redeemer = _Minter(), _Redeemer()
|
||||
missing = await _redeem_assertion_native(minter, redeemer, assertion=None)
|
||||
assert missing.status_code == 400
|
||||
assert json.loads(missing.body)["error"] == "invalid_request"
|
||||
foreign = await _redeem_assertion_native(minter, redeemer, resource="https://elsewhere.example/api")
|
||||
assert foreign.status_code == 400
|
||||
assert json.loads(foreign.body)["error"] == "invalid_target"
|
||||
assert redeemer.calls == []
|
||||
assert minter.calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jwt_bearer_grant_is_refused_where_no_redeemer_is_wired():
|
||||
minter = _Minter()
|
||||
response = await _redeem_native(
|
||||
None, REQUESTING_APP, minter, grant_type=JWT_BEARER_GRANT_TYPE, assertion=ID_JAG, client_secret=None
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert json.loads(response.body)["error"] == "unsupported_grant_type"
|
||||
assert minter.calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_unknown_grant_type_names_the_jwt_bearer_grant_among_the_served_ones():
|
||||
response = await _redeem_native(None, REQUESTING_APP, _Minter(), grant_type="urn:example:nope")
|
||||
assert response.status_code == 400
|
||||
assert JWT_BEARER_GRANT_TYPE in json.loads(response.body)["error_description"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"token_exchange, jwt_bearer, expected",
|
||||
[
|
||||
(False, False, ("authorization_code", "refresh_token")),
|
||||
(True, False, ("authorization_code", "refresh_token", TOKEN_EXCHANGE_GRANT_TYPE)),
|
||||
(False, True, ("authorization_code", "refresh_token", JWT_BEARER_GRANT_TYPE)),
|
||||
(True, True, ("authorization_code", "refresh_token", TOKEN_EXCHANGE_GRANT_TYPE, JWT_BEARER_GRANT_TYPE)),
|
||||
],
|
||||
)
|
||||
def test_the_jwt_bearer_grant_is_advertised_exactly_when_it_is_served(token_exchange, jwt_bearer, expected):
|
||||
assert supported_grant_types(token_exchange, jwt_bearer) == expected
|
||||
contract = native_client_auth_contract(_request("/.well-known/litellm-cli-auth"), token_exchange, jwt_bearer)
|
||||
assert contract["grant_types_supported"] == expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jwt_bearer_grant_narrows_the_credential_to_the_routes_the_scope_bought():
|
||||
"""The route groups the assertion's scope resolved to reach the minter, so the credential is
|
||||
enforced on every proxy route the way a virtual key's allowed_routes is, and the response states
|
||||
exactly the scope that was enforced. No scope on the assertion mints the full credential."""
|
||||
minter = _Minter()
|
||||
both = ASSERTED.model_copy(
|
||||
update={"scope": "litellm:mcp litellm:llm", "allowed_routes": ("mcp_inference_routes", "openai_routes")}
|
||||
)
|
||||
scoped = await _redeem_assertion_native(minter, _Redeemer(both), cache=DualCache())
|
||||
assert scoped.status_code == 200
|
||||
assert json.loads(scoped.body)["scope"] == "litellm:mcp litellm:llm"
|
||||
full = await _redeem_assertion_native(
|
||||
minter, _Redeemer(ASSERTED.model_copy(update={"scope": None, "allowed_routes": None})), cache=DualCache()
|
||||
)
|
||||
assert full.status_code == 200
|
||||
assert "scope" not in json.loads(full.body)
|
||||
assert minter.allowed_routes == [("mcp_inference_routes", "openai_routes"), None]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jwt_bearer_grant_accepts_the_mcp_resource_indicator_as_its_target():
|
||||
"""RFC 8707 clients send the ID-JAG's audience as ``resource``, and that audience may be the MCP
|
||||
resource ``{base}/mcp``, so it is a valid target next to the proxy origin. Anything else stays
|
||||
``invalid_target``."""
|
||||
minter = _Minter()
|
||||
served = await _redeem_assertion_native(
|
||||
minter, _Redeemer(), cache=DualCache(), resource=PROXY_API_RESOURCE.rstrip("/") + "/mcp"
|
||||
)
|
||||
assert served.status_code == 200
|
||||
foreign = await _redeem_assertion_native(
|
||||
minter, _Redeemer(), cache=DualCache(), resource=PROXY_API_RESOURCE.rstrip("/") + "/other/mcp"
|
||||
)
|
||||
assert json.loads(foreign.body)["error"] == "invalid_target"
|
||||
assert minter.calls == [("u1", "team-b")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("jwt_bearer_available", [True, False])
|
||||
def test_aggregate_metadata_advertises_basic_client_auth_only_with_the_grant_that_reads_it(jwt_bearer_available):
|
||||
"""Only the jwt-bearer redemption reads an HTTP Basic client secret, the other grants take
|
||||
``client_secret`` from the form, so ``client_secret_basic`` is advertised only when that grant
|
||||
is on. A client following the document otherwise sends Basic alone and authenticates with no
|
||||
secret."""
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
_build_aggregate_authorization_server_response,
|
||||
)
|
||||
|
||||
document = _build_aggregate_authorization_server_response(_request(), True, jwt_bearer_available)
|
||||
methods = document["token_endpoint_auth_methods_supported"]
|
||||
assert isinstance(methods, list)
|
||||
assert ("client_secret_basic" in methods) is jwt_bearer_available
|
||||
assert "none" in methods and "client_secret_post" in methods
|
||||
|
|
|
|||
738
tests/unit/proxy/_experimental/mcp_server/test_id_jag_grant.py
Normal file
738
tests/unit/proxy/_experimental/mcp_server/test_id_jag_grant.py
Normal file
|
|
@ -0,0 +1,738 @@
|
|||
import hashlib
|
||||
import logging
|
||||
import math
|
||||
from base64 import b64encode
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Final
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.proxy._experimental.mcp_server.gateway_dcr_flow import (
|
||||
AssertionIdentity,
|
||||
ConsentTeam,
|
||||
ReloadUserFailure,
|
||||
SubjectIdentity,
|
||||
SubjectTokenRefusal,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.id_jag_grant import (
|
||||
ID_JAG_REQUIRED_CLAIMS,
|
||||
REJECTED_ASSERTION,
|
||||
TEAM_HEADER,
|
||||
AssertionRedemption,
|
||||
GrantedScope,
|
||||
IdJagPrerequisites,
|
||||
LookupTeams,
|
||||
_claim_reader,
|
||||
basic_client_id,
|
||||
gateway_audiences,
|
||||
granted_scope,
|
||||
identity_from_id_jag,
|
||||
jwt_bearer_available,
|
||||
names_mcp_resource,
|
||||
proxy_user_lookup,
|
||||
select_team,
|
||||
subject_resolver,
|
||||
)
|
||||
from litellm.proxy._types import IdJagRequestingApp, LiteLLM_JWTAuth, LiteLLMRoutes
|
||||
from litellm.proxy.auth.handle_jwt import (
|
||||
ID_JAG_TYP,
|
||||
AssertionRejection,
|
||||
JWKSUnreachableError,
|
||||
JWTHandler,
|
||||
jwks_unavailable_exception,
|
||||
)
|
||||
|
||||
ISSUER = "https://idp.example.com"
|
||||
GATEWAY = "https://gateway.example.com"
|
||||
AUDIENCES = (f"{GATEWAY}/mcp", GATEWAY)
|
||||
NOW = 1_800_000_000.0
|
||||
PUBLIC_APP = IdJagRequestingApp(client_id="0oa-public")
|
||||
CONFIDENTIAL_APP = IdJagRequestingApp(client_id="0oa-confidential", client_secret="s3cret")
|
||||
SPACED_APP = IdJagRequestingApp(client_id="0oa spaced", client_secret="app secret")
|
||||
EVERY_GATE_HOLDS = {
|
||||
"jwt_auth_enabled": True,
|
||||
"has_database": True,
|
||||
"licensed": True,
|
||||
"maps_jwts_to_virtual_keys": False,
|
||||
"shared_replay_store": True,
|
||||
"base_url": "https://gateway.example.com",
|
||||
"requesting_apps": (PUBLIC_APP, CONFIDENTIAL_APP, SPACED_APP),
|
||||
}
|
||||
JWKS_DOWN = jwks_unavailable_exception(JWKSUnreachableError("ConnectError fetching the IdP's JWKS after 3 attempts"))
|
||||
|
||||
|
||||
def _claims(**overrides: object) -> dict[str, object]:
|
||||
return {
|
||||
"iss": ISSUER,
|
||||
"sub": "okta-user-1",
|
||||
"aud": AUDIENCES[1],
|
||||
"exp": NOW + 300,
|
||||
"iat": NOW - 5,
|
||||
"jti": "jti-1",
|
||||
"client_id": PUBLIC_APP.client_id,
|
||||
"scope": "litellm:mcp",
|
||||
**overrides,
|
||||
}
|
||||
|
||||
|
||||
def _assertion(typ: str = ID_JAG_TYP, **claims: object) -> str:
|
||||
return jwt.encode(
|
||||
_claims(**claims),
|
||||
"unused-by-the-fake-verifier-but-long-enough-for-hs256",
|
||||
algorithm="HS256",
|
||||
headers={"typ": typ},
|
||||
)
|
||||
|
||||
|
||||
class _Verifier:
|
||||
"""Stands in for the injected ``DecodeAssertion`` port; records each call as a new tuple."""
|
||||
|
||||
def __init__(
|
||||
self, result: Mapping[str, object] | AssertionRejection | None = None, raises: BaseException | None = None
|
||||
) -> None:
|
||||
self.calls: tuple[tuple[str, tuple[str, ...], tuple[str, ...]], ...] = ()
|
||||
self.result = result
|
||||
self.raises = raises
|
||||
|
||||
async def __call__(
|
||||
self, assertion: str, audience: Sequence[str], required_claims: Sequence[str]
|
||||
) -> Mapping[str, object] | AssertionRejection:
|
||||
self.calls = (*self.calls, (assertion, tuple(audience), tuple(required_claims))) # rebind-ok: immutable record
|
||||
if self.raises is not None:
|
||||
raise self.raises
|
||||
return self.result if self.result is not None else jwt.decode(assertion, options={"verify_signature": False})
|
||||
|
||||
|
||||
class _Resolver:
|
||||
"""Stands in for the injected ``ResolveSubject`` port; records each call as a new tuple."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
user_id: str | None = "u1",
|
||||
raises: BaseException | None = None,
|
||||
result: SubjectIdentity | SubjectTokenRefusal | None = None,
|
||||
) -> None:
|
||||
self.calls: tuple[tuple[Mapping[str, object], str | None], ...] = ()
|
||||
self.user_id = user_id
|
||||
self.raises = raises
|
||||
self.result = result
|
||||
|
||||
async def __call__(
|
||||
self, claims: Mapping[str, object], team_header: str | None
|
||||
) -> SubjectIdentity | SubjectTokenRefusal | None:
|
||||
self.calls = (*self.calls, (dict(claims), team_header)) # rebind-ok: immutable record
|
||||
if self.raises is not None:
|
||||
raise self.raises
|
||||
if self.result is not None:
|
||||
return self.result
|
||||
return None if self.user_id is None else SubjectIdentity(user_id=self.user_id, team_id=team_header)
|
||||
|
||||
|
||||
def _redemption(
|
||||
assertion: str,
|
||||
client_id: str = PUBLIC_APP.client_id,
|
||||
client_secret: str | None = None,
|
||||
authorization: str | None = None,
|
||||
team_id: str | None = "team-b",
|
||||
leeway: float = 0.0,
|
||||
) -> AssertionRedemption:
|
||||
return AssertionRedemption(
|
||||
assertion=assertion,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
authorization_header=authorization,
|
||||
team_id=team_id,
|
||||
audiences=AUDIENCES,
|
||||
now=NOW,
|
||||
leeway_seconds=leeway,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_replay_marker_outlives_the_leeway_the_verifier_grants_exp():
|
||||
"""PyJWT accepts an assertion until ``exp`` plus the handler's leeway, so the marker must
|
||||
live that long too, including for an assertion already past ``exp`` but inside the leeway."""
|
||||
within_leeway = await _identity(_redemption(_assertion(), leeway=30), _Verifier(), _Resolver())
|
||||
assert isinstance(within_leeway, AssertionIdentity)
|
||||
assert within_leeway.replay_ttl_seconds == 330
|
||||
just_expired = await _identity(
|
||||
_redemption(_assertion(), leeway=30), _Verifier(result=_claims(exp=NOW - 10)), _Resolver()
|
||||
)
|
||||
assert isinstance(just_expired, AssertionIdentity)
|
||||
assert just_expired.replay_ttl_seconds == 20
|
||||
|
||||
|
||||
async def _identity(
|
||||
redemption: AssertionRedemption,
|
||||
verifier: _Verifier | None = None,
|
||||
resolver: _Resolver | None = None,
|
||||
**unmet: object,
|
||||
) -> AssertionIdentity | SubjectTokenRefusal:
|
||||
return await identity_from_id_jag(
|
||||
redemption,
|
||||
prerequisites=IdJagPrerequisites(**{**EVERY_GATE_HOLDS, **unmet}),
|
||||
decode=verifier if verifier is not None else _Verifier(),
|
||||
resolve_subject=resolver if resolver is not None else _Resolver(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_valid_id_jag_names_the_user_its_single_use_key_and_its_scope():
|
||||
"""The verifier is asked for the gateway's own issuer values as audience and for every claim
|
||||
the ID-JAG profile requires, the subject lookup sees the verified claims, and the identity
|
||||
carries the team asked for, a replay key derived from iss and jti, a marker TTL that outlives
|
||||
the assertion, and the assertion's scope."""
|
||||
assertion, verifier, resolver = _assertion(), _Verifier(), _Resolver()
|
||||
identity = await _identity(_redemption(assertion), verifier, resolver)
|
||||
assert isinstance(identity, AssertionIdentity)
|
||||
assert verifier.calls == ((assertion, AUDIENCES, ID_JAG_REQUIRED_CLAIMS),)
|
||||
assert {"iss", "sub", "aud", "exp", "iat", "jti", "client_id"} <= set(ID_JAG_REQUIRED_CLAIMS)
|
||||
assert resolver.calls == ((_claims(), "team-b"),)
|
||||
assert (identity.user_id, identity.team_id, identity.scope) == ("u1", "team-b", "litellm:mcp")
|
||||
assert identity.allowed_routes == ("mcp_inference_routes",)
|
||||
assert identity.replay_key == "mcp_gateway_dcr:id_jag:" + hashlib.sha256(f"{ISSUER}\njti-1".encode()).hexdigest()
|
||||
assert identity.replay_ttl_seconds == math.ceil(300)
|
||||
other_jti = await _identity(_redemption(_assertion(jti="jti-2")), _Verifier(), _Resolver())
|
||||
assert isinstance(other_jti, AssertionIdentity)
|
||||
assert other_jti.replay_key != identity.replay_key
|
||||
teamless = await _identity(_redemption(_assertion(scope=""), team_id=None))
|
||||
assert isinstance(teamless, AssertionIdentity)
|
||||
assert (teamless.team_id, teamless.scope, teamless.allowed_routes) == (None, None, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_requesting_app_the_operator_did_not_allowlist_learns_nothing_about_the_assertion():
|
||||
verifier, resolver = _Verifier(), _Resolver()
|
||||
refused = await _identity(_redemption(_assertion(), client_id="0oa-stranger"), verifier, resolver)
|
||||
assert refused == SubjectTokenRefusal(
|
||||
error="invalid_client", description="client_id is not an allowlisted requesting app"
|
||||
)
|
||||
assert verifier.calls == ()
|
||||
assert resolver.calls == ()
|
||||
|
||||
|
||||
def _basic(client_id: str, secret: str) -> str:
|
||||
return "Basic " + b64encode(f"{client_id}:{secret}".encode()).decode()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"client_secret, authorization, accepted",
|
||||
[
|
||||
(None, None, False),
|
||||
("wrong", None, False),
|
||||
("s3cr\u00e9t", None, False),
|
||||
("s3cret", None, True),
|
||||
(None, _basic(CONFIDENTIAL_APP.client_id, "s3cr\u00e9t"), False),
|
||||
(None, _basic(CONFIDENTIAL_APP.client_id, "s3cret"), True),
|
||||
(None, _basic(CONFIDENTIAL_APP.client_id, "wrong"), False),
|
||||
(None, _basic(PUBLIC_APP.client_id, "s3cret"), False),
|
||||
("s3cret", _basic(CONFIDENTIAL_APP.client_id, "wrong"), False),
|
||||
(None, "Basic not-base64!", False),
|
||||
(None, "Bearer s3cret", False),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_confidential_app_must_authenticate_with_its_secret(client_secret, authorization, accepted):
|
||||
verifier = _Verifier()
|
||||
assertion = _assertion(client_id=CONFIDENTIAL_APP.client_id)
|
||||
redemption = _redemption(assertion, CONFIDENTIAL_APP.client_id, client_secret, authorization)
|
||||
identity = await _identity(redemption, verifier)
|
||||
if accepted:
|
||||
assert isinstance(identity, AssertionIdentity)
|
||||
return
|
||||
assert identity == SubjectTokenRefusal(error="invalid_client", description="client authentication failed")
|
||||
assert verifier.calls == ()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"client_secret, authorization, accepted",
|
||||
[
|
||||
("app secret", None, True),
|
||||
(None, "Basic " + b64encode(b"0oa+spaced:app+secret").decode(), True),
|
||||
(None, "Basic " + b64encode(b"0oa%20spaced:app%20secret").decode(), True),
|
||||
(None, "Basic " + b64encode(b"0oa spaced:app secret").decode(), True),
|
||||
(None, "Basic " + b64encode(b"0oa+spaced:app+wrong").decode(), False),
|
||||
(None, "Basic " + b64encode(b"0oa-spaced:app+secret").decode(), False),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_basic_credentials_are_form_decoded_so_a_space_in_an_id_or_secret_still_matches(
|
||||
client_secret, authorization, accepted
|
||||
):
|
||||
"""RFC 6749 section 2.3.1 form-encodes the id and secret before base64, so ``+`` is a space. The
|
||||
endpoint hands over the ``client_id`` it read from the header, so the header must name the same app."""
|
||||
assertion = _assertion(client_id=SPACED_APP.client_id)
|
||||
identity = await _identity(_redemption(assertion, SPACED_APP.client_id, client_secret, authorization), _Verifier())
|
||||
if accepted:
|
||||
assert isinstance(identity, AssertionIdentity)
|
||||
return
|
||||
assert identity == SubjectTokenRefusal(error="invalid_client", description="client authentication failed")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_public_app_is_identified_by_client_id_alone():
|
||||
identity = await _identity(_redemption(_assertion(), client_secret="anything", authorization="Bearer x"))
|
||||
assert isinstance(identity, AssertionIdentity)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"assertion, description",
|
||||
[
|
||||
("not-a-jwt", "assertion is not a JWT"),
|
||||
(_assertion(typ="JWT"), f"assertion typ must be {ID_JAG_TYP}"),
|
||||
(_assertion(typ="at+jwt"), f"assertion typ must be {ID_JAG_TYP}"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_bearer_or_id_token_is_not_an_id_jag(assertion, description):
|
||||
verifier = _Verifier()
|
||||
refused = await _identity(_redemption(assertion), verifier)
|
||||
assert refused == SubjectTokenRefusal(error="invalid_grant", description=description)
|
||||
assert verifier.calls == ()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"verifier, reason",
|
||||
[
|
||||
(_Verifier(result=AssertionRejection(reason="Signature verification failed")), "Signature verification"),
|
||||
(_Verifier(result=AssertionRejection(reason="Audience doesn't match")), "Audience"),
|
||||
(_Verifier(result=AssertionRejection(reason='Token is missing the "jti" claim')), "jti"),
|
||||
(_Verifier(result=AssertionRejection(reason="Signature has expired")), "expired"),
|
||||
(_Verifier(result=AssertionRejection(reason="iss is not an issuer this gateway trusts")), "iss"),
|
||||
(_Verifier(raises=Exception("Validation fails: Invalid audience")), "Invalid audience"),
|
||||
(_Verifier(result=AssertionRejection(reason="bad kid\nWARNING forged line")), "bad kid WARNING forged line"),
|
||||
],
|
||||
)
|
||||
async def test_an_assertion_the_verifier_rejects_is_refused_with_the_reason_kept_in_the_log(verifier, reason, caplog):
|
||||
resolver = _Resolver()
|
||||
with caplog.at_level(logging.WARNING, logger="litellm.proxy"):
|
||||
refused = await _identity(_redemption(_assertion()), verifier, resolver)
|
||||
assert refused == SubjectTokenRefusal(error="invalid_grant", description=REJECTED_ASSERTION)
|
||||
assert reason not in refused.description
|
||||
assert reason in caplog.text
|
||||
assert resolver.calls == ()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"claims, reason",
|
||||
[
|
||||
({"client_id": "0oa-confidential"}, "client_id"),
|
||||
({"exp": NOW}, "expired"),
|
||||
({"exp": NOW - 1}, "expired"),
|
||||
({"jti": ""}, "jti"),
|
||||
({"jti": 7}, "jti"),
|
||||
({"iss": None}, "iss"),
|
||||
({"cnf": {"jkt": "thumbprint"}}, "proof"),
|
||||
],
|
||||
)
|
||||
async def test_verified_claims_that_do_not_fit_the_profile_are_refused(claims, reason, caplog):
|
||||
resolver = _Resolver()
|
||||
with caplog.at_level(logging.WARNING, logger="litellm.proxy"):
|
||||
refused = await _identity(_redemption(_assertion()), _Verifier(result=_claims(**claims)), resolver)
|
||||
assert refused == SubjectTokenRefusal(error="invalid_grant", description=REJECTED_ASSERTION)
|
||||
assert reason in caplog.text
|
||||
assert resolver.calls == ()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_assertion_that_names_no_user_the_gateway_knows_cannot_be_redeemed(caplog):
|
||||
with caplog.at_level(logging.WARNING, logger="litellm.proxy"):
|
||||
refused = await _identity(_redemption(_assertion()), resolver=_Resolver(user_id=None))
|
||||
assert refused == SubjectTokenRefusal(error="invalid_grant", description=REJECTED_ASSERTION)
|
||||
assert "names no user" in caplog.text
|
||||
missing = await _identity(
|
||||
_redemption(_assertion()), resolver=_Resolver(raises=ValueError("User doesn't exist in db. 'user_id'=u1"))
|
||||
)
|
||||
assert missing == SubjectTokenRefusal(error="invalid_grant", description=REJECTED_ASSERTION)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"verifier, resolver",
|
||||
[
|
||||
(_Verifier(raises=JWKS_DOWN), _Resolver()),
|
||||
(_Verifier(), _Resolver(raises=HTTPException(status_code=503, detail="database unavailable"))),
|
||||
],
|
||||
)
|
||||
async def test_a_gateway_outage_is_reported_as_retryable_not_as_a_bad_assertion(verifier, resolver):
|
||||
refused = await _identity(_redemption(_assertion()), verifier, resolver)
|
||||
assert isinstance(refused, SubjectTokenRefusal)
|
||||
assert refused.error == "temporarily_unavailable"
|
||||
assert "retry" in refused.description
|
||||
assert "assertion" in refused.description
|
||||
assert "subject_token" not in refused.description
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"unmet, description",
|
||||
[
|
||||
({"jwt_auth_enabled": False}, "JWT auth is not enabled"),
|
||||
({"has_database": False}, "no database"),
|
||||
({"licensed": False}, "enterprise only"),
|
||||
({"maps_jwts_to_virtual_keys": True}, "maps IdP tokens to virtual keys"),
|
||||
({"shared_replay_store": False}, "Redis"),
|
||||
({"base_url": None}, "PROXY_BASE_URL"),
|
||||
({"requesting_apps": ()}, "no requesting apps allowlisted"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_deployment_gates_refuse_before_any_verification(unmet, description):
|
||||
verifier = _Verifier()
|
||||
refused = await _identity(_redemption(_assertion()), verifier, **unmet)
|
||||
assert isinstance(refused, SubjectTokenRefusal)
|
||||
assert refused.error == "unsupported_grant_type"
|
||||
assert description in refused.description
|
||||
assert verifier.calls == ()
|
||||
assert IdJagPrerequisites(**{**EVERY_GATE_HOLDS, **unmet}).available is False
|
||||
assert IdJagPrerequisites(**EVERY_GATE_HOLDS).available is True
|
||||
|
||||
|
||||
def _running_jwt_handler(litellm_jwtauth: LiteLLM_JWTAuth | None) -> JWTHandler:
|
||||
handler: Final = JWTHandler()
|
||||
if litellm_jwtauth is not None:
|
||||
handler.update_environment(prisma_client=None, user_api_key_cache=DualCache(), litellm_jwtauth=litellm_jwtauth)
|
||||
return handler
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"general_settings, prisma_client, premium_user, litellm_jwtauth, expected",
|
||||
[
|
||||
({"enable_jwt_auth": True}, object(), True, LiteLLM_JWTAuth(id_jag_requesting_apps=[PUBLIC_APP]), True),
|
||||
(
|
||||
{"enable_jwt_auth": True, "redis": None},
|
||||
object(),
|
||||
True,
|
||||
LiteLLM_JWTAuth(id_jag_requesting_apps=[PUBLIC_APP]),
|
||||
False,
|
||||
),
|
||||
(
|
||||
{"enable_jwt_auth": True, "base_url": ""},
|
||||
object(),
|
||||
True,
|
||||
LiteLLM_JWTAuth(id_jag_requesting_apps=[PUBLIC_APP]),
|
||||
False,
|
||||
),
|
||||
({"enable_jwt_auth": True}, object(), True, LiteLLM_JWTAuth(id_jag_requesting_apps=[]), False),
|
||||
(
|
||||
{"enable_jwt_auth": True},
|
||||
object(),
|
||||
True,
|
||||
LiteLLM_JWTAuth(id_jag_requesting_apps=[PUBLIC_APP], virtual_key_claim_field="vkey"),
|
||||
False,
|
||||
),
|
||||
({"enable_jwt_auth": True}, object(), True, LiteLLM_JWTAuth(), False),
|
||||
({"enable_jwt_auth": True}, object(), True, None, False),
|
||||
({}, object(), True, LiteLLM_JWTAuth(id_jag_requesting_apps=[PUBLIC_APP]), False),
|
||||
({"enable_jwt_auth": True}, None, True, LiteLLM_JWTAuth(id_jag_requesting_apps=[PUBLIC_APP]), False),
|
||||
({"enable_jwt_auth": True}, object(), False, LiteLLM_JWTAuth(id_jag_requesting_apps=[PUBLIC_APP]), False),
|
||||
],
|
||||
)
|
||||
def test_availability_is_read_from_the_running_proxy(
|
||||
monkeypatch, general_settings, prisma_client, premium_user, litellm_jwtauth, expected
|
||||
):
|
||||
monkeypatch.setenv("PROXY_BASE_URL", general_settings.get("base_url", "https://gateway.example.com"))
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", general_settings)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", prisma_client)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", premium_user)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.redis_usage_cache", general_settings.get("redis", object()))
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.jwt_handler", _running_jwt_handler(litellm_jwtauth))
|
||||
assert jwt_bearer_available() is expected
|
||||
|
||||
|
||||
def test_the_assertion_audience_is_the_fixed_gateway_url_not_the_request_host():
|
||||
"""An ID-JAG must name this gateway's MCP resource or its authorization server under the
|
||||
operator-fixed PROXY_BASE_URL. Without it there is no audience, so there is no grant."""
|
||||
assert gateway_audiences("https://gateway.example.com") == (
|
||||
"https://gateway.example.com/mcp",
|
||||
"https://gateway.example.com",
|
||||
)
|
||||
assert gateway_audiences(None) == ()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_team_the_subject_lookup_cannot_settle_is_refused_before_the_assertion_is_consumed():
|
||||
"""The resolver's refusal (a user on several teams who named none, or a header naming a team
|
||||
they are not on) comes back as is, so the client fixes its request and presents the same
|
||||
assertion again instead of finding its jti burned."""
|
||||
refusal = SubjectTokenRefusal(error="invalid_request", description=f"send the {TEAM_HEADER} header")
|
||||
refused = await _identity(_redemption(_assertion()), resolver=_Resolver(result=refusal))
|
||||
assert refused is refusal
|
||||
teamed = await _identity(
|
||||
_redemption(_assertion()), resolver=_Resolver(result=SubjectIdentity(user_id="u1", team_id="t9"))
|
||||
)
|
||||
assert isinstance(teamed, AssertionIdentity)
|
||||
assert teamed.team_id == "t9"
|
||||
|
||||
|
||||
ENG = ConsentTeam(team_id="team-eng", team_alias="engineering")
|
||||
OPS = ConsentTeam(team_id="team-ops", team_alias=None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"team_header, claim_teams, teams, expected",
|
||||
[
|
||||
(None, (), (), None),
|
||||
(None, (), (ENG,), "team-eng"),
|
||||
("team-eng", (), (ENG, OPS), "team-eng"),
|
||||
("engineering", (), (ENG, OPS), "team-eng"),
|
||||
("team-ops", ("team-eng",), (ENG, OPS), "team-ops"),
|
||||
(None, ("team-ops",), (ENG, OPS), "team-ops"),
|
||||
(None, ("team-sales", "team-ops"), (ENG, OPS), "team-ops"),
|
||||
(None, ("0oa-xaa-public-app",), (ENG,), "team-eng"),
|
||||
(None, ("0oa-xaa-public-app",), (), None),
|
||||
],
|
||||
)
|
||||
def test_select_team_prefers_the_header_then_the_assertion_then_the_only_team(
|
||||
team_header, claim_teams, teams, expected
|
||||
):
|
||||
assert select_team(team_header, claim_teams, teams) == expected
|
||||
|
||||
|
||||
class _Reader:
|
||||
def __init__(self, user_id=None, email=None, team_ids=(), allowed_domain=None):
|
||||
self.user_id, self.email, self.team_ids, self.allowed_domain = user_id, email, team_ids, allowed_domain
|
||||
|
||||
def is_allowed_domain(self, user_email):
|
||||
return self.allowed_domain is None or user_email.split("@")[-1] == self.allowed_domain
|
||||
|
||||
def get_user_id(self, token, default_value):
|
||||
return self.user_id or default_value
|
||||
|
||||
def get_user_email(self, token, default_value):
|
||||
return self.email or default_value
|
||||
|
||||
def get_all_jwt_team_ids(self, token):
|
||||
return list(self.team_ids)
|
||||
|
||||
|
||||
async def _known_user(user_id: str, user_email: str | None) -> str | None:
|
||||
return user_id if user_id in {"okta-user-1", "u-mapped"} else None
|
||||
|
||||
|
||||
async def _two_teams(user_id: str) -> tuple[ConsentTeam, ...]:
|
||||
return (ENG, OPS)
|
||||
|
||||
|
||||
async def _teams_unreadable(user_id: str) -> ReloadUserFailure:
|
||||
return "unavailable"
|
||||
|
||||
|
||||
def _teams_failing(failure: ReloadUserFailure) -> LookupTeams:
|
||||
async def lookup_teams(user_id: str) -> tuple[ConsentTeam, ...] | ReloadUserFailure:
|
||||
return failure
|
||||
|
||||
return lookup_teams
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_subject_resolver_binds_the_claims_to_a_known_user_and_their_asserted_team():
|
||||
"""The user comes from the JWT auth user mapping, else ``sub``, and must exist. The team the
|
||||
assertion asserts settles a multi-team user, the header still wins, and unreadable teams are a
|
||||
retryable refusal rather than a credential for the wrong team."""
|
||||
asserted = subject_resolver(_Reader(team_ids=("team-ops",)), _known_user, _two_teams)
|
||||
assert await asserted(_claims(), None) == SubjectIdentity(user_id="okta-user-1", team_id="team-ops")
|
||||
assert await asserted(_claims(), "team-eng") == SubjectIdentity(user_id="okta-user-1", team_id="team-eng")
|
||||
mapped = subject_resolver(_Reader(user_id="u-mapped", email="m@example.com"), _known_user, _two_teams)
|
||||
assert await mapped(_claims(), "engineering") == SubjectIdentity(user_id="u-mapped", team_id="team-eng")
|
||||
unknown = subject_resolver(_Reader(), _known_user, _two_teams)
|
||||
assert await unknown(_claims(sub="nobody"), None) is None
|
||||
unreadable = await subject_resolver(_Reader(), _known_user, _teams_unreadable)(_claims(), None)
|
||||
assert isinstance(unreadable, SubjectTokenRefusal)
|
||||
assert unreadable.error == "temporarily_unavailable"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", ["no_active_key", "unresolvable"])
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_blocked_or_unmapped_user_is_a_bad_grant_not_an_outage_to_retry(failure):
|
||||
"""A deactivated user, or one the resolver cannot map, can retry forever and never succeed, so
|
||||
the refusal is invalid_grant. Only a database the gateway could not reach is retryable."""
|
||||
refused = await subject_resolver(_Reader(), _known_user, _teams_failing(failure))(_claims(), None)
|
||||
assert isinstance(refused, SubjectTokenRefusal)
|
||||
assert refused.error == "invalid_grant"
|
||||
assert "retry" not in refused.description
|
||||
outage = await subject_resolver(_Reader(), _known_user, _teams_failing("faulted"))(_claims(), None)
|
||||
assert isinstance(outage, SubjectTokenRefusal)
|
||||
assert outage.error == "temporarily_unavailable"
|
||||
|
||||
|
||||
def test_the_assertion_team_claims_ignore_team_id_default():
|
||||
"""``team_id_default`` is how bearer JWT auth picks a team for a request, not a claim the
|
||||
assertion made, so a user outside that team must still get their only team or no team."""
|
||||
handler = JWTHandler()
|
||||
handler.litellm_jwtauth = LiteLLM_JWTAuth(team_id_default="default-team")
|
||||
claim_teams = tuple(_claim_reader(handler).get_all_jwt_team_ids(token={"iss": ISSUER, "sub": "u1"}))
|
||||
assert claim_teams == ()
|
||||
assert select_team(None, claim_teams, (ENG,)) == "team-eng"
|
||||
assert select_team(None, claim_teams, ()) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"team_header, claim_teams, teams, error, mention",
|
||||
[
|
||||
("team-sales", (), (ENG, OPS), "invalid_request", TEAM_HEADER),
|
||||
("engineering", (), (), "invalid_request", TEAM_HEADER),
|
||||
(None, (), (ENG, OPS), "invalid_request", TEAM_HEADER),
|
||||
(None, ("team-eng", "team-ops"), (ENG, OPS), "invalid_request", TEAM_HEADER),
|
||||
(None, ("0oa-xaa-public-app",), (ENG, OPS), "invalid_request", TEAM_HEADER),
|
||||
],
|
||||
)
|
||||
def test_select_team_refuses_a_team_the_user_is_not_on_or_an_unnamed_choice(
|
||||
team_header, claim_teams, teams, error, mention
|
||||
):
|
||||
refused = select_team(team_header, claim_teams, teams)
|
||||
assert isinstance(refused, SubjectTokenRefusal)
|
||||
assert refused.error == error
|
||||
assert mention in refused.description
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"authorization, expected",
|
||||
[
|
||||
(None, None),
|
||||
("Bearer abc", None),
|
||||
("Basic not-base64!", None),
|
||||
(_basic("0oa-confidential", "s3cret"), "0oa-confidential"),
|
||||
("basic " + b64encode(b"0oa%20x:s3cret").decode(), "0oa x"),
|
||||
("Basic " + b64encode(b"0oa+x:s3cret").decode(), "0oa x"),
|
||||
("Basic " + b64encode(b"no-separator").decode(), None),
|
||||
],
|
||||
)
|
||||
def test_basic_client_id_reads_the_app_from_the_authorization_header(authorization, expected):
|
||||
assert basic_client_id(authorization) == expected
|
||||
|
||||
|
||||
MCP_ROUTE_GROUPS = ("mcp_inference_routes",)
|
||||
LLM_ROUTE_GROUPS = ("llm_api_routes_without_mcp",)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"claim, scope, allowed_routes",
|
||||
[
|
||||
(None, None, None),
|
||||
("", None, None),
|
||||
("litellm:mcp", "litellm:mcp", MCP_ROUTE_GROUPS),
|
||||
("litellm:llm", "litellm:llm", LLM_ROUTE_GROUPS),
|
||||
("litellm:mcp litellm:llm", "litellm:mcp litellm:llm", MCP_ROUTE_GROUPS + LLM_ROUTE_GROUPS),
|
||||
("litellm:llm litellm:mcp litellm:llm", "litellm:mcp litellm:llm", MCP_ROUTE_GROUPS + LLM_ROUTE_GROUPS),
|
||||
],
|
||||
)
|
||||
def test_the_scope_claim_buys_exactly_the_route_groups_it_names(claim, scope, allowed_routes):
|
||||
"""``litellm:mcp`` is the MCP gateway's data-plane routes, ``litellm:llm`` the LLM API routes
|
||||
without their MCP members, both together are both, and no claim is the full credential. The
|
||||
scope echoed back is canonical: deduplicated, in vocabulary order, so it is exactly what is
|
||||
enforced. Every group named must be one the route check resolves."""
|
||||
granted = granted_scope(claim, mcp_resource=False)
|
||||
assert (granted.scope, granted.allowed_routes) == (scope, allowed_routes)
|
||||
if allowed_routes is not None:
|
||||
assert set(allowed_routes) <= set(LiteLLMRoutes._member_names_)
|
||||
mcp_members = set(LiteLLMRoutes.mcp_inference_routes.value)
|
||||
llm_members = set().union(*(LiteLLMRoutes[group].value for group in LLM_ROUTE_GROUPS))
|
||||
assert "/chat/completions" in llm_members and "/mcp" not in llm_members
|
||||
assert "/mcp" in mcp_members and "/chat/completions" not in mcp_members
|
||||
|
||||
|
||||
@pytest.mark.parametrize("claim", ["openid", "litellm:mcp openid", "litellm:admin", 7, ["litellm:mcp"]])
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_scope_the_gateway_does_not_know_is_refused_as_invalid_scope_before_any_lookup(claim):
|
||||
refusal = granted_scope(claim, mcp_resource=False)
|
||||
assert isinstance(refusal, SubjectTokenRefusal)
|
||||
assert refusal.error == "invalid_scope"
|
||||
assert "litellm:mcp" in refusal.description and "litellm:llm" in refusal.description
|
||||
resolver = _Resolver()
|
||||
refused = await _identity(_redemption(_assertion()), _Verifier(result=_claims(scope=claim)), resolver)
|
||||
assert refused == refusal
|
||||
assert resolver.calls == ()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_linked_sso_account_resolves_by_its_provider_subject_without_an_email_claim():
|
||||
"""An SSO login stores the provider subject as ``sso_user_id`` on a user whose ``user_id`` is
|
||||
something else, so the lookup must offer the subject as both, with no email to fall back on."""
|
||||
from litellm.models.user import LiteLLM_UserTable
|
||||
|
||||
async def get_user(*, user_id, user_email, sso_user_id):
|
||||
if sso_user_id == "00u-okta-subject" and user_email is None:
|
||||
return LiteLLM_UserTable(user_id="local-user-7", user_email=None)
|
||||
return None
|
||||
|
||||
lookup = proxy_user_lookup(get_user)
|
||||
assert await lookup("00u-okta-subject", None) == "local-user-7"
|
||||
assert await lookup("nobody", None) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("email", ["eve@evil.example", None])
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_allowed_email_domain_gates_the_assertions_subject_like_bearer_jwt_auth(email):
|
||||
"""With ``user_allowed_email_domain`` set, bearer JWT auth refuses an identity whose email is
|
||||
outside the domain or missing. An assertion for an existing user gets the same refusal before
|
||||
any lookup, as invalid_grant, and a matching email still resolves."""
|
||||
refused = await subject_resolver(_Reader(email=email, allowed_domain="corp.example"), _known_user, _two_teams)(
|
||||
_claims(), "team-eng"
|
||||
)
|
||||
assert isinstance(refused, SubjectTokenRefusal)
|
||||
assert refused.error == "invalid_grant"
|
||||
allowed = subject_resolver(
|
||||
_Reader(email="ann@corp.example", allowed_domain="corp.example"), _known_user, _two_teams
|
||||
)
|
||||
assert await allowed(_claims(), "team-eng") == SubjectIdentity(user_id="okta-user-1", team_id="team-eng")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("claim", [None, "", "litellm:mcp", "litellm:mcp litellm:mcp"])
|
||||
def test_an_assertion_for_the_mcp_resource_is_ceilinged_to_the_mcp_routes(claim):
|
||||
"""``aud = {base}/mcp`` means the identity provider named the MCP resource, so no scope or
|
||||
``litellm:mcp`` both mint an MCP-only credential and echo ``litellm:mcp``."""
|
||||
granted = granted_scope(claim, mcp_resource=True)
|
||||
assert granted == GrantedScope(scope="litellm:mcp", allowed_routes=("mcp_inference_routes",))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("claim", ["litellm:llm", "litellm:mcp litellm:llm"])
|
||||
def test_llm_routes_cannot_be_granted_under_the_mcp_resource_audience(claim):
|
||||
refusal = granted_scope(claim, mcp_resource=True)
|
||||
assert isinstance(refusal, SubjectTokenRefusal)
|
||||
assert refusal.error == "invalid_scope"
|
||||
assert "/mcp audience" in refusal.description and "bare origin" in refusal.description
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"aud, expected",
|
||||
[
|
||||
(AUDIENCES[0], True),
|
||||
(AUDIENCES[1], False),
|
||||
([AUDIENCES[0]], True),
|
||||
([AUDIENCES[0], AUDIENCES[1]], False),
|
||||
("https://other.example", False),
|
||||
(None, False),
|
||||
],
|
||||
)
|
||||
def test_the_mcp_resource_audience_is_the_mcp_path_without_the_bare_origin(aud, expected):
|
||||
assert names_mcp_resource(aud, GATEWAY) is expected
|
||||
assert names_mcp_resource(aud, None) is False
|
||||
|
||||
|
||||
@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
|
||||
resource, come out ceilinged, and asking for LLM routes under that audience is a scope error."""
|
||||
narrowed = await _identity(_redemption(_assertion(aud=AUDIENCES[0])), _Verifier(), _Resolver())
|
||||
assert isinstance(narrowed, AssertionIdentity)
|
||||
assert (narrowed.scope, narrowed.allowed_routes) == ("litellm:mcp", ("mcp_inference_routes",))
|
||||
refused = await _identity(_redemption(_assertion(aud=AUDIENCES[0], scope="litellm:llm")), _Verifier(), _Resolver())
|
||||
assert isinstance(refused, SubjectTokenRefusal)
|
||||
assert refused.error == "invalid_scope"
|
||||
unscoped = jwt.encode(
|
||||
{key: value for key, value in _claims(aud=AUDIENCES[1]).items() if key != "scope"},
|
||||
"unused-by-the-fake-verifier-but-long-enough-for-hs256",
|
||||
algorithm="HS256",
|
||||
headers={"typ": ID_JAG_TYP},
|
||||
)
|
||||
full = await _identity(_redemption(unscoped), _Verifier(), _Resolver())
|
||||
assert isinstance(full, AssertionIdentity)
|
||||
assert (full.scope, full.allowed_routes) == (None, None)
|
||||
scoped = await _identity(_redemption(_assertion(aud=AUDIENCES[1], scope="litellm:llm")), _Verifier(), _Resolver())
|
||||
assert isinstance(scoped, AssertionIdentity)
|
||||
assert scoped.scope == "litellm:llm"
|
||||
|
|
@ -1,15 +1,21 @@
|
|||
"""Tests for minting the ``lite login`` credential from a consented native-client grant."""
|
||||
|
||||
from unittest.mock import ANY, AsyncMock, MagicMock
|
||||
from unittest.mock import ANY, AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm.constants import CLI_JWT_EXPIRATION_HOURS
|
||||
from litellm.models.user import LiteLLM_UserTable
|
||||
from litellm.proxy._experimental.mcp_server.gateway_dcr_flow import ConsentTeam, MintedProxyCredential
|
||||
from litellm.proxy._experimental.mcp_server.proxy_api_credentials import lookup_consent_teams, mint_proxy_credential
|
||||
from litellm.proxy._experimental.mcp_server.proxy_api_credentials import (
|
||||
lookup_consent_teams,
|
||||
lookup_consent_teams_from_database,
|
||||
mint_proxy_credential,
|
||||
)
|
||||
from litellm.proxy._types import LitellmUserRoles
|
||||
from litellm.proxy.auth.auth_checks import ExperimentalUIJWTToken
|
||||
from litellm.proxy.auth.route_checks import RouteChecks
|
||||
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
||||
from litellm.proxy.management_endpoints.ui_sso import CliSsoTeamDetail
|
||||
|
||||
|
|
@ -235,3 +241,82 @@ async def test_lookup_consent_teams_passes_user_lookup_failures_through(failure,
|
|||
async def test_lookup_consent_teams_reports_unavailable_when_details_cannot_load(load_user, fetch_teams):
|
||||
fetch_teams.return_value = None
|
||||
assert await lookup_consent_teams("u1") == "unavailable"
|
||||
|
||||
|
||||
def _route_status(minted: MintedProxyCredential, route: str, method: str | None = None) -> int:
|
||||
request = None if method is None else MagicMock(method=method)
|
||||
try:
|
||||
RouteChecks.is_virtual_key_allowed_to_call_route(route=route, valid_token=_decoded(minted), request=request)
|
||||
except HTTPException as refused:
|
||||
return refused.status_code
|
||||
return 200
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"allowed_routes, chat_completions, mcp",
|
||||
[
|
||||
(None, True, True),
|
||||
(("mcp_inference_routes",), False, True),
|
||||
(("llm_api_routes_without_mcp",), True, False),
|
||||
(("mcp_inference_routes", "llm_api_routes_without_mcp"), True, True),
|
||||
],
|
||||
ids=["full credential", "mcp only", "llm only", "both"],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_mint_narrows_the_credential_to_the_allowed_routes_the_route_check_enforces(
|
||||
load_user, fetch_teams, allowed_routes, chat_completions, mcp
|
||||
):
|
||||
"""The allowed_routes a grant's scope resolved to ride inside the credential, so the same route
|
||||
check a virtual key goes through on every request refuses /chat/completions for an MCP-only
|
||||
credential and /mcp for an LLM-only one, and a credential minted without them is unchanged."""
|
||||
minted = await mint_proxy_credential("u1", "team-a", allowed_routes=allowed_routes)
|
||||
assert isinstance(minted, MintedProxyCredential)
|
||||
assert _decoded(minted).allowed_routes == list(allowed_routes or ())
|
||||
chat_status, mcp_status = (200 if chat_completions else 403), (200 if mcp else 403)
|
||||
assert _route_status(minted, "/chat/completions") == chat_status
|
||||
assert _route_status(minted, "/v1/chat/completions") == chat_status
|
||||
assert _route_status(minted, "/mcp") == mcp_status
|
||||
assert _route_status(minted, "/mcp/tools/call") == mcp_status
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lookup_consent_teams_from_database_reads_the_row_the_minter_will_recheck(load_user, fetch_teams):
|
||||
"""A grant consumed once a team is picked must pick it from the same membership the minter re-reads,
|
||||
or a stale cache would burn the assertion on a request the caller could have fixed."""
|
||||
teams = await lookup_consent_teams_from_database("u1")
|
||||
load_user.assert_awaited_once_with("u1", source="database")
|
||||
assert teams == (ConsentTeam(team_id="team-a", team_alias="Team A"), ConsentTeam(team_id="team-b"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"allowed_routes, registered_pass_through, auto_router_session, mcp_server_listing, agent_registry",
|
||||
[
|
||||
(("llm_api_routes",), 200, 200, 200, 200),
|
||||
(("llm_api_routes_without_mcp",), 200, 200, 403, 403),
|
||||
(("mcp_inference_routes",), 403, 403, 403, 403),
|
||||
],
|
||||
ids=["llm_api_routes key", "llm only", "mcp only"],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_llm_only_credential_keeps_the_llm_api_carve_outs_except_mcp_discovery(
|
||||
load_user,
|
||||
fetch_teams,
|
||||
allowed_routes,
|
||||
registered_pass_through,
|
||||
auto_router_session,
|
||||
mcp_server_listing,
|
||||
agent_registry,
|
||||
):
|
||||
"""Operator-registered pass-through endpoints and the auto-router session read are LLM API
|
||||
surface that only the ``llm_api_routes`` group used to reach, so an LLM-only credential keeps
|
||||
them, while the MCP server listing that group also carves out stays MCP surface."""
|
||||
minted = await mint_proxy_credential("u1", "team-a", allowed_routes=allowed_routes)
|
||||
assert isinstance(minted, MintedProxyCredential)
|
||||
with patch(
|
||||
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.InitPassThroughEndpointHelpers.is_registered_pass_through_route",
|
||||
side_effect=lambda route: route.startswith("/my-llm-endpoint"),
|
||||
):
|
||||
assert _route_status(minted, "/my-llm-endpoint/v1/chat", "POST") == registered_pass_through
|
||||
assert _route_status(minted, "/auto_router/session", "GET") == auto_router_session
|
||||
assert _route_status(minted, "/v1/mcp/server", "GET") == mcp_server_listing
|
||||
assert _route_status(minted, "/v1/agents", "GET") == agent_registry
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from litellm.caching.dual_cache import DualCache
|
|||
from litellm.proxy._types import (
|
||||
DEFAULT_JWKS_STALE_TTL,
|
||||
JWTLiteLLMRoleMap,
|
||||
JWTIssuerConfig,
|
||||
LiteLLMRoutes,
|
||||
LiteLLM_JWTAuth,
|
||||
LiteLLM_ModelTable,
|
||||
|
|
@ -29,9 +30,11 @@ from litellm.proxy._types import (
|
|||
from litellm.proxy.agent_endpoints.agent_registry import AgentRegistry
|
||||
from litellm.proxy.auth.auth_checks import TeamNotFoundError
|
||||
from litellm.proxy.auth.handle_jwt import (
|
||||
ID_JAG_TYP,
|
||||
JWKS_FETCH_ATTEMPTS,
|
||||
STALE_CACHE_KEY_PREFIX,
|
||||
STALE_WRITTEN_AT_CACHE_KEY_PREFIX,
|
||||
AssertionRejection,
|
||||
HeaderTeam,
|
||||
JWKSUnreachableError,
|
||||
JWTAuthManager,
|
||||
|
|
@ -8303,3 +8306,198 @@ def test_team_passthrough_route_denial_names_the_matched_deny_entry(
|
|||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert expected_detail in str(exc_info.value.detail)
|
||||
|
||||
|
||||
ASSERTION_ISSUER = "https://idp.example.com/oauth2/default"
|
||||
ASSERTION_JWKS_URL = f"{ASSERTION_ISSUER}/v1/keys"
|
||||
GATEWAY_AUDIENCES = ("https://gateway.example.com/mcp", "https://gateway.example.com")
|
||||
ID_JAG_CLAIMS = ("iss", "sub", "aud", "exp", "iat", "jti", "client_id")
|
||||
|
||||
|
||||
def _assertion_signer():
|
||||
import json
|
||||
|
||||
import jwt as pyjwt
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
private_key: Final = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
jwk: Final = {**json.loads(pyjwt.algorithms.RSAAlgorithm.to_jwk(private_key.public_key())), "kid": "k1"}
|
||||
|
||||
def sign(claims: Mapping[str, object], typ: str = ID_JAG_TYP) -> str:
|
||||
return pyjwt.encode(dict(claims), private_key, algorithm="RS256", headers={"kid": "k1", "typ": typ})
|
||||
|
||||
return jwk, sign
|
||||
|
||||
|
||||
def _id_jag_claims(**overrides):
|
||||
now: Final = int(time.time())
|
||||
return {
|
||||
"iss": ASSERTION_ISSUER,
|
||||
"sub": "okta-user-1",
|
||||
"aud": GATEWAY_AUDIENCES[0],
|
||||
"exp": now + 300,
|
||||
"iat": now - 5,
|
||||
"jti": "jti-1",
|
||||
"client_id": "0oa-requesting-app",
|
||||
**overrides,
|
||||
}
|
||||
|
||||
|
||||
def _assertion_handler(jwk, litellm_jwtauth, jwks_url=ASSERTION_JWKS_URL):
|
||||
"""A handler whose JWKS for the issuer is already cached, so no HTTP is needed."""
|
||||
handler: Final = JWTHandler()
|
||||
cache: Final = DualCache()
|
||||
cache.set_cache(f"litellm_jwt_auth_keys_{jwks_url}", [jwk])
|
||||
handler.update_environment(prisma_client=None, user_api_key_cache=cache, litellm_jwtauth=litellm_jwtauth)
|
||||
return handler
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_decode_trusted_assertion_verifies_against_the_configured_issuer_with_the_callers_audience():
|
||||
"""The assertion is checked with the issuer's JWKS and claim mappings, but for the audience the
|
||||
caller names (the gateway's own issuer values), not the API audience the issuer entry carries,
|
||||
since an ID-JAG is addressed to the authorization server, never to the API."""
|
||||
jwk, sign = _assertion_signer()
|
||||
handler = _assertion_handler(
|
||||
jwk,
|
||||
LiteLLM_JWTAuth(
|
||||
issuers=[
|
||||
JWTIssuerConfig(
|
||||
issuer=ASSERTION_ISSUER,
|
||||
jwks_url=ASSERTION_JWKS_URL,
|
||||
audience="api://litellm",
|
||||
user_id_jwt_field="sub",
|
||||
)
|
||||
]
|
||||
),
|
||||
)
|
||||
claims = _id_jag_claims()
|
||||
verified = await handler.decode_trusted_assertion(sign(claims), GATEWAY_AUDIENCES, ID_JAG_CLAIMS)
|
||||
assert isinstance(verified, dict)
|
||||
assert {key: verified[key] for key in claims} == claims
|
||||
assert handler.get_user_id(token=verified, default_value=None) == "okta-user-1"
|
||||
second = await handler.decode_trusted_assertion(
|
||||
sign(_id_jag_claims(aud=GATEWAY_AUDIENCES[1])), GATEWAY_AUDIENCES, ID_JAG_CLAIMS
|
||||
)
|
||||
assert isinstance(second, dict)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_decode_trusted_assertion_falls_back_to_the_env_scoped_issuer(monkeypatch):
|
||||
jwk, sign = _assertion_signer()
|
||||
monkeypatch.setenv("JWT_ISSUER", ASSERTION_ISSUER)
|
||||
monkeypatch.setenv("JWT_PUBLIC_KEY_URL", ASSERTION_JWKS_URL)
|
||||
handler = _assertion_handler(jwk, LiteLLM_JWTAuth())
|
||||
verified = await handler.decode_trusted_assertion(sign(_id_jag_claims()), GATEWAY_AUDIENCES, ID_JAG_CLAIMS)
|
||||
assert isinstance(verified, dict)
|
||||
assert verified["jti"] == "jti-1"
|
||||
monkeypatch.setenv("JWT_ISSUER", "https://someone-else.example.com")
|
||||
foreign = await handler.decode_trusted_assertion(sign(_id_jag_claims()), GATEWAY_AUDIENCES, ID_JAG_CLAIMS)
|
||||
assert isinstance(foreign, AssertionRejection)
|
||||
assert "trusts" in foreign.reason
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"fault, reason",
|
||||
[
|
||||
({"aud": "api://litellm"}, "udience"),
|
||||
({"jti": None}, "jti"),
|
||||
({"client_id": None}, "client_id"),
|
||||
({"exp": -10}, "xpired"),
|
||||
({"iat": 600}, "iat"),
|
||||
({"iss": "https://untrusted.example.com"}, "trusts"),
|
||||
],
|
||||
)
|
||||
async def test_decode_trusted_assertion_rejects_a_claim_outside_the_profile(fault: dict[str, object], reason: str):
|
||||
"""The claims are built inside the test, relative to the clock the verifier reads, so a slow
|
||||
collection cannot turn an intended claim fault into an unrelated expiry (``None`` drops a claim,
|
||||
an int on ``exp`` or ``iat`` is an offset from now)."""
|
||||
now: Final = int(time.time())
|
||||
offsets: Final = {k: now + v for k, v in fault.items() if k in ("exp", "iat") and isinstance(v, int)}
|
||||
claims: Final = {k: v for k, v in _id_jag_claims(**offsets).items() if fault.get(k, k) is not None}
|
||||
claims.update({k: v for k, v in fault.items() if v is not None and k not in offsets})
|
||||
jwk, sign = _assertion_signer()
|
||||
handler = _assertion_handler(
|
||||
jwk,
|
||||
LiteLLM_JWTAuth(issuers=[JWTIssuerConfig(issuer=ASSERTION_ISSUER, jwks_url=ASSERTION_JWKS_URL, audience="x")]),
|
||||
)
|
||||
rejected = await handler.decode_trusted_assertion(sign(claims), GATEWAY_AUDIENCES, ID_JAG_CLAIMS)
|
||||
assert isinstance(rejected, AssertionRejection)
|
||||
assert reason in rejected.reason
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_decode_trusted_assertion_rejects_a_forged_signature_and_garbage(monkeypatch):
|
||||
jwk, _ = _assertion_signer()
|
||||
_, forge = _assertion_signer()
|
||||
handler = _assertion_handler(
|
||||
jwk,
|
||||
LiteLLM_JWTAuth(issuers=[JWTIssuerConfig(issuer=ASSERTION_ISSUER, jwks_url=ASSERTION_JWKS_URL, audience="x")]),
|
||||
)
|
||||
forged = await handler.decode_trusted_assertion(forge(_id_jag_claims()), GATEWAY_AUDIENCES, ID_JAG_CLAIMS)
|
||||
assert isinstance(forged, AssertionRejection)
|
||||
assert "verification" in forged.reason
|
||||
garbage = await handler.decode_trusted_assertion("not.a.jwt", GATEWAY_AUDIENCES, ID_JAG_CLAIMS)
|
||||
assert isinstance(garbage, AssertionRejection)
|
||||
monkeypatch.delenv("JWT_ISSUER", raising=False)
|
||||
unconfigured = await _assertion_handler(jwk, LiteLLM_JWTAuth()).decode_trusted_assertion(
|
||||
forge(_id_jag_claims()), GATEWAY_AUDIENCES, ID_JAG_CLAIMS
|
||||
)
|
||||
assert isinstance(unconfigured, AssertionRejection)
|
||||
assert "trusts" in unconfigured.reason
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_decode_trusted_assertion_reports_an_unreachable_jwks_as_an_outage_not_a_bad_assertion():
|
||||
jwk, sign = _assertion_signer()
|
||||
handler = _assertion_handler(
|
||||
jwk,
|
||||
LiteLLM_JWTAuth(issuers=[JWTIssuerConfig(issuer=ASSERTION_ISSUER, jwks_url=ASSERTION_JWKS_URL, audience="x")]),
|
||||
jwks_url="https://a-different-cached-url.example.com/keys",
|
||||
)
|
||||
with patch.object(handler, "_fetch_jwks_keys", new_callable=AsyncMock, side_effect=JWKSUnreachableError("down")):
|
||||
with pytest.raises(ProxyException) as outage:
|
||||
await handler.decode_trusted_assertion(sign(_id_jag_claims()), GATEWAY_AUDIENCES, ID_JAG_CLAIMS)
|
||||
assert outage.value.code == "503"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_jwt_never_accepts_an_id_jag_as_a_bearer_token():
|
||||
"""An assertion typed oauth-id-jag+jwt is a grant for the token endpoint, so bearer auth
|
||||
refuses it before any key lookup, even where it would otherwise verify."""
|
||||
jwk, sign = _assertion_signer()
|
||||
handler = _assertion_handler(
|
||||
jwk,
|
||||
LiteLLM_JWTAuth(
|
||||
issuers=[
|
||||
JWTIssuerConfig(issuer=ASSERTION_ISSUER, jwks_url=ASSERTION_JWKS_URL, audience=GATEWAY_AUDIENCES[0])
|
||||
]
|
||||
),
|
||||
)
|
||||
accepted = await handler.auth_jwt(sign(_id_jag_claims(), typ="JWT"))
|
||||
assert accepted["jti"] == "jti-1"
|
||||
with pytest.raises(Exception, match="token endpoint"):
|
||||
await handler.auth_jwt(sign(_id_jag_claims()))
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("verdict", [True, False])
|
||||
async def test_decode_trusted_assertion_runs_the_configured_custom_validate_on_the_verified_claims(verdict):
|
||||
"""The operator's ``custom_validate`` gates every JWT the proxy admits, so an assertion redeemed
|
||||
at the token endpoint goes through it too, after signature and claim verification."""
|
||||
jwk, sign = _assertion_signer()
|
||||
seen: Final = []
|
||||
handler = _assertion_handler(
|
||||
jwk,
|
||||
LiteLLM_JWTAuth(
|
||||
issuers=[JWTIssuerConfig(issuer=ASSERTION_ISSUER, jwks_url=ASSERTION_JWKS_URL, audience="api://litellm")]
|
||||
),
|
||||
)
|
||||
handler.litellm_jwtauth.custom_validate = lambda claims: seen.append(dict(claims)) or verdict
|
||||
claims = _id_jag_claims()
|
||||
verified = await handler.decode_trusted_assertion(sign(claims), GATEWAY_AUDIENCES, ID_JAG_CLAIMS)
|
||||
assert [{key: call[key] for key in claims} for call in seen] == [claims]
|
||||
if verdict:
|
||||
assert isinstance(verified, dict)
|
||||
else:
|
||||
assert isinstance(verified, AssertionRejection)
|
||||
assert "custom_validate" in verified.reason
|
||||
|
|
|
|||
|
|
@ -401,3 +401,25 @@ def test_without_the_flag_the_warmup_route_registers_a_feature_and_returns_its_p
|
|||
assert body.paths["/alpha/list"]["get"].tags == ("alpha",)
|
||||
assert loaded_lazy_modules(app) == {features[0].module_path}
|
||||
assert "/alpha/list" in _paths(app) and "/beta/list" not in _paths(app)
|
||||
|
||||
|
||||
def test_root_authorization_server_metadata_is_the_gateways_not_the_byok_connector_document(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Two features claim the root ``/.well-known/oauth-authorization-server``. The one a client
|
||||
gets must be the gateway's own authorization server metadata, whose ``/token`` serves the
|
||||
gateway's grants, not the BYOK connector form flow whose token endpoint is
|
||||
``/v1/mcp/oauth/token``; the connector keeps its path-appended document."""
|
||||
monkeypatch.setenv(FLAG, "false")
|
||||
app: Final = FastAPI()
|
||||
attach_lazy_features(app)
|
||||
|
||||
with TestClient(app, base_url="https://gateway.example.com") as client:
|
||||
root: Final = client.get("/.well-known/oauth-authorization-server")
|
||||
connector: Final = client.get("/.well-known/oauth-authorization-server/v1/mcp/oauth")
|
||||
|
||||
assert root.status_code == 200
|
||||
assert root.json()["issuer"] == "https://gateway.example.com"
|
||||
assert root.json()["token_endpoint"] == "https://gateway.example.com/token"
|
||||
assert connector.status_code == 200
|
||||
assert connector.json()["token_endpoint"] == "https://gateway.example.com/v1/mcp/oauth/token"
|
||||
|
|
|
|||
8
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
8
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -27480,8 +27480,10 @@ export interface components {
|
|||
};
|
||||
/** Body_token_endpoint__mcp_server_name__token_post */
|
||||
Body_token_endpoint__mcp_server_name__token_post: {
|
||||
/** Assertion */
|
||||
assertion?: string | null;
|
||||
/** Client Id */
|
||||
client_id: string;
|
||||
client_id?: string | null;
|
||||
/** Client Secret */
|
||||
client_secret?: string | null;
|
||||
/** Code */
|
||||
|
|
@ -27507,8 +27509,10 @@ export interface components {
|
|||
};
|
||||
/** Body_token_endpoint_token_post */
|
||||
Body_token_endpoint_token_post: {
|
||||
/** Assertion */
|
||||
assertion?: string | null;
|
||||
/** Client Id */
|
||||
client_id: string;
|
||||
client_id?: string | null;
|
||||
/** Client Secret */
|
||||
client_secret?: string | null;
|
||||
/** Code */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue