From bca7650e8f6612375496797c805eb464cb2633bc Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Fri, 16 Oct 2026 12:16:55 -0700 Subject: [PATCH] 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> --- .../mcp_server/auth/user_api_key_auth_mcp.py | 4 +- .../mcp_server/discoverable_endpoints.py | 56 +- .../mcp_server/gateway_dcr_flow.py | 162 +++- .../_experimental/mcp_server/id_jag_grant.py | 540 +++++++++++++ .../mcp_server/idp_token_exchange.py | 41 +- .../_experimental/mcp_server/oauth_utils.py | 5 + .../mcp_server/proxy_api_credentials.py | 18 +- litellm/proxy/_lazy_features.py | 19 +- litellm/proxy/_lazy_openapi_snapshot.json | 86 +- litellm/proxy/_types.py | 25 + litellm/proxy/auth/auth_checks.py | 4 + litellm/proxy/auth/handle_jwt.py | 96 ++- litellm/proxy/auth/route_checks.py | 11 +- .../auth/test_user_api_key_auth_mcp.py | 117 +++ .../mcp_server/test_discoverable_endpoints.py | 218 +++++- .../mcp_server/test_gateway_dcr_flow.py | 204 ++++- .../mcp_server/test_id_jag_grant.py | 738 ++++++++++++++++++ .../mcp_server/test_proxy_api_credentials.py | 89 ++- tests/unit/proxy/auth/test_handle_jwt.py | 198 +++++ tests/unit/proxy/test__lazy_features.py | 22 + ui/litellm-dashboard/src/lib/http/schema.d.ts | 8 +- 21 files changed, 2535 insertions(+), 126 deletions(-) create mode 100644 litellm/proxy/_experimental/mcp_server/id_jag_grant.py create mode 100644 tests/unit/proxy/_experimental/mcp_server/test_id_jag_grant.py diff --git a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py index 9c8890efd2b..4090666e16f 100644 --- a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py +++ b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py @@ -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 diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py index 6e5b5486696..449adaa73e9 100644 --- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -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" diff --git a/litellm/proxy/_experimental/mcp_server/gateway_dcr_flow.py b/litellm/proxy/_experimental/mcp_server/gateway_dcr_flow.py index dd8c82a99fe..90289fd4c20 100644 --- a/litellm/proxy/_experimental/mcp_server/gateway_dcr_flow.py +++ b/litellm/proxy/_experimental/mcp_server/gateway_dcr_flow.py @@ -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, diff --git a/litellm/proxy/_experimental/mcp_server/id_jag_grant.py b/litellm/proxy/_experimental/mcp_server/id_jag_grant.py new file mode 100644 index 00000000000..155d5280f5a --- /dev/null +++ b/litellm/proxy/_experimental/mcp_server/id_jag_grant.py @@ -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) diff --git a/litellm/proxy/_experimental/mcp_server/idp_token_exchange.py b/litellm/proxy/_experimental/mcp_server/idp_token_exchange.py index 15632eb4783..5044a5bd3dc 100644 --- a/litellm/proxy/_experimental/mcp_server/idp_token_exchange.py +++ b/litellm/proxy/_experimental/mcp_server/idp_token_exchange.py @@ -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) diff --git a/litellm/proxy/_experimental/mcp_server/oauth_utils.py b/litellm/proxy/_experimental/mcp_server/oauth_utils.py index b0448a62c9e..69732186d75 100644 --- a/litellm/proxy/_experimental/mcp_server/oauth_utils.py +++ b/litellm/proxy/_experimental/mcp_server/oauth_utils.py @@ -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() diff --git a/litellm/proxy/_experimental/mcp_server/proxy_api_credentials.py b/litellm/proxy/_experimental/mcp_server/proxy_api_credentials.py index 2f7fcaef645..fd637a2f187 100644 --- a/litellm/proxy/_experimental/mcp_server/proxy_api_credentials.py +++ b/litellm/proxy/_experimental/mcp_server/proxy_api_credentials.py @@ -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, diff --git a/litellm/proxy/_lazy_features.py b/litellm/proxy/_lazy_features.py index a909a7a993a..a7adb5c1bf5 100644 --- a/litellm/proxy/_lazy_features.py +++ b/litellm/proxy/_lazy_features.py @@ -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", diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 20819a59a43..2eb62a07447 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -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", diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index d62c6b0d51f..2c98cfeb33d 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -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: diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 8b32eb450e8..b395a826625 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -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) diff --git a/litellm/proxy/auth/handle_jwt.py b/litellm/proxy/auth/handle_jwt.py index 2421eb10c41..05b8cca19af 100644 --- a/litellm/proxy/auth/handle_jwt.py +++ b/litellm/proxy/auth/handle_jwt.py @@ -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) diff --git a/litellm/proxy/auth/route_checks.py b/litellm/proxy/auth/route_checks.py index ae129ca47b5..8dab693cd58 100644 --- a/litellm/proxy/auth/route_checks.py +++ b/litellm/proxy/auth/route_checks.py @@ -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, ): diff --git a/tests/unit/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py b/tests/unit/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py index c96e759b1a3..c4414003ca5 100644 --- a/tests/unit/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py +++ b/tests/unit/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py @@ -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 ()) diff --git a/tests/unit/proxy/_experimental/mcp_server/test_discoverable_endpoints.py b/tests/unit/proxy/_experimental/mcp_server/test_discoverable_endpoints.py index f09409a023d..5fdc7669496 100644 --- a/tests/unit/proxy/_experimental/mcp_server/test_discoverable_endpoints.py +++ b/tests/unit/proxy/_experimental/mcp_server/test_discoverable_endpoints.py @@ -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"] diff --git a/tests/unit/proxy/_experimental/mcp_server/test_gateway_dcr_flow.py b/tests/unit/proxy/_experimental/mcp_server/test_gateway_dcr_flow.py index 1417e77c8e2..ef95df23ff8 100644 --- a/tests/unit/proxy/_experimental/mcp_server/test_gateway_dcr_flow.py +++ b/tests/unit/proxy/_experimental/mcp_server/test_gateway_dcr_flow.py @@ -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 diff --git a/tests/unit/proxy/_experimental/mcp_server/test_id_jag_grant.py b/tests/unit/proxy/_experimental/mcp_server/test_id_jag_grant.py new file mode 100644 index 00000000000..fdfa0544ad1 --- /dev/null +++ b/tests/unit/proxy/_experimental/mcp_server/test_id_jag_grant.py @@ -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" diff --git a/tests/unit/proxy/_experimental/mcp_server/test_proxy_api_credentials.py b/tests/unit/proxy/_experimental/mcp_server/test_proxy_api_credentials.py index 8484dfdde72..f39aec82c3e 100644 --- a/tests/unit/proxy/_experimental/mcp_server/test_proxy_api_credentials.py +++ b/tests/unit/proxy/_experimental/mcp_server/test_proxy_api_credentials.py @@ -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 diff --git a/tests/unit/proxy/auth/test_handle_jwt.py b/tests/unit/proxy/auth/test_handle_jwt.py index 69eb01cabcd..e4a3935a58d 100644 --- a/tests/unit/proxy/auth/test_handle_jwt.py +++ b/tests/unit/proxy/auth/test_handle_jwt.py @@ -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 diff --git a/tests/unit/proxy/test__lazy_features.py b/tests/unit/proxy/test__lazy_features.py index 4e0889fd93d..adc4de8c8fa 100644 --- a/tests/unit/proxy/test__lazy_features.py +++ b/tests/unit/proxy/test__lazy_features.py @@ -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" diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 6f6eae1f98e..05a4893ad7f 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -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 */