diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260526120000_add_oauth_passthrough_to_mcp_servers/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260526120000_add_oauth_passthrough_to_mcp_servers/migration.sql new file mode 100644 index 00000000000..3c387891a5e --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260526120000_add_oauth_passthrough_to_mcp_servers/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN IF NOT EXISTS "oauth_passthrough" BOOLEAN NOT NULL DEFAULT false; diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index 78143fe0411..c4754ef6117 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -325,6 +325,7 @@ model LiteLLM_MCPServerTable { allow_all_keys Boolean @default(false) available_on_public_internet Boolean @default(true) delegate_auth_to_upstream Boolean @default(false) + oauth_passthrough Boolean @default(false) is_byok Boolean @default(false) byok_description String[] @default([]) byok_api_key_help_url String? diff --git a/litellm/experimental_mcp_client/client.py b/litellm/experimental_mcp_client/client.py index 0dc56b6a3bc..7559fe142c4 100644 --- a/litellm/experimental_mcp_client/client.py +++ b/litellm/experimental_mcp_client/client.py @@ -421,8 +421,16 @@ def factory( return factory - async def list_tools(self) -> List[MCPTool]: - """List available tools from the server.""" + async def list_tools(self, raise_on_error: bool = False) -> List[MCPTool]: + """List available tools from the server. + + Args: + raise_on_error: When True, re-raise exceptions instead of returning + an empty list. Used by the proxy's pass-through MCP flow so it + can surface upstream HTTP 401 responses as a proper 401 to the + MCP client (triggering the upstream OAuth flow) rather than + masking them as "connected, no tools". + """ verbose_logger.debug( f"MCP client listing tools from {self.server_url or 'stdio'}" ) @@ -458,6 +466,8 @@ async def _list_tools_operation(session: ClientSession): "the MCP server may have crashed, disconnected, or timed out" ) + if raise_on_error: + raise # Return empty list instead of raising to allow graceful degradation return [] 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 2aacab80f57..863e6acd41e 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 @@ -7,6 +7,7 @@ from starlette.types import Scope from litellm._logging import verbose_logger +from litellm.constants import DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL from litellm.proxy._types import ( LiteLLM_TeamTable, ProxyException, @@ -14,6 +15,88 @@ UserAPIKeyAuth, ) from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.auth.ip_address_utils import IPAddressUtils + + +def _parse_mcp_server_names_from_path( + path: str, mcp_servers_header: Optional[List[str]] = None +) -> Optional[List[str]]: + """Resolve the single MCP server name a cold-start passthrough bypass may + target. Delegates parsing to + :meth:`MCPRequestHandler._extract_target_server_names_from_path` so the + names used here always match the names downstream routing uses; returns + ``None`` whenever the bypass must not activate (aggregate ``/mcp``, + multi-server CSV paths, or any other unrecognized path). + + Also fails closed when the ``x-mcp-servers`` header introduces any server + not present in the path-derived target set. Downstream routing for + ``/mcp/...`` paths overrides the header with path-derived names, but a + header/path mismatch here is a sign of a confused or hostile caller — + refuse the cold-start bypass rather than admit anonymously based on the + path while the header advertises a stricter, non-passthrough target.""" + servers = MCPRequestHandler._extract_target_server_names_from_path(path) + if len(servers) != 1: + verbose_logger.debug( + "MCP cold-start: path %r resolved to %r; passthrough 401 bypass " + "requires exactly one target and will not activate", + path, + servers, + ) + return None + if mcp_servers_header is not None and (set(mcp_servers_header) - set(servers)): + verbose_logger.debug( + "MCP cold-start: x-mcp-servers header %r introduces target(s) not " + "in path-derived set %r; passthrough 401 bypass will not activate", + mcp_servers_header, + servers, + ) + return None + return servers + + +def _is_mcp_passthrough_cold_start( + mcp_servers: Optional[List[str]], client_ip: Optional[str] +) -> bool: + """True only when EVERY targeted server is a pass-through server with no + auth headers — the cold-start OAuth discovery case per RFC 9728 / MCP + Authorization spec. Lets the route handler's 401 emitter produce the + spec-compliant WWW-Authenticate challenge instead of surfacing a generic + admission error. + + Uses "all" semantics (mirrors :meth:`MCPRequestHandler._target_servers_use_oauth2`): + one non-passthrough target in a co-targeted set must not flip the bypass + open for the others. Fails closed when any target cannot be resolved.""" + if not mcp_servers: + return False + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + + for name in mcp_servers: + server = global_mcp_server_manager.get_mcp_server_by_name( + name, client_ip=client_ip + ) + if server is None or not getattr(server, "is_oauth_passthrough", False): + return False + return True + + +def _is_litellm_auth_admission_error(exc: Exception) -> bool: + if isinstance(exc, HTTPException): + return exc.status_code == 401 + if isinstance(exc, ProxyException): + try: + return int(exc.code) == 401 + except (TypeError, ValueError): + return False + return False + + +def _has_client_supplied_mcp_auth( + mcp_auth_header: Optional[str], + mcp_server_auth_headers: Optional[Dict[str, Dict[str, str]]], +) -> bool: + return bool(mcp_auth_header) or bool(mcp_server_auth_headers) class MCPRequestHandler: @@ -37,7 +120,7 @@ class MCPRequestHandler: LITELLM_MCP_ACCESS_GROUPS_HEADER_NAME = SpecialHeaders.mcp_access_groups.value @staticmethod - async def process_mcp_request( + async def process_mcp_request( # noqa: PLR0915 scope: Scope, ) -> Tuple[ UserAPIKeyAuth, @@ -130,7 +213,9 @@ async def mock_body(): elif ( not litellm_api_key and MCPRequestHandler._target_servers_delegate_auth_to_upstream( # noqa: E501 - path=request_route, mcp_servers=mcp_servers + path=request_route, + mcp_servers=mcp_servers, + client_ip=IPAddressUtils.get_mcp_client_ip(request), ) ): # Operator opted this oauth2 server into upstream-delegated auth @@ -172,25 +257,87 @@ async def mock_body(): # than coercing (``int("None")`` would raise ValueError and # rewrite the auth error as a 500). status = e.status_code if isinstance(e, HTTPException) else e.code - if status in ( - 401, - 403, - "401", - "403", - ) and MCPRequestHandler._target_servers_use_oauth2( - path=request_route, mcp_servers=mcp_servers + is_auth_error = status in (401, 403, "401", "403") + is_unauthenticated = status in (401, "401") + client_ip = IPAddressUtils.get_mcp_client_ip(request) + if is_auth_error and MCPRequestHandler._target_servers_use_oauth2( + path=request_route, + mcp_servers=mcp_servers, + client_ip=client_ip, ): verbose_logger.debug( "MCP OAuth2: target server is OAuth2-mode, treating " "Authorization as upstream OAuth2 token passthrough" ) validated_user_api_key_auth = UserAPIKeyAuth() + elif is_unauthenticated: + # Pass-through cold-start return: per RFC 9728 / MCP + # Authorization spec the client completes upstream OAuth + # discovery and returns with ``Authorization: Bearer + # ``. For ``auth_type=none`` passthrough + # servers that bearer is not a LiteLLM key (auth above + # failed) but is meant to be forwarded upstream + # unchanged. Fall back to anonymous admission so the + # caller is not rejected for following the discovery + # flow without also setting ``x-litellm-api-key``. + # Only trigger on 401 (token unrecognized); a 403 means + # the key WAS recognized but is forbidden (e.g. over + # budget / rate limited) and must propagate so those + # controls are not bypassed via anonymous admission. + mcp_servers_from_path = _parse_mcp_server_names_from_path( + request_route, mcp_servers + ) + if ( + mcp_servers_from_path is not None + and not _has_client_supplied_mcp_auth( + mcp_auth_header, + mcp_server_auth_headers, + ) + and _is_mcp_passthrough_cold_start( + mcp_servers_from_path, client_ip=client_ip + ) + ): + verbose_logger.debug( + "MCP pass-through return: target server is " + "passthrough, treating Authorization as " + "upstream OAuth token for delegated auth" + ) + validated_user_api_key_auth = UserAPIKeyAuth() + else: + raise else: raise else: - validated_user_api_key_auth = await user_api_key_auth( - api_key=litellm_api_key, request=request - ) + try: + validated_user_api_key_auth = await user_api_key_auth( + api_key=litellm_api_key, request=request + ) + except (HTTPException, ProxyException) as exc: + # Cold-start MCP OAuth discovery: RFC 9728 / MCP Authorization spec + # require unauthenticated requests to protected resources to receive + # 401 + WWW-Authenticate. Defer to _raise_preemptive_401_for_unauthenticated_servers + # for pass-through servers instead of surfacing a generic admission error. + mcp_servers_from_path = _parse_mcp_server_names_from_path( + request_route, mcp_servers + ) + client_ip = IPAddressUtils.get_mcp_client_ip(request) + if ( + mcp_servers_from_path is not None + and not _has_client_supplied_mcp_auth( + mcp_auth_header, + mcp_server_auth_headers, + ) + and _is_litellm_auth_admission_error(exc) + and _is_mcp_passthrough_cold_start( + mcp_servers_from_path, client_ip=client_ip + ) + ): + verbose_logger.debug( + "MCP pass-through cold start: deferring admission to route 401 emitter" + ) + validated_user_api_key_auth = UserAPIKeyAuth() + else: + raise return ( validated_user_api_key_auth, @@ -262,7 +409,9 @@ def _extract_target_server_names_from_path(path: str) -> List[str]: return [servers_and_path] @staticmethod - def _target_servers_use_oauth2(path: str, mcp_servers: Optional[List[str]]) -> bool: + def _target_servers_use_oauth2( + path: str, mcp_servers: Optional[List[str]], client_ip: Optional[str] + ) -> bool: """ True only when EVERY MCP server the request targets is configured for ``auth_type == oauth2``. If any target is non-OAuth2 — or if the target @@ -291,14 +440,16 @@ def _target_servers_use_oauth2(path: str, mcp_servers: Optional[List[str]]) -> b return False for name in target_names: - server = global_mcp_server_manager.get_mcp_server_by_name(name) + server = global_mcp_server_manager.get_mcp_server_by_name( + name, client_ip=client_ip + ) if server is None or server.auth_type != MCPAuth.oauth2: return False return True @staticmethod def _target_servers_delegate_auth_to_upstream( - path: str, mcp_servers: Optional[List[str]] + path: str, mcp_servers: Optional[List[str]], client_ip: Optional[str] ) -> bool: """ True only when EVERY MCP server the request targets is configured for @@ -328,7 +479,9 @@ def _target_servers_delegate_auth_to_upstream( return False for name in target_names: - server = global_mcp_server_manager.get_mcp_server_by_name(name) + server = global_mcp_server_manager.get_mcp_server_by_name( + name, client_ip=client_ip + ) if server is None or server.auth_type != MCPAuth.oauth2: return False # `is True` is intentional: opt-in must be an explicit boolean @@ -1090,22 +1243,21 @@ async def _get_allowed_mcp_servers_for_team( ) return [] - # Sentinel stored in cache when an org has no object_permission, so we - # don't re-query the DB on every MCP request for that org. - _ORG_NO_PERMISSION_SENTINEL = "__org_no_mcp_permission__" - @staticmethod async def _get_org_object_permission( user_api_key_auth: Optional[UserAPIKeyAuth] = None, ): """ - Get org object_permission, using user_api_key_cache to avoid DB hits on every request. - - Caches both positive results and the absence of an object_permission so that orgs - with no MCP permissions configured (the common default) do not trigger a DB query - on every request. + Get org object_permission via the established ``get_org_object`` / + ``get_object_permission`` helpers so MCP requests share the same + ``user_api_key_cache`` entries as the rest of the proxy. """ - from litellm.proxy.proxy_server import prisma_client, user_api_key_cache + from litellm.proxy.auth.auth_checks import get_object_permission, get_org_object + from litellm.proxy.proxy_server import ( + prisma_client, + proxy_logging_obj, + user_api_key_cache, + ) if not user_api_key_auth or not user_api_key_auth.org_id: return None @@ -1114,45 +1266,25 @@ async def _get_org_object_permission( verbose_logger.debug("prisma_client is None") return None - org_id = user_api_key_auth.org_id - cache_key = f"org_object_permission:{org_id}" - - from litellm.proxy._types import LiteLLM_ObjectPermissionTable - try: - cached = await user_api_key_cache.async_get_cache(key=cache_key) - if cached is not None: - # Sentinel means the DB confirmed no object_permission for this org - if cached == MCPRequestHandler._ORG_NO_PERMISSION_SENTINEL: - return None - # Redis deserialises to a plain dict; reconstruct the Pydantic model - # so callers can access .mcp_servers / .mcp_tool_permissions as attrs. - if isinstance(cached, dict): - return LiteLLM_ObjectPermissionTable(**cached) - return cached - - org_row = await prisma_client.db.litellm_organizationtable.find_unique( - where={"organization_id": org_id}, - include={"object_permission": True}, + org_obj = await get_org_object( + org_id=user_api_key_auth.org_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=user_api_key_auth.parent_otel_span, + proxy_logging_obj=proxy_logging_obj, ) - if org_row is None or org_row.object_permission is None: - # Cache the negative result so subsequent calls skip the DB - await user_api_key_cache.async_set_cache( - key=cache_key, - value=MCPRequestHandler._ORG_NO_PERMISSION_SENTINEL, - ) + if org_obj is None or not org_obj.object_permission_id: return None - # Convert raw Prisma model → Pydantic before caching. Caching the - # Pydantic .dict() ensures the value survives a Redis JSON round-trip - # as a plain dict that we can reconstruct above (same pattern used by - # get_end_user_object / get_team_object in auth_checks.py). - obj_perm = LiteLLM_ObjectPermissionTable(**org_row.object_permission.dict()) - await user_api_key_cache.async_set_cache( - key=cache_key, value=obj_perm.dict() + return await get_object_permission( + object_permission_id=org_obj.object_permission_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=user_api_key_auth.parent_otel_span, + proxy_logging_obj=proxy_logging_obj, ) - return obj_perm except Exception as e: verbose_logger.warning(f"Failed to get org object permission: {str(e)}") return None @@ -1273,16 +1405,26 @@ async def _get_allowed_mcp_servers_for_end_user( ) return [] + # Sentinel stored in cache when an agent has no object_permission, so we + # don't re-query the DB on every MCP request for that agent. + _AGENT_NO_PERMISSION_SENTINEL = "__agent_no_mcp_permission__" + @staticmethod async def _get_agent_object_permission( user_api_key_auth: Optional[UserAPIKeyAuth] = None, ): """ - Fetch the agent's object_permission from the DB (single query). - - Returns the object_permission object or None. + Get agent object_permission via the established ``get_object_permission`` + helper. Caches the ``agent_id -> object_permission_id`` mapping so we + avoid re-reading the agent row on every request, and reuses the shared + ``object_permission_id`` cache populated by the org / team / key paths. """ - from litellm.proxy.proxy_server import prisma_client + from litellm.proxy.auth.auth_checks import get_object_permission + from litellm.proxy.proxy_server import ( + prisma_client, + proxy_logging_obj, + user_api_key_cache, + ) if not user_api_key_auth or not user_api_key_auth.agent_id: return None @@ -1291,15 +1433,42 @@ async def _get_agent_object_permission( verbose_logger.debug("prisma_client is None") return None + agent_id = user_api_key_auth.agent_id + cache_key = f"agent_object_permission_id:{agent_id}" + try: - agent_row = await prisma_client.db.litellm_agentstable.find_unique( - where={"agent_id": user_api_key_auth.agent_id}, - include={"object_permission": True}, + object_permission_id: Optional[str] = ( + await user_api_key_cache.async_get_cache(key=cache_key) ) - if agent_row is None or agent_row.object_permission is None: + + if object_permission_id == MCPRequestHandler._AGENT_NO_PERMISSION_SENTINEL: return None - return agent_row.object_permission + if object_permission_id is None: + agent_row = await prisma_client.db.litellm_agentstable.find_unique( + where={"agent_id": agent_id}, + ) + object_permission_id = ( + getattr(agent_row, "object_permission_id", None) + if agent_row is not None + else None + ) + await user_api_key_cache.async_set_cache( + key=cache_key, + value=object_permission_id + or MCPRequestHandler._AGENT_NO_PERMISSION_SENTINEL, + ttl=DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL, + ) + if not object_permission_id: + return None + + return await get_object_permission( + object_permission_id=object_permission_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=user_api_key_auth.parent_otel_span, + proxy_logging_obj=proxy_logging_obj, + ) except Exception as e: verbose_logger.warning(f"Failed to get agent object permission: {str(e)}") return None diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py index 8324ba641a4..ed374635fea 100644 --- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -1,8 +1,11 @@ +import asyncio import html as _html import json -from typing import Any, Dict, Optional +import time +from typing import Any, Dict, Optional, Tuple from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse +import httpx from fastapi import APIRouter, Form, HTTPException, Request from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse @@ -26,11 +29,54 @@ from litellm.types.mcp import MCPAuth from litellm.types.mcp_server.mcp_server_manager import MCPServer +# TTL cache for upstream OAuth metadata fetched from pass-through MCP servers. +# Keeps us from hammering the upstream IdP on each discovery request. +# Keyed by (server_id, resource_url) → (expires_at_epoch, payload). +# A payload of ``None`` is a negative-result entry that prevents repeated +# upstream fetches when the IdP consistently has no metadata to serve. +_OAUTH_METADATA_CACHE: Dict[Tuple[str, str], Tuple[float, Optional[dict]]] = {} +_OAUTH_METADATA_CACHE_TTL_SECONDS = 300 +_OAUTH_METADATA_NEGATIVE_CACHE_TTL_SECONDS = 60 +_OAUTH_METADATA_CACHE_MAX_SIZE = 128 +# Per-(server_id, resource_url) async locks so concurrent discovery requests +# coalesce onto a single upstream fetch instead of issuing N parallel calls. +_OAUTH_METADATA_FETCH_LOCKS: Dict[Tuple[str, str], asyncio.Lock] = {} + router = APIRouter( tags=["mcp"], ) +def _prune_oauth_metadata_cache(now: Optional[float] = None) -> None: + now = now if now is not None else time.time() + expired_cache_keys = [ + cache_key + for cache_key, (expires_at, _payload) in _OAUTH_METADATA_CACHE.items() + if expires_at <= now + ] + for cache_key in expired_cache_keys: + _OAUTH_METADATA_CACHE.pop(cache_key, None) + + if len(_OAUTH_METADATA_CACHE) > _OAUTH_METADATA_CACHE_MAX_SIZE: + overflow = len(_OAUTH_METADATA_CACHE) - _OAUTH_METADATA_CACHE_MAX_SIZE + cache_keys_by_expiry = sorted( + _OAUTH_METADATA_CACHE, + key=lambda cache_key: _OAUTH_METADATA_CACHE[cache_key][0], + ) + for cache_key in cache_keys_by_expiry[:overflow]: + _OAUTH_METADATA_CACHE.pop(cache_key, None) + + # Drop locks whose cache entry has been evicted and that aren't currently + # held; held locks stay so in-flight callers continue to coalesce. + for cache_key in list(_OAUTH_METADATA_FETCH_LOCKS): + if cache_key in _OAUTH_METADATA_CACHE: + continue + lock = _OAUTH_METADATA_FETCH_LOCKS.get(cache_key) + if lock is None or lock.locked(): + continue + _OAUTH_METADATA_FETCH_LOCKS.pop(cache_key, None) + + def encode_state_with_base_url( base_url: str, original_state: str, @@ -125,6 +171,17 @@ def _resolve_oauth2_server_for_root_endpoints( return None +def _normalize_for_token_comparison(value: Any) -> str: + """Stringify ``value`` for token-rule comparison. + + Booleans are lower-cased so Python's ``True`` / ``False`` line up with + JSON-style ``"true"`` / ``"false"`` rules from admin config. + """ + if isinstance(value, bool): + return "true" if value else "false" + return str(value) + + def _validate_token_response( token_response: Dict[str, Any], validation_rules: Dict[str, Any], @@ -136,7 +193,9 @@ def _validate_token_response( ``token_response["team"]["enterprise_id"]``). Top-level keys are tried first, then dot-split traversal. All comparisons are string-coerced so that numeric values in the response (e.g. ``"org_id": 12345``) match string rules - (``"org_id": "12345"``). + (``"org_id": "12345"``). Booleans are normalised to JSON-style ``"true"`` / + ``"false"`` so admin rules written as ``{"verified": "true"}`` match upstream + responses of ``{"verified": true}``. """ for key, expected in validation_rules.items(): actual: Any = token_response.get(key) @@ -163,7 +222,9 @@ def _validate_token_response( ), }, ) - if str(actual) != str(expected): + if _normalize_for_token_comparison(actual) != _normalize_for_token_comparison( + expected + ): raise HTTPException( status_code=403, detail={ @@ -400,6 +461,11 @@ async def exchange_token_with_server( headers={"Accept": "application/json"}, data=token_data, ) + if response is None: + raise HTTPException( + status_code=502, + detail="MCP upstream token endpoint returned no response", + ) response.raise_for_status() token_response = response.json() @@ -505,6 +571,11 @@ async def register_client_with_server( headers=headers, json=register_data, ) + if response is None: + raise HTTPException( + status_code=502, + detail="MCP upstream registration endpoint returned no response", + ) response.raise_for_status() token_response = response.json() @@ -766,7 +837,119 @@ async def callback( """ -def _build_oauth_protected_resource_response( +async def fetch_upstream_oauth_protected_resource( + mcp_server: MCPServer, +) -> Optional[dict]: + """Fetch the upstream MCP server's ``.well-known/oauth-protected-resource`` + metadata for a pass-through server. + + Tries host-only first, then falls back to the RFC 9728 §3.1 path-suffix + form (e.g. ``https://host/.well-known/oauth-protected-resource/mcp``) to + cover upstreams that scope metadata per resource path. + + Responses are cached in-process for ~5 minutes keyed on + ``(server_id, resource_url)`` so we do not hammer the IdP. + + Returns the parsed JSON dict on success, or ``None`` if neither form + responds with a 2xx JSON payload. Raises on network/connection errors so + the caller can emit HTTP 502 rather than fabricate a gateway response. + """ + if not mcp_server.url: + return None + + upstream = urlparse(mcp_server.url) + if not upstream.scheme or not upstream.netloc: + return None + + cache_key = (mcp_server.server_id, mcp_server.url) + now = time.time() + _prune_oauth_metadata_cache(now) + cached = _OAUTH_METADATA_CACHE.get(cache_key) + if cached is not None and cached[0] > now: + return cached[1] + + lock = _OAUTH_METADATA_FETCH_LOCKS.setdefault(cache_key, asyncio.Lock()) + async with lock: + now = time.time() + cached = _OAUTH_METADATA_CACHE.get(cache_key) + if cached is not None and cached[0] > now: + return cached[1] + + host_base = f"{upstream.scheme}://{upstream.netloc}" + candidates = [f"{host_base}/.well-known/oauth-protected-resource"] + # RFC 9728 §3.1 path fallback + if upstream.path and upstream.path not in ("", "/"): + candidates.append( + f"{host_base}/.well-known/oauth-protected-resource" + f"{upstream.path.rstrip('/')}" + ) + + async_client = get_async_httpx_client( + llm_provider=httpxSpecialProvider.Oauth2Check + ) + + network_errors: list[Exception] = [] + for candidate in candidates: + try: + response = await async_client.get( + candidate, + headers={"Accept": "application/json"}, + ) + except Exception as exc: + if is_network_error(exc): + network_errors.append(exc) + else: + verbose_logger.warning( + "MCP OAuth metadata fetch for %s raised non-transport " + "%s: %s — treating as no metadata for this candidate", + candidate, + type(exc).__name__, + exc, + ) + continue + if response.status_code == 200: + try: + payload = response.json() + except Exception as exc: + verbose_logger.warning( + "MCP OAuth metadata at %s returned 200 but JSON " + "decode failed (%s: %s) — treating as no metadata", + candidate, + type(exc).__name__, + exc, + ) + continue + if isinstance(payload, dict): + now = time.time() + _OAUTH_METADATA_CACHE[cache_key] = ( + now + _OAUTH_METADATA_CACHE_TTL_SECONDS, + payload, + ) + _prune_oauth_metadata_cache(now) + return payload + + if len(network_errors) == len(candidates): + raise network_errors[-1] + + # Negative-result caching: when no candidate yielded a usable payload, + # remember that for a shorter TTL so we don't re-fetch on every + # subsequent discovery request (and so the per-key lock can be pruned). + now = time.time() + _OAUTH_METADATA_CACHE[cache_key] = ( + now + _OAUTH_METADATA_NEGATIVE_CACHE_TTL_SECONDS, + None, + ) + _prune_oauth_metadata_cache(now) + return None + + +def is_network_error(exc: Exception) -> bool: + """True for transport-layer failures (connection refused, DNS, TLS, timeout) + as opposed to HTTP protocol errors (4xx/5xx with a valid response).""" + return isinstance(exc, httpx.TransportError) + + +async def _build_oauth_protected_resource_response( request: Request, mcp_server_name: Optional[str], use_standard_pattern: bool, @@ -774,6 +957,12 @@ def _build_oauth_protected_resource_response( """ Build OAuth protected resource response with the appropriate URL pattern. + For pass-through MCP servers (``MCPServer.is_oauth_passthrough``), the + gateway proxies the upstream's own ``oauth-protected-resource`` metadata + so that standards-compliant MCP clients discover the **upstream** IdP + instead of the gateway. The ``resource`` field is rewritten to the + gateway's own URL so clients present the bearer token back to the gateway. + Args: request: FastAPI Request object mcp_server_name: Name of the MCP server @@ -813,6 +1002,46 @@ def _build_oauth_protected_resource_response( else: resource_url = f"{request_base_url}/mcp" + # Pass-through branch: proxy the upstream's own metadata so discovery + # directs the client at the real IdP (Okta, Keycloak, …) instead of us. + if mcp_server is not None and mcp_server.is_oauth_passthrough: + try: + upstream_metadata = await fetch_upstream_oauth_protected_resource( + mcp_server + ) + except Exception as exc: + verbose_logger.warning( + "Failed to fetch upstream oauth-protected-resource metadata " + f"for pass-through MCP server {mcp_server.name!r}: {exc}" + ) + raise HTTPException( + status_code=502, + detail=( + "Failed to fetch upstream oauth-protected-resource " + f"metadata for MCP server {mcp_server.name!r}" + ), + ) + + if upstream_metadata is not None: + response = {**upstream_metadata, "resource": resource_url} + return response + + # Upstream responded but with non-200 or non-dict payload. For + # pass-through servers the gateway is NOT the authorization server, + # so we must not fall through to the default gateway metadata — + # that would point clients at the wrong IdP. + verbose_logger.warning( + "Upstream oauth-protected-resource metadata unavailable for " + f"pass-through MCP server {mcp_server.name!r}" + ) + raise HTTPException( + status_code=502, + detail=( + "Upstream oauth-protected-resource metadata unavailable " + f"for MCP server {mcp_server.name!r}" + ), + ) + return { "authorization_servers": [ ( @@ -843,7 +1072,7 @@ async def oauth_protected_resource_mcp_standard(request: Request, mcp_server_nam This endpoint is compliant with MCP specification and works with standard MCP clients like mcp-inspector and VSCode Copilot. """ - return _build_oauth_protected_resource_response( + return await _build_oauth_protected_resource_response( request=request, mcp_server_name=mcp_server_name, use_standard_pattern=True, @@ -868,36 +1097,22 @@ async def oauth_protected_resource_mcp( This endpoint is kept for backward compatibility. New integrations should use the standard MCP pattern (/mcp/{server_name}) instead. """ - return _build_oauth_protected_resource_response( + return await _build_oauth_protected_resource_response( request=request, mcp_server_name=mcp_server_name, use_standard_pattern=False, ) -""" - https://datatracker.ietf.org/doc/html/rfc8414#section-3.1 - RFC 8414: Path-aware OAuth discovery - If the issuer identifier value contains a path component, any - terminating "/" MUST be removed before inserting "/.well-known/" and - the well-known URI suffix between the host component and the path(include root path) - component. -""" - - def _build_oauth_authorization_server_response( request: Request, mcp_server_name: Optional[str], ) -> dict: - """ - Build OAuth authorization server metadata response. + """Build OAuth authorization server metadata response (gateway-as-AS shape). - Args: - request: FastAPI Request object - mcp_server_name: Name of the MCP server - - Returns: - OAuth authorization server metadata dict + Synchronous because the body only does dict construction and synchronous + registry lookups; unlike :func:`_build_oauth_protected_resource_response` + it does not need to await any upstream IO. """ from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( global_mcp_server_manager, diff --git a/litellm/proxy/_experimental/mcp_server/exceptions.py b/litellm/proxy/_experimental/mcp_server/exceptions.py new file mode 100644 index 00000000000..fd8fc3d5e58 --- /dev/null +++ b/litellm/proxy/_experimental/mcp_server/exceptions.py @@ -0,0 +1,80 @@ +"""Exceptions raised by the LiteLLM MCP proxy.""" + +from typing import Optional + +from fastapi import HTTPException + + +class MCPUpstreamAuthError(Exception): + """Raised when an upstream MCP server returns an authentication failure + (typically HTTP 401) and the gateway should surface it transparently to + the client instead of swallowing it. + + Only relevant for pass-through MCP servers (see + ``MCPServer.is_oauth_passthrough``). The gateway converts this exception + into an HTTP 401 response on single-server routes, preserving any + ``WWW-Authenticate`` challenge emitted by the upstream so standards- + compliant MCP clients can trigger the upstream OAuth flow. + """ + + def __init__( + self, + status_code: int, + www_authenticate: Optional[str], + server_name: str, + ) -> None: + self.status_code = status_code + self.www_authenticate = www_authenticate + self.server_name = server_name + super().__init__(f"Upstream MCP server {server_name!r} returned {status_code}") + + def to_http_exception( + self, + base_url: Optional[str] = None, + request_path: Optional[str] = None, + ) -> HTTPException: + """Convert this upstream-auth error into an ``HTTPException`` that + preserves the upstream status code and any ``WWW-Authenticate`` + challenge, so standards-compliant MCP clients can trigger the + upstream OAuth flow. + + When the upstream 401 omits ``WWW-Authenticate`` (non-compliant per + RFC 7235 §3.1) we fabricate a ``Bearer resource_metadata=`` challenge + that points at the gateway's well-known endpoint for this server, so + MCP clients can still initiate RFC 9728 discovery against the upstream + IdP via the gateway's proxied metadata. Callers must pass ``base_url`` + (the gateway origin, no trailing slash) so the fabricated URI is + absolute as RFC 9728 §3.2 requires; if ``base_url`` is missing we + skip fabrication entirely rather than emit a relative URI that strict + clients reject in the Bearer challenge. + + When ``request_path`` is supplied and matches the legacy + ``/{server_name}/mcp`` MCP transport route, the fabricated URI uses + the matching legacy well-known form + ``/.well-known/oauth-protected-resource/{server_name}/mcp``. Otherwise + we default to the standard form + ``/.well-known/oauth-protected-resource/mcp/{server_name}``. This + keeps the ``resource_metadata`` URI aligned with the resource pattern + the client originally targeted, matching the path-aware behaviour of + ``_get_passthrough_resource_metadata_url`` in ``server.py``. + """ + challenge: Optional[str] = self.www_authenticate + if challenge is None and self.status_code == 401 and base_url: + prefix = base_url.rstrip("/") + if request_path and request_path.startswith(f"/{self.server_name}/mcp"): + resource_metadata_url = ( + f"{prefix}/.well-known/oauth-protected-resource/" + f"{self.server_name}/mcp" + ) + else: + resource_metadata_url = ( + f"{prefix}/.well-known/oauth-protected-resource/" + f"mcp/{self.server_name}" + ) + challenge = f'Bearer resource_metadata="{resource_metadata_url}"' + detail = "Forbidden" if self.status_code == 403 else "Unauthorized" + return HTTPException( + status_code=self.status_code, + detail=detail, + headers={"www-authenticate": challenge} if challenge else None, + ) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index f35aa30a7c9..85a1191b348 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -48,6 +48,7 @@ from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import ( MCPRequestHandler, ) +from litellm.proxy._experimental.mcp_server.exceptions import MCPUpstreamAuthError from litellm.proxy._experimental.mcp_server.oauth2_token_cache import resolve_mcp_auth from litellm.proxy._experimental.mcp_server.utils import ( MCP_TOOL_PREFIX_SEPARATOR, @@ -118,6 +119,103 @@ def validate_tool_name(name: str) -> _ToolNameValidationResult: # type: ignore[ } +def _should_strip_caller_authorization( + mcp_server: MCPServer, + raw_headers: Optional[Dict[str, str]], + user_api_key_auth: Optional[UserAPIKeyAuth], +) -> bool: + """Decide whether the caller's ``Authorization`` header must NOT be + forwarded upstream when populating ``extra_headers`` for an MCP server. + + Centralized so ``_call_regular_mcp_tool`` (this module) and + ``_prepare_mcp_server_headers`` (``server.py``) cannot drift apart on + this security-sensitive decision. + + Strip rules: + - **M2M (client_credentials) servers**: never forward the caller's + ``Authorization`` — the proxy fetches its own upstream token. + - **OAuth pass-through servers**: strip when the ``Authorization`` + header is actually the LiteLLM API key — either because admission + validated it (``user_api_key_auth.api_key`` is set) and the caller + did NOT also supply ``x-litellm-api-key`` to disambiguate, or + because the legacy ``user_api_key_auth is None`` call sites did + not supply an explicit admission header. In the anonymous / + pass-through cold-start case (RFC 9728) the bearer in + ``Authorization`` is the upstream OAuth token and must be + forwarded, so we keep it. + """ + if mcp_server.has_client_credentials: + return True + if not mcp_server.is_oauth_passthrough: + return False + + normalized_raw_headers = { + str(k).lower(): v for k, v in (raw_headers or {}).items() if isinstance(k, str) + } + has_explicit_litellm_admission_header = ( + normalized_raw_headers.get("x-litellm-api-key") is not None + ) + admission_consumed_authorization_as_litellm_key = ( + user_api_key_auth is not None + and bool(getattr(user_api_key_auth, "api_key", None)) + and not has_explicit_litellm_admission_header + ) + return admission_consumed_authorization_as_litellm_key or ( + user_api_key_auth is None and not has_explicit_litellm_admission_header + ) + + +def _extract_upstream_auth_failure( + exc: BaseException, +) -> Optional[Tuple[int, Optional[str]]]: + """Walk the exception tree looking for an HTTP 401/403 response from the + upstream MCP server. + + The MCP SDK wraps transport errors in anyio ``ExceptionGroup`` objects and + may chain through ``__cause__`` / ``__context__``. We inspect all of those + layers for an ``httpx.Response``-bearing exception (typically + ``httpx.HTTPStatusError``) and extract the status code and any upstream + ``WWW-Authenticate`` header. + + Returns ``(status_code, www_authenticate)`` on match, else ``None``. + """ + seen: Set[int] = set() + stack: List[BaseException] = [exc] + while stack: + current = stack.pop() + if id(current) in seen: + continue + seen.add(id(current)) + + response = getattr(current, "response", None) + if response is not None: + status_code = getattr(response, "status_code", None) + if isinstance(status_code, int) and status_code in (401, 403): + www_authenticate: Optional[str] = None + headers = getattr(response, "headers", None) + if headers is not None: + try: + www_authenticate = headers.get("www-authenticate") + except Exception: + www_authenticate = None + return status_code, www_authenticate + + # anyio / PEP 654 ExceptionGroup + sub_exceptions = getattr(current, "exceptions", None) + if sub_exceptions: + stack.extend(sub_exceptions) + + if current.__cause__ is not None: + stack.append(current.__cause__) + if ( + current.__context__ is not None + and current.__context__ is not current.__cause__ + ): + stack.append(current.__context__) + + return None + + def _warn_on_server_name_fields( *, server_id: str, @@ -483,6 +581,7 @@ async def load_servers_from_config( delegate_auth_to_upstream=bool( server_config.get("delegate_auth_to_upstream", False) ), + oauth_passthrough=bool(server_config.get("oauth_passthrough", False)), # AWS SigV4 fields aws_access_key_id=server_config.get("aws_access_key_id", None), aws_secret_access_key=server_config.get("aws_secret_access_key", None), @@ -881,6 +980,7 @@ async def build_mcp_server_from_table( delegate_auth_to_upstream=bool( getattr(mcp_server, "delegate_auth_to_upstream", False) ), + oauth_passthrough=bool(getattr(mcp_server, "oauth_passthrough", False)), created_at=getattr(mcp_server, "created_at", None), updated_at=getattr(mcp_server, "updated_at", None), tool_name_to_display_name=_deserialize_json_dict( @@ -1599,7 +1699,9 @@ async def _get_tools_from_server( ] return tools else: - tools = await self._fetch_tools_with_timeout(client, server.name) + tools = await self._fetch_tools_with_timeout( + client, server.name, server=server + ) self._remember_upstream_initialize_instructions(server, client) prefixed_or_original_tools = self._create_prefixed_tools( @@ -1608,6 +1710,11 @@ async def _get_tools_from_server( return prefixed_or_original_tools + except MCPUpstreamAuthError: + # Pass-through 401 must surface to single-server routes so the + # client triggers the upstream OAuth flow. The multi-server + # aggregator catches this explicitly to keep absorbing. + raise except Exception as e: verbose_logger.warning( f"Failed to get tools from server {server.name}: {str(e)}" @@ -2209,7 +2316,10 @@ def _extract_scopes(self, scopes_value: Any) -> Optional[List[str]]: return None async def _fetch_tools_with_timeout( - self, client: MCPClient, server_name: str + self, + client: MCPClient, + server_name: str, + server: Optional[MCPServer] = None, ) -> List[MCPTool]: """ Fetch tools from MCP client with timeout and error handling. @@ -2217,16 +2327,28 @@ async def _fetch_tools_with_timeout( Uses anyio.fail_after() instead of asyncio.wait_for() to avoid conflicts with the MCP SDK's anyio TaskGroup. See GitHub issue #20715 for details. + For pass-through MCP servers (``MCPServer.is_oauth_passthrough``) an + upstream HTTP 401 is converted into :class:`MCPUpstreamAuthError` + instead of being swallowed to an empty tool list. That lets the + single-server HTTP routes surface a proper 401 + ``WWW-Authenticate`` + challenge so standards-compliant MCP clients trigger the upstream + OAuth flow. Non-pass-through servers keep today's swallow-and-log + behaviour so the multi-server ``/mcp`` aggregator doesn't get + tainted by a single bad server. + Args: client: MCP client instance server_name: Name of the server for logging + server: Optional MCPServer; when pass-through, auth errors are + re-raised as :class:`MCPUpstreamAuthError`. Returns: List of tools from the server """ + is_passthrough = bool(server is not None and server.is_oauth_passthrough) try: with anyio.fail_after(MCP_TOOL_LISTING_TIMEOUT): - tools = await client.list_tools() + tools = await client.list_tools(raise_on_error=is_passthrough) verbose_logger.debug(f"Tools from {server_name}: {tools}") return tools except TimeoutError: @@ -2243,6 +2365,19 @@ async def _fetch_tools_with_timeout( ) return [] except Exception as e: + if is_passthrough: + auth_info = _extract_upstream_auth_failure(e) + if auth_info is not None: + status_code, www_authenticate = auth_info + verbose_logger.info( + f"Upstream auth failure from pass-through MCP server " + f"{server_name}: HTTP {status_code}" + ) + raise MCPUpstreamAuthError( + status_code=status_code, + www_authenticate=www_authenticate, + server_name=server_name, + ) from e verbose_logger.warning(f"Error listing tools from {server_name}: {str(e)}") return [] @@ -2762,6 +2897,7 @@ async def _call_regular_mcp_tool( # noqa: PLR0915 proxy_logging_obj: Optional[ProxyLogging], host_progress_callback: Optional[Callable] = None, hook_extra_headers: Optional[Dict[str, str]] = None, + user_api_key_auth: Optional[UserAPIKeyAuth] = None, ) -> CallToolResult: """ Call a regular MCP tool using the MCP client. @@ -2827,13 +2963,16 @@ async def _call_regular_mcp_tool( # noqa: PLR0915 normalized_raw_headers = { str(k).lower(): v for k, v in raw_headers.items() if isinstance(k, str) } + strip_caller_authorization = _should_strip_caller_authorization( + mcp_server=mcp_server, + raw_headers=raw_headers, + user_api_key_auth=user_api_key_auth, + ) + for header in mcp_server.extra_headers: if not isinstance(header, str): continue - if ( - mcp_server.has_client_credentials - and header.lower() == "authorization" - ): + if header.lower() == "authorization" and strip_caller_authorization: continue header_value = normalized_raw_headers.get(header.lower()) if header_value is None: @@ -3125,6 +3264,7 @@ async def call_tool( proxy_logging_obj=proxy_logging_obj, host_progress_callback=host_progress_callback, hook_extra_headers=hook_result.get("extra_headers"), + user_api_key_auth=user_api_key_auth, ) return await self._gather_openapi_tool_tasks(tasks, proxy_logging_obj) @@ -3156,7 +3296,23 @@ async def _initialize_tool_name_to_mcp_server_name_mapping(self): if server.needs_user_oauth_token: # Skip OAuth2 servers that rely on user-provided tokens continue - tools = await self._get_tools_from_server(server) + try: + tools = await self._get_tools_from_server(server) + except MCPUpstreamAuthError as e: + # Pass-through servers expect a user-supplied bearer token; + # at startup we have none, so an upstream 401 is normal. + # Swallow it so we keep mapping the remaining servers. + verbose_logger.debug( + f"Skipping tool name mapping for server {server.name} " + f"due to upstream auth error: {str(e)}" + ) + continue + except Exception as e: + verbose_logger.warning( + f"Failed to get tools from server {server.name} during " + f"tool name mapping initialization: {str(e)}" + ) + continue for tool in tools: # The tool.name here is already prefixed from _get_tools_from_server # Extract original name for mapping @@ -3748,6 +3904,7 @@ def _build_mcp_server_table(self, server: MCPServer) -> LiteLLM_MCPServerTable: allow_all_keys=server.allow_all_keys, available_on_public_internet=server.available_on_public_internet, delegate_auth_to_upstream=server.delegate_auth_to_upstream, + oauth_passthrough=getattr(server, "oauth_passthrough", False), is_byok=server.is_byok, byok_description=server.byok_description, byok_api_key_help_url=server.byok_api_key_help_url, diff --git a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py index cec5224e183..d8a9928231c 100644 --- a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py @@ -16,6 +16,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from litellm._logging import verbose_logger +from litellm.proxy._experimental.mcp_server.exceptions import MCPUpstreamAuthError from litellm.proxy._experimental.mcp_server.ui_session_utils import ( build_effective_auth_contexts, ) @@ -46,6 +47,9 @@ from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( global_mcp_server_manager, ) + from litellm.proxy._experimental.mcp_server.oauth_utils import ( + get_request_base_url, + ) from litellm.proxy._experimental.mcp_server.server import ( ListMCPToolsRestAPIResponseObject, MCPServer, @@ -424,101 +428,6 @@ async def _resolve_allowed_mcp_servers_for_tool_call( allowed_mcp_servers.append(server) return allowed_mcp_servers - async def _list_tools_for_single_server( - server_id: str, - allowed_server_ids: List[str], - rest_client_ip: Optional[str], - mcp_server_auth_headers: dict, - mcp_auth_header: Optional[str], - raw_headers_from_request: dict, - user_api_key_dict: "UserAPIKeyAuth", - ) -> dict: - """ - Resolve and fetch tools for a single specified MCP server. - - Returns the full REST response dict (tools / error / message). - Raises HTTPException on access / IP-filter errors. - """ - # Resolve a server name to its UUID if needed - _name_resolved = None - if server_id not in allowed_server_ids: - _name_resolved = global_mcp_server_manager.get_mcp_server_by_name(server_id) - if _name_resolved is not None and _name_resolved.server_id in set( - allowed_server_ids - ): - server_id = _name_resolved.server_id - - if server_id not in allowed_server_ids: - _server = ( - global_mcp_server_manager.get_mcp_server_by_id(server_id) - or _name_resolved - ) - if ( - _server is not None - and rest_client_ip is not None - and not global_mcp_server_manager._is_server_accessible_from_ip( - _server, rest_client_ip - ) - ): - raise HTTPException( - status_code=403, - detail={ - "error": "ip_filtering", - "message": ( - f"MCP server '{server_id}' is not accessible from your IP address " - f"({rest_client_ip}). This server is restricted to internal " - "networks only. To make it externally accessible, set " - "'available_on_public_internet: true' in the server configuration." - ), - }, - ) - raise HTTPException( - status_code=403, - detail={ - "error": "access_denied", - "message": f"The key is not allowed to access server {server_id}", - }, - ) - - server = global_mcp_server_manager.get_mcp_server_by_id(server_id) - if server is None: - return { - "tools": [], - "error": "server_not_found", - "message": f"Server with id {server_id} not found", - } - - server_auth_header = _get_server_auth_header( - server, mcp_server_auth_headers, mcp_auth_header - ) - user_oauth_extra_headers = await _get_user_oauth_extra_headers( - server, user_api_key_dict - ) - - try: - tools = await _get_tools_for_single_server( - server, - server_auth_header, - raw_headers_from_request, - user_api_key_dict, - extra_headers=user_oauth_extra_headers, - ) - except Exception as e: - verbose_logger.exception(f"Error getting tools from {server.name}: {e}") - return { - "tools": [], - "error": "server_error", - "message": f"Failed to get tools from server {server.name}: {str(e)}", - } - - return { - "tools": tools, - "error": None, - "message": "Successfully retrieved tools", - } - - ######################################################## - async def _list_tools_for_single_server( server_id: str, allowed_server_ids: List[str], @@ -592,6 +501,11 @@ async def _list_tools_for_single_server( user_api_key_dict, extra_headers=user_oauth_extra_headers, ) + except MCPUpstreamAuthError: + # Surface the upstream 401/403 to the caller so it can emit the + # matching status code and WWW-Authenticate challenge; that is what + # lets standards-compliant MCP clients run the upstream OAuth flow. + raise except Exception as e: verbose_logger.exception(f"Error getting tools from {server.name}: {e}") return { @@ -758,6 +672,24 @@ async def list_tool_rest_api( ), } + except MCPUpstreamAuthError as e: + # Surface upstream pass-through 401/403 challenges to the client so + # standards-compliant MCP clients can run the upstream OAuth flow. + raise e.to_http_exception( + base_url=get_request_base_url(request), + request_path=request.scope.get("_original_path") or request.url.path, + ) + except HTTPException as http_exc: + # Internal access/IP 403s keep the legacy error-dict response shape + # so the existing contract stays intact. + verbose_logger.exception( + "HTTPException in list_tool_rest_api: %s", str(http_exc) + ) + return { + "tools": [], + "error": "unexpected_error", + "message": (f"An unexpected error occurred: {http_exc.detail}"), + } except Exception as e: verbose_logger.exception( "Unexpected error in list_tool_rest_api: %s", str(e) diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index a05ce3f7417..c15f824c1f0 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -20,6 +20,7 @@ Dict, List, Optional, + Set, Tuple, Union, cast, @@ -38,6 +39,7 @@ from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import ( MCPRequestHandler, ) +from litellm.proxy._experimental.mcp_server.exceptions import MCPUpstreamAuthError from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( get_request_base_url, ) @@ -174,6 +176,7 @@ def _write_byok_cred_cache( ) from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( MCPServerManager, + _should_strip_caller_authorization, global_mcp_server_manager, ) from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import ( @@ -1080,6 +1083,42 @@ async def _get_allowed_mcp_servers( return allowed_mcp_servers + def _client_has_passthrough_authorization( + server: MCPServer, + oauth2_headers: Optional[Dict[str, str]], + mcp_server_auth_headers: Optional[Dict[str, Dict[str, str]]], + ) -> bool: + """True if the incoming request already carries an ``Authorization`` + header the gateway will forward to this pass-through server. + + The client may supply the bearer as either the top-level + ``Authorization`` header (surfaced via ``oauth2_headers``) or a + per-server ``x-mcp-auth-`` style header (surfaced via + ``mcp_server_auth_headers``). Either form skips the pre-emptive 401. + """ + if oauth2_headers: + for k in oauth2_headers.keys(): + if k.lower() == "authorization": + return True + if mcp_server_auth_headers: + for key in (server.alias, server.server_name, server.name): + if not key: + continue + server_headers = None + for k, v in mcp_server_auth_headers.items(): + if k.lower() == key.lower(): + server_headers = v + break + if server_headers is None: + continue + if isinstance(server_headers, str) and server_headers.strip(): + return True + if isinstance(server_headers, dict): + for hk in server_headers.keys(): + if hk.lower() == "authorization": + return True + return False + async def _get_user_oauth_extra_headers_from_db( server: MCPServer, user_api_key_auth: Optional[UserAPIKeyAuth], @@ -1260,6 +1299,7 @@ def _prepare_mcp_server_headers( mcp_auth_header: Optional[str], oauth2_headers: Optional[Dict[str, str]], raw_headers: Optional[Dict[str, str]], + user_api_key_auth: Optional[UserAPIKeyAuth] = None, ) -> Tuple[Optional[Union[Dict[str, str], str]], Optional[Dict[str, str]]]: """Build auth and extra headers for a server.""" server_auth_header: Optional[Union[Dict[str, str], str]] = None @@ -1292,10 +1332,20 @@ def _prepare_mcp_server_headers( str(k).lower(): v for k, v in raw_headers.items() if isinstance(k, str) } + # Centralized strip decision shared with + # ``MCPServerManager._call_regular_mcp_tool`` so the two + # code paths cannot drift on this security-sensitive choice. + # See ``_should_strip_caller_authorization`` for the rules. + strip_caller_authorization = _should_strip_caller_authorization( + mcp_server=server, + raw_headers=raw_headers, + user_api_key_auth=user_api_key_auth, + ) + for header in server.extra_headers: if not isinstance(header, str): continue - if server.has_client_credentials and header.lower() == "authorization": + if header.lower() == "authorization" and strip_caller_authorization: continue header_value = normalized_raw_headers.get(header.lower()) if header_value is None: @@ -1504,6 +1554,7 @@ async def _fetch_and_filter_server_tools( mcp_auth_header=mcp_auth_header, oauth2_headers=oauth2_headers, raw_headers=raw_headers, + user_api_key_auth=user_api_key_auth, ) # Prefer server-stored per-user OAuth when configured, so a stale @@ -1555,6 +1606,13 @@ async def _fetch_and_filter_server_tools( f"Successfully fetched {len(tools)} tools from server {server.name}, {len(filtered_tools)} after filtering" ) return filtered_tools + except MCPUpstreamAuthError: + # Surface upstream 401/403 to the outer handler so the + # client receives a proper WWW-Authenticate challenge + # instead of a silently empty tool list. Without this + # re-raise the broad ``except Exception`` below would + # swallow the auth error. + raise except Exception as e: verbose_logger.exception( f"Error getting tools from server {server.name}: {str(e)}" @@ -1678,6 +1736,7 @@ async def _get_prompts_from_mcp_servers( mcp_auth_header=mcp_auth_header, oauth2_headers=oauth2_headers, raw_headers=raw_headers, + user_api_key_auth=user_api_key_auth, ) try: @@ -1735,6 +1794,7 @@ async def _get_resources_from_mcp_servers( mcp_auth_header=mcp_auth_header, oauth2_headers=oauth2_headers, raw_headers=raw_headers, + user_api_key_auth=user_api_key_auth, ) try: @@ -1790,6 +1850,7 @@ async def _get_resource_templates_from_mcp_servers( mcp_auth_header=mcp_auth_header, oauth2_headers=oauth2_headers, raw_headers=raw_headers, + user_api_key_auth=user_api_key_auth, ) try: @@ -2625,6 +2686,7 @@ async def mcp_get_prompt( mcp_auth_header=mcp_auth_header, oauth2_headers=oauth2_headers, raw_headers=raw_headers, + user_api_key_auth=user_api_key_auth, ) return await global_mcp_server_manager.get_prompt_from_server( @@ -2675,6 +2737,7 @@ async def mcp_read_resource( mcp_auth_header=mcp_auth_header, oauth2_headers=oauth2_headers, raw_headers=raw_headers, + user_api_key_auth=user_api_key_auth, ) return await global_mcp_server_manager.read_resource_from_server( @@ -3130,6 +3193,117 @@ async def _apply_toolset_scope( ) return user_api_key_auth.model_copy(update={"object_permission": updated_op}) + def _get_passthrough_resource_metadata_url(scope: Scope, server_name: str) -> str: + request = StarletteRequest(scope) + base_url = get_request_base_url(request) + _path = scope.get("_original_path") or scope.get("path", "") or "" + + if _path.startswith(f"/{server_name}/mcp"): + return f"{base_url}/.well-known/oauth-protected-resource/{server_name}/mcp" + return f"{base_url}/.well-known/oauth-protected-resource/mcp/{server_name}" + + def _get_passthrough_www_authenticate( + scope: Scope, + server_name: str, + invalid_token: bool = False, + ) -> str: + resource_metadata_url = _get_passthrough_resource_metadata_url( + scope=scope, + server_name=server_name, + ) + params = [] + if invalid_token: + params.append('error="invalid_token"') + params.append(f'resource_metadata="{resource_metadata_url}"') + return "Bearer " + ", ".join(params) + + async def _raise_preemptive_401_for_unauthenticated_servers( + scope: Scope, + mcp_servers: Optional[List[str]], + oauth2_headers: Optional[Dict[str, str]], + mcp_server_auth_headers: Optional[Dict[str, Dict[str, str]]], + user_api_key_auth: Optional[UserAPIKeyAuth], + client_ip: Optional[str], + allowed_server_ids: Optional[Set[str]] = None, + ) -> None: + """Fail fast with HTTP 401 for MCP servers that need user auth but + didn't receive it on this request. Covers both gateway-managed OAuth2 + (points clients at the gateway AS metadata) and pass-through OAuth + (points clients at the upstream resource-metadata via our well-known). + + ``allowed_server_ids`` may be passed by callers that have already + narrowed the authorized server set (e.g. toolset scoping); servers + not in that set are skipped so a client targeting a toolset that + excludes a passthrough server is not pushed into an OAuth flow for + a server it will be 403'd on immediately after authentication. + """ + for server_name in mcp_servers or []: + server = global_mcp_server_manager.get_mcp_server_by_name( + server_name, client_ip=client_ip + ) + if ( + server is not None + and allowed_server_ids is not None + and server.server_id not in allowed_server_ids + ): + # Caller's narrowed scope excludes this server — skip the + # preemptive challenge and let downstream authorization + # return 403. + continue + if server and server.auth_type == MCPAuth.oauth2 and not oauth2_headers: + # For per-user OAuth servers, only skip the pre-emptive 401 when + # a stored token actually exists for this user+server pair. + # If no stored token exists, fail fast with 401 so clients can + # kick off PKCE/interactive OAuth flow immediately. + if server.needs_user_oauth_token: + stored_oauth_headers = await _get_user_oauth_extra_headers_from_db( + server=server, + user_api_key_auth=user_api_key_auth, + ) + if stored_oauth_headers: + continue + + request = StarletteRequest(scope) + base_url = get_request_base_url(request) + _path = scope.get("_original_path") or scope.get("path", "") or "" + + # Pick the well-known AS-metadata form that matches the inbound route + # so strict RFC 9728 §3.2 clients can resolve it correctly. + if _path.startswith(f"/mcp/{server_name}"): + _as_url = f"{base_url}/.well-known/oauth-authorization-server/mcp/{server_name}" + else: + _as_url = f"{base_url}/.well-known/oauth-authorization-server/{server_name}" + authorization_uri = f'Bearer authorization_uri="{_as_url}"' + + raise HTTPException( + status_code=401, + detail="Unauthorized", + headers={"www-authenticate": authorization_uri}, + ) + + # Pass-through OAuth: when the admin has opted a server into + # forwarding the client's bearer token (is_oauth_passthrough) and + # the client hasn't supplied one, fail fast with 401 and point + # them at the gateway's oauth-protected-resource well-known URL. + # That endpoint proxies the upstream's metadata so the client + # kicks off OAuth against the real upstream IdP, not the gateway. + if ( + server + and server.is_oauth_passthrough + and not _client_has_passthrough_authorization( + server, oauth2_headers, mcp_server_auth_headers + ) + ): + www_authenticate = _get_passthrough_www_authenticate( + scope=scope, + server_name=server_name, + ) + raise HTTPException( + status_code=401, + detail="Unauthorized", + headers={"www-authenticate": www_authenticate}, + ) + def _get_forwarded_auth_from_scope(scope: Scope) -> Optional[str]: """Return the upstream-bound ``Authorization`` header value, or None. @@ -3242,12 +3416,15 @@ async def _check_passthrough_upstream_auth( passthrough_servers = [ srv for srv in allowed_servers - if srv.extra_headers - and any(h.lower() == "authorization" for h in srv.extra_headers) - # Exclude M2M servers: _prepare_mcp_server_headers skips caller - # Authorization when has_client_credentials is set, so probing - # those with the caller's token would send the wrong credential. - and not srv.has_client_credentials + # Restrict to genuine OAuth pass-through servers (auth_type none + + # Authorization in extra_headers). Gateway-managed OAuth2 servers + # must not receive the ``resource_metadata=`` challenge emitted + # below — they require ``authorization_uri=`` pointing at the + # gateway AS metadata. ``is_oauth_passthrough`` already requires + # ``auth_type in (None, MCPAuth.none)``, which is mutually + # exclusive with ``has_client_credentials`` (oauth2 + M2M flow), + # so M2M servers are implicitly excluded here. + if srv.is_oauth_passthrough ] if not passthrough_servers: return @@ -3258,19 +3435,20 @@ async def _check_passthrough_upstream_auth( for srv in passthrough_servers ] ) - request = StarletteRequest(scope) - base_url = get_request_base_url(request) for srv, (probe_status, _) in zip(passthrough_servers, probe_results): if probe_status == 401: - # Token is missing or expired — direct the client to re-authorize. - authorization_uri = ( - f"Bearer authorization_uri=" - f"{base_url}/.well-known/oauth-authorization-server/{srv.name}" + # Token is missing or expired: keep pass-through clients on the + # protected-resource discovery flow so they re-authorize against + # the upstream IdP metadata proxied by LiteLLM. + www_authenticate = _get_passthrough_www_authenticate( + scope=scope, + server_name=srv.name, + invalid_token=True, ) raise HTTPException( status_code=401, detail="Unauthorized", - headers={"WWW-Authenticate": authorization_uri}, + headers={"www-authenticate": www_authenticate}, ) if probe_status == 403: # Token is valid but the caller lacks permission — do not hint @@ -3305,39 +3483,6 @@ async def handle_streamable_http_mcp( # noqa: PLR0915 verbose_logger.debug( f"MCP server auth headers: {list(mcp_server_auth_headers.keys()) if mcp_server_auth_headers else None}" ) - # https://datatracker.ietf.org/doc/html/rfc9728#name-www-authenticate-response - for server_name in mcp_servers or []: - server = global_mcp_server_manager.get_mcp_server_by_name( - server_name, client_ip=_client_ip - ) - if server and server.auth_type == MCPAuth.oauth2 and not oauth2_headers: - # For per-user OAuth servers, only skip the pre-emptive 401 when - # a stored token actually exists for this user+server pair. - # If no stored token exists, fail fast with 401 so clients can - # kick off PKCE/interactive OAuth flow immediately. - if server.needs_user_oauth_token: - stored_oauth_headers = ( - await _get_user_oauth_extra_headers_from_db( - server=server, - user_api_key_auth=user_api_key_auth, - ) - ) - if stored_oauth_headers: - continue - - request = StarletteRequest(scope) - base_url = get_request_base_url(request) - - authorization_uri = ( - f"Bearer authorization_uri=" - f"{base_url}/.well-known/oauth-authorization-server/{server_name}" - ) - - raise HTTPException( - status_code=401, - detail="Unauthorized", - headers={"www-authenticate": authorization_uri}, - ) # Strip any client-supplied x-mcp-toolset-id to prevent forgery. scope["headers"] = [ @@ -3349,10 +3494,28 @@ async def handle_streamable_http_mcp( # noqa: PLR0915 # Apply toolset scope if set server-side via ContextVar (set by # /toolset/{name}/mcp and /{name}/mcp route handlers in proxy_server.py). active_toolset_id = _mcp_active_toolset_id.get() + toolset_allowed_server_ids: Optional[Set[str]] = None if active_toolset_id and user_api_key_auth is not None: user_api_key_auth = await _apply_toolset_scope( user_api_key_auth, active_toolset_id ) + op = user_api_key_auth.object_permission + toolset_allowed_server_ids = set(op.mcp_servers or []) if op else set() + + # https://datatracker.ietf.org/doc/html/rfc9728#name-www-authenticate-response + # Must run after toolset scoping so the challenge set is derived + # from the fully-authorized server set: a passthrough server that + # the active toolset excludes should not trigger an OAuth flow + # for a server the caller will be 403'd on after authentication. + await _raise_preemptive_401_for_unauthenticated_servers( + scope=scope, + mcp_servers=mcp_servers, + oauth2_headers=oauth2_headers, + mcp_server_auth_headers=mcp_server_auth_headers, + user_api_key_auth=user_api_key_auth, + client_ip=_client_ip, + allowed_server_ids=toolset_allowed_server_ids, + ) # Pre-flight auth check for pass-through servers. Must run after # toolset scoping so the probe list is derived from the fully-authorized @@ -3583,6 +3746,13 @@ async def _dispatch() -> None: not in _stateful_session_auth_contexts ): _stateful_session_locks.pop(active_request_session_id, None) + except MCPUpstreamAuthError as e: + # Pass-through server returned 401 — surface it to the client so + # standards-compliant MCP clients trigger the upstream OAuth flow. + raise e.to_http_exception( + base_url=get_request_base_url(StarletteRequest(scope)), + request_path=scope.get("_original_path") or scope.get("path"), + ) except HTTPException: # Re-raise HTTP exceptions to preserve status codes and details raise @@ -3626,6 +3796,50 @@ async def handle_sse_mcp(scope: Scope, receive: Receive, send: Send) -> None: verbose_logger.debug( f"MCP server auth headers: {list(mcp_server_auth_headers.keys()) if mcp_server_auth_headers else None}" ) + + # Strip any client-supplied x-mcp-toolset-id to prevent forgery. + scope["headers"] = [ + (k, v) + for k, v in scope.get("headers", []) + if k.lower() != b"x-mcp-toolset-id" + ] + + # Apply toolset scope if set server-side via ContextVar so the + # downstream probe list matches the fully-authorized server set + # (mirrors the streamable HTTP handler). + active_toolset_id = _mcp_active_toolset_id.get() + toolset_allowed_server_ids: Optional[Set[str]] = None + if active_toolset_id and user_api_key_auth is not None: + user_api_key_auth = await _apply_toolset_scope( + user_api_key_auth, active_toolset_id + ) + op = user_api_key_auth.object_permission + toolset_allowed_server_ids = set(op.mcp_servers or []) if op else set() + + # https://datatracker.ietf.org/doc/html/rfc9728#name-www-authenticate-response + # Must run after toolset scoping so the challenge set is derived + # from the fully-authorized server set: a passthrough server that + # the active toolset excludes should not trigger an OAuth flow + # for a server the caller will be 403'd on after authentication. + await _raise_preemptive_401_for_unauthenticated_servers( + scope=scope, + mcp_servers=mcp_servers, + oauth2_headers=oauth2_headers, + mcp_server_auth_headers=mcp_server_auth_headers, + user_api_key_auth=user_api_key_auth, + client_ip=_sse_client_ip, + allowed_server_ids=toolset_allowed_server_ids, + ) + + # Pre-flight auth check for pass-through servers: surface upstream + # 401/403 as a proper challenge before the SSE session commits 200 + # headers, so clients can refresh their OAuth token instead of + # being stuck with a silently empty tool list. Must run after + # toolset scoping so the probe list is derived from the fully- + # authorized server set, not the raw user-supplied names. + await _check_passthrough_upstream_auth( + scope, user_api_key_auth, mcp_servers, _sse_client_ip + ) set_auth_context( user_api_key_auth=user_api_key_auth, mcp_auth_header=mcp_auth_header, @@ -3646,9 +3860,20 @@ async def handle_sse_mcp(scope: Scope, receive: Receive, send: Send) -> None: _sse_client_ip, ): await sse_session_manager.handle_request(scope, receive, send) + except MCPUpstreamAuthError as e: + # Pass-through server returned 401 — surface it to the client so + # standards-compliant MCP clients trigger the upstream OAuth flow. + raise e.to_http_exception( + base_url=get_request_base_url(StarletteRequest(scope)), + request_path=scope.get("_original_path") or scope.get("path"), + ) + except HTTPException: + # Re-raise HTTP exceptions to preserve status codes and details + # (e.g. 401 + WWW-Authenticate challenges from OAuth pass-through). + raise except Exception as e: verbose_logger.exception(f"Error handling MCP request: {e}") - # Instead of re-raising, try to send a graceful error response + # Try to send a graceful error response for non-HTTP exceptions try: # Send a proper HTTP error response instead of letting the exception bubble up from starlette.responses import JSONResponse diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 751f855ea34..0c6bda3b553 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1289,6 +1289,7 @@ class NewMCPServerRequest(LiteLLMPydanticObjectBase): allow_all_keys: bool = False available_on_public_internet: bool = True delegate_auth_to_upstream: bool = False + oauth_passthrough: bool = False is_byok: bool = False byok_description: List[str] = Field(default_factory=list) byok_api_key_help_url: Optional[str] = None @@ -1372,6 +1373,7 @@ class UpdateMCPServerRequest(LiteLLMPydanticObjectBase): allow_all_keys: bool = False available_on_public_internet: bool = True delegate_auth_to_upstream: bool = False + oauth_passthrough: bool = False is_byok: bool = False byok_description: List[str] = Field(default_factory=list) byok_api_key_help_url: Optional[str] = None @@ -1444,6 +1446,7 @@ class LiteLLM_MCPServerTable(LiteLLMPydanticObjectBase): allow_all_keys: bool = False available_on_public_internet: bool = True delegate_auth_to_upstream: bool = False + oauth_passthrough: bool = False is_byok: bool = False byok_description: List[str] = Field(default_factory=list) byok_api_key_help_url: Optional[str] = None @@ -2515,7 +2518,7 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase): ) mcp_trusted_proxy_ranges: Optional[List[str]] = Field( None, - description="CIDR ranges of trusted reverse proxies. When set, X-Forwarded-For headers are only trusted from these IPs.", + description="CIDR ranges of trusted reverse proxies. When set, X-Forwarded-For and X-Forwarded-* origin headers are only trusted from these IPs.", ) trusted_proxy_ranges: Optional[List[str]] = Field( None, @@ -4433,6 +4436,72 @@ class JWTRoutingOverride(BaseModel): } +class JWTIssuerConfig(BaseModel): + """ + Issuer-bound JWT validation configuration. + + When a token's unverified `iss` claim matches an entry in + ``LiteLLM_JWTAuth.issuers``, LiteLLM validates it only against that + issuer's JWKS and audience. Tokens whose `iss` does not match any + configured issuer fall back to the global JWT_AUDIENCE/JWT_ISSUER + validation path; `issuers` is additive routing, not an allow-list. + """ + + issuer: str = Field(description="Exact expected JWT issuer (`iss`) value.") + jwks_url: Optional[str] = Field( + default=None, + description="Issuer JWKS URL. If omitted, LiteLLM uses the issuer's OIDC discovery document.", + ) + audience: Optional[Union[str, List[str]]] = Field( + default=None, + description="Expected token audience for this issuer.", + ) + disable_audience_validation: bool = Field( + default=False, + description="Explicitly disable audience validation for this issuer. Use only when the issuer cannot provide an audience suitable for LiteLLM.", + ) + user_id_jwt_field: Optional[str] = Field( + default=None, + description="Issuer-specific claim path to normalize into LiteLLM's user id.", + ) + user_email_jwt_field: Optional[str] = Field( + default=None, + description="Issuer-specific claim path to normalize into LiteLLM's user email.", + ) + team_id_jwt_field: Optional[str] = Field( + default=None, + description="Issuer-specific claim path to normalize into LiteLLM's team id.", + ) + team_ids_jwt_field: Optional[str] = Field( + default=None, + description="Issuer-specific claim path to normalize into LiteLLM's team ids.", + ) + org_id_jwt_field: Optional[str] = Field( + default=None, + description="Issuer-specific claim path to normalize into LiteLLM's organization id.", + ) + end_user_id_jwt_field: Optional[str] = Field( + default=None, + description="Issuer-specific claim path to normalize into LiteLLM's end-user id.", + ) + + model_config = { + "extra": "forbid", + } + + @model_validator(mode="after") + def validate_audience_configured(self) -> "JWTIssuerConfig": + if self.audience is None and not self.disable_audience_validation: + raise ValueError( + f"JWT issuer {self.issuer} must configure audience or set disable_audience_validation=True" + ) + if self.audience is not None and self.disable_audience_validation: + raise ValueError( + f"JWT issuer {self.issuer} cannot set audience and disable_audience_validation=True together" + ) + return self + + class LiteLLM_JWTAuth(LiteLLMPydanticObjectBase): """ A class to define the roles and permissions for a LiteLLM Proxy w/ JWT Auth. @@ -4537,6 +4606,10 @@ class LiteLLM_JWTAuth(LiteLLMPydanticObjectBase): default=None, description="Optional claim-based routing overrides for JWT-shaped tokens. Matching rules route requests to oauth2 before default JWT flow.", ) + issuers: Optional[List[JWTIssuerConfig]] = Field( + 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.", + ) ######################################################### def __init__(self, **kwargs: Any) -> None: diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index 86265270357..4e5169d8d84 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -522,14 +522,20 @@ def get_request_route(request: Request) -> str: if not isinstance(scope, dict): return str(request.url.path) raw_path: str = str(scope.get("path", request.url.path)) - root_path: str = str(scope.get("app_root_path", scope.get("root_path", ""))) + root_path: str = str( + scope.get("app_root_path", scope.get("root_path", "")) + ).rstrip("/") if not isinstance(raw_path, str): return str(request.url.path) - # Only strip root_path when it is a meaningful prefix (not bare "/"). - # Stripping bare "/" would remove the leading slash from every path - # e.g. "/team/new" → "team/new", breaking route matching. - if root_path and root_path != "/" and raw_path.startswith(root_path): - return raw_path[len(root_path) :] + # Strip root_path only when it matches whole path segments — guarding + # against sibling paths like "/apifoo" being truncated under + # root_path="/api". Trailing slashes on root_path are stripped above, + # so bare "/" or "/prefix/" still leave the leading "/" intact. + if root_path and ( + raw_path == root_path or raw_path.startswith(root_path + "/") + ): + stripped = raw_path[len(root_path) :] + return stripped or "/" return raw_path except Exception as e: verbose_proxy_logger.debug( diff --git a/litellm/proxy/auth/handle_jwt.py b/litellm/proxy/auth/handle_jwt.py index 9838b4ba49b..654e7e0ff28 100644 --- a/litellm/proxy/auth/handle_jwt.py +++ b/litellm/proxy/auth/handle_jwt.py @@ -12,7 +12,7 @@ import hashlib import os import re -from typing import Any, List, Literal, Optional, Set, Tuple, cast +from typing import Any, List, Literal, Optional, Set, Tuple, Union, cast from cryptography import x509 from cryptography.hazmat.backends import default_backend @@ -29,6 +29,7 @@ RBAC_ROLES, JWKKeyValue, JWTAuthBuilderResult, + JWTIssuerConfig, JWTKeyItem, LiteLLM_EndUserTable, LiteLLM_JWTAuth, @@ -66,6 +67,10 @@ ) +class NoMatchingJWTPublicKeyError(Exception): + """Raised when a JWKS endpoint returns no key matching the requested ``kid``.""" + + class JWTHandler: """ - treat the sub id passed in as the user id @@ -91,6 +96,22 @@ class JWTHandler: "ES512", "EdDSA", ] + LITELLM_JWT_ISSUER_CLAIM = "_litellm_jwt_issuer" + LITELLM_USER_ID_CLAIM = "_litellm_user_id" + LITELLM_USER_EMAIL_CLAIM = "_litellm_user_email" + LITELLM_TEAM_ID_CLAIM = "_litellm_team_id" + LITELLM_TEAM_IDS_CLAIM = "_litellm_team_ids" + LITELLM_ORG_ID_CLAIM = "_litellm_org_id" + LITELLM_END_USER_ID_CLAIM = "_litellm_end_user_id" + LITELLM_INTERNAL_CLAIMS = ( + LITELLM_JWT_ISSUER_CLAIM, + LITELLM_USER_ID_CLAIM, + LITELLM_USER_EMAIL_CLAIM, + LITELLM_TEAM_ID_CLAIM, + LITELLM_TEAM_IDS_CLAIM, + LITELLM_ORG_ID_CLAIM, + LITELLM_END_USER_ID_CLAIM, + ) def __init__( self, @@ -213,7 +234,33 @@ def is_admin(self, scopes: list) -> bool: return True return False + def _is_trusted_issuer_normalized_token(self, token: dict) -> bool: + issuer = token.get(self.LITELLM_JWT_ISSUER_CLAIM) + if not isinstance(issuer, str) or not issuer: + return False + + litellm_jwtauth = getattr(self, "litellm_jwtauth", None) + issuer_configs = getattr(litellm_jwtauth, "issuers", None) or [] + return any(issuer_config.issuer == issuer for issuer_config in issuer_configs) + + def _has_trusted_issuer_normalized_claim(self, token: dict, claim: str) -> bool: + return self._is_trusted_issuer_normalized_token(token=token) and claim in token + def get_team_ids_from_jwt(self, token: dict) -> List[str]: + if self._has_trusted_issuer_normalized_claim( + token=token, claim=self.LITELLM_TEAM_IDS_CLAIM + ): + issuer_team_ids = token.get(self.LITELLM_TEAM_IDS_CLAIM) + if isinstance(issuer_team_ids, list): + return issuer_team_ids + if isinstance(issuer_team_ids, str): + return [issuer_team_ids] + # Issuer-scoped claim exists but has an unexpected type + # (e.g. int/dict from an unusual upstream mapping). Don't silently + # fall through to the global ``team_ids_jwt_field`` path — that + # would read a semantically unrelated claim on the same token. + return [] + if self.litellm_jwtauth.team_ids_jwt_field is not None: team_ids: Optional[List[str]] = get_nested_value( data=token, @@ -242,12 +289,18 @@ def get_all_jwt_team_ids(self, token: dict) -> List[str]: default-team behavior should still go through ``get_team_id``. """ team_ids: List[str] = list(self.get_team_ids_from_jwt(token)) - if self.litellm_jwtauth.team_id_jwt_field is not None: + singular: Any = None + if self._has_trusted_issuer_normalized_claim( + token=token, claim=self.LITELLM_TEAM_ID_CLAIM + ): + singular = token.get(self.LITELLM_TEAM_ID_CLAIM) + elif self.litellm_jwtauth.team_id_jwt_field is not None: singular = get_nested_value( data=token, key_path=self.litellm_jwtauth.team_id_jwt_field, default=None, ) + if singular is not None: if isinstance(singular, list): for item in singular: if item is None: @@ -262,6 +315,11 @@ def get_all_jwt_team_ids(self, token: dict) -> List[str]: def get_end_user_id( self, token: dict, default_value: Optional[str] ) -> Optional[str]: + if self._has_trusted_issuer_normalized_claim( + token=token, claim=self.LITELLM_END_USER_ID_CLAIM + ): + return token.get(self.LITELLM_END_USER_ID_CLAIM) + try: if self.litellm_jwtauth.end_user_id_jwt_field is not None: user_id = get_nested_value( @@ -303,6 +361,14 @@ def is_enforced_email_domain(self) -> bool: return False def get_team_id(self, token: dict, default_value: Optional[str]) -> Optional[str]: + if self._has_trusted_issuer_normalized_claim( + token=token, claim=self.LITELLM_TEAM_ID_CLAIM + ): + team_id = token.get(self.LITELLM_TEAM_ID_CLAIM) + if isinstance(team_id, list): + return team_id[0] if team_id else default_value + return team_id + try: if self.litellm_jwtauth.team_id_jwt_field is not None: # Use a sentinel value to detect if the path actually exists @@ -376,6 +442,11 @@ def is_upsert_user_id(self, valid_user_email: Optional[bool] = None) -> bool: return self.litellm_jwtauth.user_id_upsert def get_user_id(self, token: dict, default_value: Optional[str]) -> Optional[str]: + if self._has_trusted_issuer_normalized_claim( + token=token, claim=self.LITELLM_USER_ID_CLAIM + ): + return token.get(self.LITELLM_USER_ID_CLAIM) + try: if self.litellm_jwtauth.user_id_jwt_field is not None: user_id = get_nested_value( @@ -467,6 +538,11 @@ def is_allowed_user_role(self, user_roles: Optional[List[str]]) -> bool: def get_user_email( self, token: dict, default_value: Optional[str] ) -> Optional[str]: + if self._has_trusted_issuer_normalized_claim( + token=token, claim=self.LITELLM_USER_EMAIL_CLAIM + ): + return token.get(self.LITELLM_USER_EMAIL_CLAIM) + try: if self.litellm_jwtauth.user_email_jwt_field is not None: user_email = get_nested_value( @@ -495,6 +571,11 @@ def get_object_id(self, token: dict, default_value: Optional[str]) -> Optional[s return object_id def get_org_id(self, token: dict, default_value: Optional[str]) -> Optional[str]: + if self._has_trusted_issuer_normalized_claim( + token=token, claim=self.LITELLM_ORG_ID_CLAIM + ): + return token.get(self.LITELLM_ORG_ID_CLAIM) + try: if self.litellm_jwtauth.org_id_jwt_field is not None: org_id = get_nested_value( @@ -590,55 +671,77 @@ async def _resolve_jwks_url(self, url: str) -> str: await self.user_api_key_cache.async_set_cache( key=cache_key, value=jwks_uri, - ttl=self.litellm_jwtauth.public_key_ttl, + ttl=self._get_public_key_cache_ttl(), ) return jwks_uri - async def get_public_key(self, kid: Optional[str]) -> dict: - keys_url = os.getenv("JWT_PUBLIC_KEY_URL") + def _get_public_key_cache_ttl(self) -> float: + litellm_jwtauth = getattr(self, "litellm_jwtauth", None) + if litellm_jwtauth is None: + return 600 + return litellm_jwtauth.public_key_ttl - if keys_url is None: - raise Exception("Missing JWT Public Key URL from environment.") + async def _get_public_key_from_jwks_url( + self, jwks_url: str, kid: Optional[str] + ) -> dict: + resolved_jwks_url = await self._resolve_jwks_url(jwks_url) + cache_key = f"litellm_jwt_auth_keys_{resolved_jwks_url}" - keys_url_list = [url.strip() for url in keys_url.split(",")] + cached_keys = await self.user_api_key_cache.async_get_cache(cache_key) - for key_url in keys_url_list: - key_url = await self._resolve_jwks_url(key_url) - cache_key = f"litellm_jwt_auth_keys_{key_url}" + if cached_keys is None: + response = await self.http_handler.get(resolved_jwks_url) - cached_keys = await self.user_api_key_cache.async_get_cache(cache_key) + try: + response_json = response.json() + except Exception as e: + verbose_proxy_logger.error( + f"Error parsing response: {e}. Original Response: {response.text}" + ) + raise Exception( + f"Error parsing response: {e}. Check server logs for original response." + ) - if cached_keys is None: - response = await self.http_handler.get(key_url) + if "keys" in response_json: + keys: JWKKeyValue = response_json["keys"] + else: + keys = response_json - try: - response_json = response.json() - except Exception as e: - verbose_proxy_logger.error( - f"Error parsing response: {e}. Original Response: {response.text}" - ) - raise Exception( - f"Error parsing response: {e}. Check server logs for original response." - ) + await self.user_api_key_cache.async_set_cache( + key=cache_key, + value=keys, + ttl=self._get_public_key_cache_ttl(), + ) + else: + keys = cached_keys - if "keys" in response_json: - keys: JWKKeyValue = response.json()["keys"] - else: - keys = response_json + public_key = self.parse_keys(keys=keys, kid=kid) + if public_key is not None: + return cast(dict, public_key) - await self.user_api_key_cache.async_set_cache( - key=cache_key, - value=keys, - ttl=self.litellm_jwtauth.public_key_ttl, # cache for 10 mins - ) - else: - keys = cached_keys + raise NoMatchingJWTPublicKeyError( + f"No matching public key found. keys={resolved_jwks_url}, kid={kid}" + ) - public_key = self.parse_keys(keys=keys, kid=kid) - if public_key is not None: - return cast(dict, public_key) + async def get_public_key(self, kid: Optional[str]) -> dict: + keys_url = os.getenv("JWT_PUBLIC_KEY_URL") + + if keys_url is None: + raise Exception("Missing JWT Public Key URL from environment.") - raise Exception( + keys_url_list = [url.strip() for url in keys_url.split(",") if url.strip()] + + for key_url in keys_url_list: + try: + return await self._get_public_key_from_jwks_url( + jwks_url=key_url, kid=kid + ) + except NoMatchingJWTPublicKeyError as e: + verbose_proxy_logger.debug( + "JWT Auth: No matching public key found at %s: %s", key_url, e + ) + + raise NoMatchingJWTPublicKeyError( f"No matching public key found. keys={keys_url_list}, kid={kid}" ) @@ -753,6 +856,11 @@ def _build_decode_kwargs(cls) -> dict: minted by other applications that share the same IdP signing keys. When both are unset PyJWT only checks the signature and expiry, which is preserved for backward compatibility but logged once as a warning. + + The warning fires even in mixed deployments that also configure + ``LiteLLM_JWTAuth.issuers``: tokens whose ``iss`` does not match any + configured issuer fall through to this global path, and if env-var + scoping is absent that fallback is itself unscoped. """ audience = os.getenv("JWT_AUDIENCE") issuer = os.getenv("JWT_ISSUER") @@ -782,73 +890,217 @@ def _build_decode_kwargs(cls) -> dict: "options": options or None, } - async def auth_jwt(self, token: str) -> dict: - decode_kwargs = self._build_decode_kwargs() + def _get_configured_issuer(self, token: str) -> Optional[JWTIssuerConfig]: + litellm_jwtauth = getattr(self, "litellm_jwtauth", None) + if litellm_jwtauth is None: + return None + + issuer_configs = litellm_jwtauth.issuers + if not issuer_configs: + return None + + claims = self.get_unverified_claims(token=token) + if claims is None: + return None + + issuer = claims.get("iss") + if not isinstance(issuer, str) or not issuer: + return None + + for issuer_config in issuer_configs: + if issuer_config.issuer == issuer: + return issuer_config + + return None + + def _get_jwks_url_for_issuer(self, issuer_config: JWTIssuerConfig) -> str: + if issuer_config.jwks_url: + return issuer_config.jwks_url + # _resolve_jwks_url fetches this OIDC discovery document and follows + # 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) -> Any: + """Resolve a mapped claim from ``token``. + + Returns ``None`` when the field is absent or empty so that mapped claims + behave like the global ``litellm_jwtauth`` path — present claims override + the normalised value, missing ones simply leave it ``None``. + """ + sentinel = object() + claim_value = get_nested_value( + data=token, + key_path=claim_field, + default=sentinel, + ) + if claim_value is sentinel or claim_value is None or claim_value == "": + return None + return claim_value + + def _apply_issuer_claim_mappings( + self, token: dict, issuer_config: JWTIssuerConfig + ) -> dict: + normalized: dict = { + 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 = [ + (issuer_config.user_id_jwt_field, self.LITELLM_USER_ID_CLAIM), + (issuer_config.user_email_jwt_field, self.LITELLM_USER_EMAIL_CLAIM), + (issuer_config.team_id_jwt_field, self.LITELLM_TEAM_ID_CLAIM), + (issuer_config.team_ids_jwt_field, self.LITELLM_TEAM_IDS_CLAIM), + (issuer_config.org_id_jwt_field, self.LITELLM_ORG_ID_CLAIM), + (issuer_config.end_user_id_jwt_field, self.LITELLM_END_USER_ID_CLAIM), + ] + + for source_claim, normalized_claim in claim_mappings: + if source_claim is None: + continue + claim_value = self._get_claim_value_for_issuer_mapping( + token=token, + claim_field=source_claim, + ) + if claim_value is not None: + normalized[normalized_claim] = claim_value + + return normalized + + def _get_jwk_from_public_key(self, public_key: dict) -> dict: + jwk = {} + for key in ["kty", "kid", "n", "e", "x", "y", "crv"]: + if key in public_key: + jwk[key] = public_key[key] + return jwk + + def _get_decode_options( + self, + audience: Optional[Union[str, List[str]]], + issuer: Optional[str] = None, + disable_audience_validation: bool = False, + ) -> Optional[dict]: + # Disabling audience verification must be an explicit choice — never + # an implicit consequence of ``audience`` being None. Otherwise a + # caller that accidentally constructs a config with ``audience=None`` + # (bypassing the model validator) would silently lose audience + # validation. Require callers to opt in via + # ``disable_audience_validation=True``. + if audience is None and not disable_audience_validation: + raise ValueError( + "audience must be provided unless disable_audience_validation=True" + ) + options: dict = {} + if audience is None: + options["verify_aud"] = False + if issuer is None: + options["verify_iss"] = False + return options or None + + def _decode_jwt_with_public_key( + self, + token: str, + public_key: Union[dict, str], + audience: Optional[Union[str, List[str]]], + issuer: Optional[str] = None, + options: Optional[dict] = None, + disable_audience_validation: bool = False, + ) -> dict: + decode_options = ( + options + if options is not None + else self._get_decode_options( + audience=audience, + issuer=issuer, + disable_audience_validation=disable_audience_validation, + ) + ) + if isinstance(public_key, dict): + public_key_obj = PyJWK.from_dict( + self._get_jwk_from_public_key(public_key=public_key) + ).key + return jwt.decode( + token, + public_key_obj, # type: ignore + algorithms=self.SUPPORTED_JWT_ALGORITHMS, + options=decode_options, # type: ignore[arg-type] + audience=audience, + issuer=issuer, + leeway=self.leeway, + ) + + cert = x509.load_pem_x509_certificate(public_key.encode(), default_backend()) + key = cert.public_key().public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo, + ) + return jwt.decode( + token, + key, + algorithms=self.SUPPORTED_JWT_ALGORITHMS, + audience=audience, + issuer=issuer, + options=decode_options, # type: ignore[arg-type] + leeway=self.leeway, + ) + + async def _auth_jwt_with_issuer( + self, token: str, issuer_config: JWTIssuerConfig, kid: Optional[str] + ) -> dict: + public_key = await self._get_public_key_from_jwks_url( + jwks_url=self._get_jwks_url_for_issuer(issuer_config=issuer_config), + kid=kid, + ) + try: + payload = self._decode_jwt_with_public_key( + token=token, + public_key=public_key, + audience=issuer_config.audience, + issuer=issuer_config.issuer, + disable_audience_validation=issuer_config.disable_audience_validation, + ) + except jwt.ExpiredSignatureError: + raise Exception("Token Expired") + except Exception as e: + raise Exception(f"Validation fails: {str(e)}") + + return self._apply_issuer_claim_mappings( + token=payload, + issuer_config=issuer_config, + ) + + async def auth_jwt(self, token: str) -> dict: header = jwt.get_unverified_header(token) verbose_proxy_logger.debug("header: %s", header) kid = header.get("kid", None) - public_key = await self.get_public_key(kid=kid) + issuer_config = self._get_configured_issuer(token=token) + if issuer_config is not None: + return await self._auth_jwt_with_issuer( + token=token, + issuer_config=issuer_config, + kid=kid, + ) - if public_key is not None and isinstance(public_key, dict): - jwk = {} - if "kty" in public_key: - jwk["kty"] = public_key["kty"] - if "kid" in public_key: - jwk["kid"] = public_key["kid"] - if "n" in public_key: - jwk["n"] = public_key["n"] - if "e" in public_key: - jwk["e"] = public_key["e"] - if "x" in public_key: - jwk["x"] = public_key["x"] - if "y" in public_key: - jwk["y"] = public_key["y"] - if "crv" in public_key: - jwk["crv"] = public_key["crv"] - - # parse RSA/EC/OKP keys - public_key_obj = PyJWK.from_dict(jwk).key + decode_kwargs = self._build_decode_kwargs() - try: - # decode the token using the public key - payload = jwt.decode( - token, - public_key_obj, # type: ignore - algorithms=self.SUPPORTED_JWT_ALGORITHMS, - leeway=self.leeway, # allow testing of expired tokens - **decode_kwargs, - ) - return payload + public_key = await self.get_public_key(kid=kid) - except jwt.ExpiredSignatureError: - # the token is expired, do something to refresh it - raise Exception("Token Expired") - except Exception as e: - raise Exception(f"Validation fails: {str(e)}") - elif public_key is not None and isinstance(public_key, str): + if public_key is not None: try: - cert = x509.load_pem_x509_certificate( - public_key.encode(), default_backend() - ) - - # Extract public key - key = cert.public_key().public_bytes( - serialization.Encoding.PEM, - serialization.PublicFormat.SubjectPublicKeyInfo, - ) - - # decode the token using the public key - payload = jwt.decode( - token, - key, - algorithms=self.SUPPORTED_JWT_ALGORITHMS, - **decode_kwargs, + payload = self._decode_jwt_with_public_key( + token=token, + public_key=public_key, + audience=decode_kwargs["audience"], + issuer=decode_kwargs["issuer"], + options=decode_kwargs["options"], ) - return payload + return { + k: v + for k, v in payload.items() + if k not in self.LITELLM_INTERNAL_CLAIMS + } except jwt.ExpiredSignatureError: # the token is expired, do something to refresh it diff --git a/litellm/proxy/auth/ip_address_utils.py b/litellm/proxy/auth/ip_address_utils.py index 39d3282942f..be0d83dfcdc 100644 --- a/litellm/proxy/auth/ip_address_utils.py +++ b/litellm/proxy/auth/ip_address_utils.py @@ -153,8 +153,9 @@ def is_request_from_trusted_proxy( verbose_proxy_logger.warning( "use_x_forwarded_for is enabled but mcp_trusted_proxy_ranges " "is not configured. X-Forwarded-* headers will NOT be " - "trusted, so MCP OAuth discovery URLs will use the proxy's " - "literal base URL. Set mcp_trusted_proxy_ranges in " + "trusted, so MCP OAuth discovery URLs and access-control " + "client IPs will use the proxy's literal request values. " + "Set mcp_trusted_proxy_ranges in " "general_settings to your reverse-proxy CIDR(s) to allow " "X-Forwarded-* through." ) @@ -199,17 +200,19 @@ def get_mcp_client_ip( # If XFF is enabled, validate the request comes from a trusted proxy if use_xff and "x-forwarded-for" in request.headers: - trusted_ranges = general_settings.get("mcp_trusted_proxy_ranges") - if trusted_ranges: - # Validate direct connection is from trusted proxy + if not IPAddressUtils.is_request_from_trusted_proxy( + request, general_settings=general_settings + ): direct_ip = request.client.host if request.client else None - trusted_networks = IPAddressUtils.parse_trusted_proxy_networks( - trusted_ranges - ) - if not IPAddressUtils.is_trusted_proxy(direct_ip, trusted_networks): - # Untrusted source trying to set XFF - ignore XFF, use direct IP + if general_settings.get("mcp_trusted_proxy_ranges"): + # Direct connection isn't in any configured trusted CIDR. verbose_proxy_logger.warning( "XFF header from untrusted IP %s, ignoring", direct_ip ) return direct_ip + # XFF enabled but no trusted proxy ranges configured: the direct + # peer is typically the reverse proxy's own (private) IP, so + # returning it would mis-classify external callers as internal. + # Fail closed for access control. + return "" return _get_request_ip_address(request, use_x_forwarded_for=use_xff) diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index b35e2b6e3fd..4d67df16b0b 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -1542,7 +1542,7 @@ async def _mcp_oauth_user_api_key_auth(request: Request) -> UserAPIKeyAuth: master_key, algorithms=["HS256"], # UI session cookies may omit exp; don't require it. - options={"verify_exp": False}, + options={"verify_exp": False, "verify_aud": False}, ) if decoded.get("login_method") in ("sso", "username_password"): cookie_key = decoded.get("key", "") diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index e0f139dee57..61df62ba2be 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -15845,6 +15845,8 @@ async def _mcp_forward_as_path(path_segment: str, request: Request): ) scope = dict(request.scope) + # Preserve the public request path for OAuth challenge URL selection. + scope["_original_path"] = scope.get("path", "") scope["path"] = f"/mcp/{path_segment}" return await _stream_mcp_asgi_response( handle_streamable_http_mcp, scope, request.receive @@ -15992,6 +15994,7 @@ async def dynamic_mcp_route(mcp_server_name: str, request: Request): ) if toolset is not None: scope = dict(request.scope) + scope["_original_path"] = scope.get("path", "") scope["path"] = "/mcp" token = _mcp_active_toolset_id.set(toolset.toolset_id) try: diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 78143fe0411..c4754ef6117 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -325,6 +325,7 @@ model LiteLLM_MCPServerTable { allow_all_keys Boolean @default(false) available_on_public_internet Boolean @default(true) delegate_auth_to_upstream Boolean @default(false) + oauth_passthrough Boolean @default(false) is_byok Boolean @default(false) byok_description String[] @default([]) byok_api_key_help_url String? diff --git a/litellm/types/interactions/generated.py b/litellm/types/interactions/generated.py index d546e897891..b38cd8f58b9 100644 --- a/litellm/types/interactions/generated.py +++ b/litellm/types/interactions/generated.py @@ -203,6 +203,8 @@ class Status1(Enum): completed = "completed" failed = "failed" cancelled = "cancelled" + incomplete = "incomplete" + budget_exceeded = "budget_exceeded" class InteractionStatusUpdate(BaseModel): @@ -386,13 +388,13 @@ class ResponseModality(Enum): class Status3(Enum): - UNSPECIFIED = "UNSPECIFIED" - IN_PROGRESS = "IN_PROGRESS" - REQUIRES_ACTION = "REQUIRES_ACTION" - COMPLETED = "COMPLETED" - FAILED = "FAILED" - CANCELLED = "CANCELLED" - INCOMPLETE = "INCOMPLETE" + IN_PROGRESS = "in_progress" + REQUIRES_ACTION = "requires_action" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + INCOMPLETE = "incomplete" + BUDGET_EXCEEDED = "budget_exceeded" class ModelOption(RootModel[str]): diff --git a/litellm/types/mcp_server/mcp_server_manager.py b/litellm/types/mcp_server/mcp_server_manager.py index 13e325838dc..6aa62c35106 100644 --- a/litellm/types/mcp_server/mcp_server_manager.py +++ b/litellm/types/mcp_server/mcp_server_manager.py @@ -68,12 +68,29 @@ class MCPServer(BaseModel): access_groups: Optional[List[str]] = None allow_all_keys: bool = False available_on_public_internet: bool = True - # When True AND auth_type == oauth2, MCP requests targeting this server + # Explicit opt-in to upstream-delegated authentication for ``oauth2`` + # servers. When ``auth_type == oauth2`` and this is ``True``, MCP requests # bypass LiteLLM API-key/SSO auth (and the pre-emptive 401) so the client - # completes PKCE directly with the upstream MCP server. Honored only for - # auth_type=oauth2; ignored for any other auth_type. See - # MCPRequestHandler._target_servers_delegate_auth_to_upstream. + # completes PKCE directly with the upstream MCP server. See + # ``MCPRequestHandler._target_servers_delegate_auth_to_upstream``. + # + # Honored only for ``auth_type == oauth2``; ignored for any other + # ``auth_type``. OAuth pass-through for non-oauth2 servers + # (``auth_type in (None, MCPAuth.none)``) is a separate, explicit opt-in — + # see ``oauth_passthrough`` / ``is_oauth_passthrough``. delegate_auth_to_upstream: bool = False + # Explicit opt-in to OAuth pass-through for non-oauth2 servers. When this + # is ``True`` AND ``auth_type in (None, MCPAuth.none)`` AND ``extra_headers`` + # contains ``Authorization``, the gateway proxies upstream + # ``/.well-known/oauth-protected-resource`` metadata, emits spec-compliant + # 401 challenges when no bearer is supplied, and propagates upstream + # 401/403 responses instead of swallowing them. See ``is_oauth_passthrough``. + # + # Intentionally distinct from ``delegate_auth_to_upstream`` (oauth2-only): + # reusing that flag would silently change behavior for servers that forward + # ``Authorization`` for non-OAuth reasons (e.g. static bearer tokens). Must + # be set explicitly to avoid regressing servers that did not opt in. + oauth_passthrough: bool = False is_byok: bool = False byok_description: List[str] = [] byok_api_key_help_url: Optional[str] = None @@ -139,6 +156,42 @@ def requires_per_user_auth(self) -> bool: return False + @property + def is_oauth_passthrough(self) -> bool: + """True iff the gateway should transparently forward upstream OAuth + (discovery + 401s) rather than participating as an authorization + server itself. + + A server is pass-through for OAuth purposes when ALL three conditions + hold: + 1. ``auth_type`` is ``None`` or ``MCPAuth.none`` (the gateway does + not manage OAuth for this server). + 2. ``extra_headers`` includes ``Authorization`` — the admin has + opted this server into forwarding the client's bearer token + straight to the upstream MCP server. + 3. ``oauth_passthrough`` is ``True`` — the admin has + explicitly opted into upstream-delegated OAuth semantics for + this server. This is the explicit detection flag: without it, + a server that merely forwards ``Authorization`` (e.g. for + static bearer tokens or custom auth schemes) keeps the + pre-PR behavior and is not treated as OAuth pass-through. + This is deliberately a separate flag from + ``delegate_auth_to_upstream`` (which is oauth2-only) so enabling + pass-through here never changes behavior for oauth2 servers. + + This is intentionally narrower than ``requires_per_user_auth``, + which also covers PATs (``x-api-key``, ``api-key``, ``apikey``). + Those are static credentials, not OAuth bearer tokens, so they + must not trigger upstream OAuth discovery or 401 propagation. + """ + if self.auth_type not in (None, MCPAuth.none): + return False + if not self.extra_headers: + return False + if self.oauth_passthrough is not True: + return False + return any(h.lower() == "authorization" for h in self.extra_headers) + @property def has_token_exchange_config(self) -> bool: """True if this server is configured for OAuth2 token exchange (OBO / RFC 8693).""" diff --git a/schema.prisma b/schema.prisma index 78143fe0411..c4754ef6117 100644 --- a/schema.prisma +++ b/schema.prisma @@ -325,6 +325,7 @@ model LiteLLM_MCPServerTable { allow_all_keys Boolean @default(false) available_on_public_internet Boolean @default(true) delegate_auth_to_upstream Boolean @default(false) + oauth_passthrough Boolean @default(false) is_byok Boolean @default(false) byok_description String[] @default([]) byok_api_key_help_url String? diff --git a/tests/litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py b/tests/litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py index 8785e450a4b..2a8768df722 100644 --- a/tests/litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py +++ b/tests/litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py @@ -1,8 +1,32 @@ """Tests for MCP OAuth discoverable endpoints""" import pytest +from fastapi import HTTPException from unittest.mock import AsyncMock, MagicMock, patch +TRUSTED_PROXY_IP = "10.0.0.5" +TRUSTED_PROXY_RANGES = ["10.0.0.0/8"] + + +def set_request_from_trusted_proxy(mock_request): + mock_request.client = MagicMock() + mock_request.client.host = TRUSTED_PROXY_IP + + +@pytest.fixture +def trusted_proxy_origin_headers(): + with ( + patch( + "litellm.proxy._experimental.mcp_server.discoverable_endpoints.IPAddressUtils.is_request_from_trusted_proxy", + return_value=True, + ), + patch( + "litellm.proxy._experimental.mcp_server.oauth_utils.IPAddressUtils.is_request_from_trusted_proxy", + return_value=True, + ), + ): + yield + @pytest.mark.asyncio async def test_authorize_endpoint_includes_response_type(): @@ -56,7 +80,7 @@ async def test_authorize_endpoint_includes_response_type(): request=mock_request, client_id="test_client_id", mcp_server_name="test_oauth", - redirect_uri="https://client.example.com/callback", + redirect_uri="http://127.0.0.1:60108/callback", state="test_state", ) @@ -154,7 +178,6 @@ async def test_token_endpoint_forwards_code_verifier(): from litellm.types.mcp_server.mcp_server_manager import MCPServer from litellm.proxy._types import MCPTransport from fastapi import Request - import httpx except ImportError: pytest.skip("MCP discoverable endpoints not available") @@ -244,10 +267,15 @@ async def test_register_client_without_mcp_server_name_returns_dummy(): from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( register_client, ) + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) from fastapi import Request except ImportError: pytest.skip("MCP discoverable endpoints not available") + global_mcp_server_manager.registry.clear() + mock_request = MagicMock(spec=Request) mock_request.base_url = "https://proxy.litellm.example/" mock_request.headers = {} @@ -410,7 +438,9 @@ async def test_register_client_remote_registration_success(): @pytest.mark.asyncio -async def test_authorize_endpoint_respects_x_forwarded_proto(): +async def test_authorize_endpoint_respects_x_forwarded_proto( + trusted_proxy_origin_headers, +): """Test that authorize endpoint uses X-Forwarded-Proto header to construct correct redirect_uri""" try: from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( @@ -449,6 +479,7 @@ async def test_authorize_endpoint_respects_x_forwarded_proto(): mock_request = MagicMock(spec=Request) mock_request.base_url = "http://litellm.example.com/" # HTTP mock_request.headers = {"X-Forwarded-Proto": "https"} # Behind HTTPS proxy + set_request_from_trusted_proxy(mock_request) # Mock the encryption functions with patch( @@ -461,7 +492,7 @@ async def test_authorize_endpoint_respects_x_forwarded_proto(): request=mock_request, client_id="test_client_id", mcp_server_name="test_oauth", - redirect_uri="https://client.example.com/callback", + redirect_uri="http://127.0.0.1:60108/callback", state="test_state", ) @@ -476,7 +507,9 @@ async def test_authorize_endpoint_respects_x_forwarded_proto(): @pytest.mark.asyncio -async def test_token_endpoint_respects_x_forwarded_proto(): +async def test_token_endpoint_respects_x_forwarded_proto( + trusted_proxy_origin_headers, +): """Test that token endpoint uses X-Forwarded-Proto header for redirect_uri""" try: from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( @@ -515,6 +548,7 @@ async def test_token_endpoint_respects_x_forwarded_proto(): mock_request = MagicMock(spec=Request) mock_request.base_url = "http://litellm-proxy.example.com/" # HTTP mock_request.headers = {"X-Forwarded-Proto": "https"} # Behind HTTPS proxy + set_request_from_trusted_proxy(mock_request) # Mock httpx client response mock_response = MagicMock() @@ -535,7 +569,7 @@ async def test_token_endpoint_respects_x_forwarded_proto(): mock_get_client.return_value = mock_async_client # Call token endpoint - response = await token_endpoint( + await token_endpoint( request=mock_request, grant_type="authorization_code", code="test_code", @@ -666,7 +700,9 @@ async def test_oauth_protected_resource_legacy_pattern(): @pytest.mark.asyncio -async def test_oauth_protected_resource_respects_x_forwarded_proto(): +async def test_oauth_protected_resource_respects_x_forwarded_proto( + trusted_proxy_origin_headers, +): """Test that oauth_protected_resource_mcp uses X-Forwarded-Proto for URLs""" try: from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( @@ -704,6 +740,7 @@ async def test_oauth_protected_resource_respects_x_forwarded_proto(): mock_request = MagicMock(spec=Request) mock_request.base_url = "http://litellm.example.com/" # HTTP mock_request.headers = {"X-Forwarded-Proto": "https"} # Behind HTTPS proxy + set_request_from_trusted_proxy(mock_request) # Call the endpoint response = await oauth_protected_resource_mcp( @@ -719,7 +756,9 @@ async def test_oauth_protected_resource_respects_x_forwarded_proto(): @pytest.mark.asyncio -async def test_oauth_authorization_server_respects_x_forwarded_proto(): +async def test_oauth_authorization_server_respects_x_forwarded_proto( + trusted_proxy_origin_headers, +): """Test that oauth_authorization_server_mcp uses X-Forwarded-Proto for URLs""" try: from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( @@ -757,6 +796,7 @@ async def test_oauth_authorization_server_respects_x_forwarded_proto(): mock_request = MagicMock(spec=Request) mock_request.base_url = "http://litellm.example.com/" # HTTP mock_request.headers = {"X-Forwarded-Proto": "https"} # Behind HTTPS proxy + set_request_from_trusted_proxy(mock_request) # Call the endpoint response = await oauth_authorization_server_mcp( @@ -773,20 +813,28 @@ async def test_oauth_authorization_server_respects_x_forwarded_proto(): @pytest.mark.asyncio -async def test_register_client_respects_x_forwarded_proto(): +async def test_register_client_respects_x_forwarded_proto( + trusted_proxy_origin_headers, +): """Test that register_client uses X-Forwarded-Proto for redirect_uris""" try: from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( register_client, ) + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) from fastapi import Request except ImportError: pytest.skip("MCP discoverable endpoints not available") + global_mcp_server_manager.registry.clear() + # Mock request with http base_url but X-Forwarded-Proto: https mock_request = MagicMock(spec=Request) mock_request.base_url = "http://proxy.litellm.example/" # HTTP mock_request.headers = {"X-Forwarded-Proto": "https"} # Behind HTTPS proxy + set_request_from_trusted_proxy(mock_request) with patch( "litellm.proxy._experimental.mcp_server.discoverable_endpoints._read_request_body", @@ -803,7 +851,9 @@ async def test_register_client_respects_x_forwarded_proto(): @pytest.mark.asyncio -async def test_authorize_endpoint_respects_x_forwarded_host(): +async def test_authorize_endpoint_respects_x_forwarded_host( + trusted_proxy_origin_headers, +): """Test that authorize endpoint uses X-Forwarded-Host and X-Forwarded-Proto to construct correct redirect_uri""" try: from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( @@ -847,6 +897,7 @@ async def test_authorize_endpoint_respects_x_forwarded_host(): "X-Forwarded-Proto": "https", "X-Forwarded-Host": "proxy.example.com", } + set_request_from_trusted_proxy(mock_request) # Mock the encryption functions with patch( @@ -859,7 +910,7 @@ async def test_authorize_endpoint_respects_x_forwarded_host(): request=mock_request, client_id="test_client_id", mcp_server_name="test_oauth", - redirect_uri="https://client.example.com/callback", + redirect_uri="http://127.0.0.1:60108/callback", state="test_state", ) @@ -875,7 +926,9 @@ async def test_authorize_endpoint_respects_x_forwarded_host(): @pytest.mark.asyncio -async def test_token_endpoint_respects_x_forwarded_host(): +async def test_token_endpoint_respects_x_forwarded_host( + trusted_proxy_origin_headers, +): """Test that token endpoint uses X-Forwarded-Host and X-Forwarded-Proto for redirect_uri""" try: from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( @@ -917,6 +970,7 @@ async def test_token_endpoint_respects_x_forwarded_host(): "X-Forwarded-Proto": "https", "X-Forwarded-Host": "proxy.example.com", } + set_request_from_trusted_proxy(mock_request) # Mock httpx client response mock_response = MagicMock() @@ -937,7 +991,7 @@ async def test_token_endpoint_respects_x_forwarded_host(): mock_get_client.return_value = mock_async_client # Call token endpoint - response = await token_endpoint( + await token_endpoint( request=mock_request, grant_type="authorization_code", code="test_code", @@ -1075,7 +1129,12 @@ async def test_token_endpoint_respects_x_forwarded_host(): ], ) def test_get_request_base_url_comprehensive( - base_url, x_forwarded_proto, x_forwarded_host, x_forwarded_port, expected_url + base_url, + x_forwarded_proto, + x_forwarded_host, + x_forwarded_port, + expected_url, + trusted_proxy_origin_headers, ): """Comprehensive test for get_request_base_url with various header combinations""" try: @@ -1089,6 +1148,7 @@ def test_get_request_base_url_comprehensive( # Create mock request mock_request = MagicMock(spec=Request) mock_request.base_url = base_url + set_request_from_trusted_proxy(mock_request) # Build headers dict headers = {} @@ -1116,3 +1176,93 @@ def mock_get(header_name, default=None): f"X-Forwarded-Host={x_forwarded_host}, " f"X-Forwarded-Port={x_forwarded_port}" ) + + +def test_get_request_base_url_ignores_forwarded_headers_from_untrusted_client(): + try: + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + get_request_base_url, + ) + from fastapi import Request + except ImportError: + pytest.skip("MCP discoverable endpoints not available") + + mock_request = MagicMock(spec=Request) + mock_request.base_url = "https://gateway.example.com/mcp" + mock_request.headers = { + "X-Forwarded-Proto": "https", + "X-Forwarded-Host": "attacker.example.com", + "X-Forwarded-Port": "443", + } + mock_request.client = MagicMock() + mock_request.client.host = "203.0.113.10" + + with patch( + "litellm.proxy.proxy_server.general_settings", + { + "use_x_forwarded_for": True, + "mcp_trusted_proxy_ranges": TRUSTED_PROXY_RANGES, + }, + create=True, + ): + assert get_request_base_url(mock_request) == "https://gateway.example.com/mcp" + + +def test_validate_trusted_redirect_uri_rejects_spoofed_forwarded_host(): + try: + from litellm.proxy._experimental.mcp_server.oauth_utils import ( + validate_trusted_redirect_uri, + ) + from fastapi import Request + except ImportError: + pytest.skip("MCP OAuth utilities not available") + + mock_request = MagicMock(spec=Request) + mock_request.base_url = "https://gateway.example.com/" + mock_request.headers = { + "X-Forwarded-Proto": "https", + "X-Forwarded-Host": "attacker.example.com", + } + mock_request.client = MagicMock() + mock_request.client.host = "203.0.113.10" + + with ( + patch( + "litellm.proxy.proxy_server.general_settings", + { + "use_x_forwarded_for": True, + "mcp_trusted_proxy_ranges": TRUSTED_PROXY_RANGES, + }, + create=True, + ), + pytest.raises(HTTPException), + ): + validate_trusted_redirect_uri( + mock_request, + "https://attacker.example.com/callback", + ) + + +def test_validate_trusted_redirect_uri_allows_forwarded_origin_from_trusted_proxy( + trusted_proxy_origin_headers, +): + try: + from litellm.proxy._experimental.mcp_server.oauth_utils import ( + validate_trusted_redirect_uri, + ) + from fastapi import Request + except ImportError: + pytest.skip("MCP OAuth utilities not available") + + mock_request = MagicMock(spec=Request) + mock_request.base_url = "http://localhost:4000/" + mock_request.headers = { + "X-Forwarded-Proto": "https", + "X-Forwarded-Host": "proxy.example.com", + } + set_request_from_trusted_proxy(mock_request) + + validate_trusted_redirect_uri( + mock_request, + "https://proxy.example.com/callback", + ) diff --git a/tests/local_testing/conftest.py b/tests/local_testing/conftest.py index abb871789c3..0831313c136 100644 --- a/tests/local_testing/conftest.py +++ b/tests/local_testing/conftest.py @@ -22,13 +22,13 @@ ) # Adds the parent directory to the system path import litellm -# ``litellm.model_cost`` is loaded at import time from the URL pinned to -# ``main`` (``LITELLM_MODEL_COST_MAP_URL``). The in-tree backup ships with -# this branch and can include pricing entries that main has not yet picked -# up (e.g. an upstream provider rotates a model id and the test cassette -# records the new name). Backfill any entries that are missing from the -# remote-fetched map so cost-calculator lookups in tests succeed against -# the cassette state the branch is being tested with. +# ``litellm.model_cost`` is loaded at import time from the URL pinned to ``main`` +# (``LITELLM_MODEL_COST_MAP_URL``). The in-tree backup ships with this branch +# and can include pricing entries that ``main`` has not yet picked up (e.g. +# Mistral now returns ``ministral-8b-2512`` from ``mistral-tiny`` and the entry +# was added on this branch). Backfill any entries that are missing from the +# remote-fetched map so cost-calculator lookups in tests succeed against the +# cassette state the branch is being tested with. from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap for _k, _v in GetModelCostMap.load_local_model_cost_map().items(): diff --git a/tests/mcp_tests/test_mcp_server.py b/tests/mcp_tests/test_mcp_server.py index d76ebb0072f..cafd2ca848e 100644 --- a/tests/mcp_tests/test_mcp_server.py +++ b/tests/mcp_tests/test_mcp_server.py @@ -514,6 +514,69 @@ async def test_sse_mcp_handler_mock(): ) +@pytest.mark.asyncio +async def test_sse_mcp_handler_propagates_passthrough_401(): + """SSE handler must raise 401 + WWW-Authenticate when the upstream + pass-through probe rejects the client's bearer token, instead of letting + the SSE session start and silently return empty tool lists.""" + from fastapi import HTTPException + + from litellm.proxy._types import UserAPIKeyAuth + + mock_scope = { + "type": "http", + "method": "GET", + "path": "/mcp/sse", + "headers": [(b"accept", b"text/event-stream")], + "query_string": b"", + "server": ("localhost", 8000), + "scheme": "http", + } + mock_receive = AsyncMock() + mock_send = AsyncMock() + + mock_auth_result = (UserAPIKeyAuth(), None, None, {}, {}, []) + + challenge = HTTPException( + status_code=401, + detail="Unauthorized", + headers={"WWW-Authenticate": "Bearer authorization_uri=https://example/"}, + ) + + with ( + patch( + "litellm.proxy._experimental.mcp_server.server._SESSION_MANAGERS_INITIALIZED", + True, + ), + patch( + "litellm.proxy._experimental.mcp_server.server.sse_session_manager", + AsyncMock(), + ), + patch( + "litellm.proxy._experimental.mcp_server.server.extract_mcp_auth_context", + new=AsyncMock(return_value=mock_auth_result), + ), + patch( + "litellm.proxy._experimental.mcp_server.server.set_auth_context", + ), + patch( + "litellm.proxy._experimental.mcp_server.server._raise_preemptive_401_for_unauthenticated_servers", + new=AsyncMock(), + ), + patch( + "litellm.proxy._experimental.mcp_server.server._check_passthrough_upstream_auth", + new=AsyncMock(side_effect=challenge), + ), + ): + from litellm.proxy._experimental.mcp_server.server import handle_sse_mcp + + with pytest.raises(HTTPException) as excinfo: + await handle_sse_mcp(mock_scope, mock_receive, mock_send) + + assert excinfo.value.status_code == 401 + assert excinfo.value.headers and "WWW-Authenticate" in excinfo.value.headers + + def test_generate_stable_server_id(): """ Test the _generate_stable_server_id method to ensure hash stability across releases. diff --git a/tests/mcp_tests/test_per_user_oauth_cache.py b/tests/mcp_tests/test_per_user_oauth_cache.py index 43e514b32ae..141b906fce9 100644 --- a/tests/mcp_tests/test_per_user_oauth_cache.py +++ b/tests/mcp_tests/test_per_user_oauth_cache.py @@ -183,6 +183,31 @@ def test_multiple_rules_all_must_match(self): server_id="atlassian", ) + def test_boolean_value_matches_lowercase_string_rule(self): + """Boolean ``True`` in token response must match the JSON-style rule ``"true"``. + + Admin config is typically written as ``{"verified": "true"}`` (lower-case + from JSON / YAML), but the OAuth response returns ``{"verified": true}`` + (Python ``True``). The normaliser must align them. + """ + _validate_token_response = _import_validate() + token_response = {"access_token": "tok", "verified": True} + # Should not raise + _validate_token_response( + token_response=token_response, + validation_rules={"verified": "true"}, + server_id="test", + ) + + def test_boolean_false_matches_lowercase_string_rule(self): + _validate_token_response = _import_validate() + token_response = {"access_token": "tok", "is_admin": False} + _validate_token_response( + token_response=token_response, + validation_rules={"is_admin": "false"}, + server_id="test", + ) + # ── _compute_per_user_token_ttl ────────────────────────────────────────────── diff --git a/tests/proxy_unit_tests/test_jwt.py b/tests/proxy_unit_tests/test_jwt.py index 9a8d6d37020..92209e11315 100644 --- a/tests/proxy_unit_tests/test_jwt.py +++ b/tests/proxy_unit_tests/test_jwt.py @@ -2,6 +2,8 @@ # Unit tests for JWT-Auth import asyncio +import base64 +import logging import os import random import sys @@ -21,6 +23,9 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +import jwt +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa from fastapi import Request, HTTPException from fastapi.routing import APIRoute from fastapi.responses import Response @@ -35,7 +40,7 @@ from litellm.proxy.auth.handle_jwt import JWTHandler, JWTAuthManager from litellm.proxy.management_endpoints.team_endpoints import new_team from litellm.proxy.proxy_server import chat_completion -from typing import Literal +from typing import Literal, Optional public_key = { "kty": "RSA", @@ -1584,3 +1589,524 @@ def b64url(b: bytes) -> str: with pytest.raises(Exception) as exc: await h.auth_jwt(token) assert "Validation fails" in str(exc.value) + + +def _base64url_encode_bytes(value: bytes) -> str: + return base64.urlsafe_b64encode(value).rstrip(b"=").decode() + + +def _base64url_encode_int(value: int) -> str: + value_bytes = value.to_bytes((value.bit_length() + 7) // 8, "big") + return _base64url_encode_bytes(value=value_bytes) + + +def _get_rsa_key_and_jwk(kid: str): + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + public_numbers = private_key.public_key().public_numbers() + jwk = { + "kty": "RSA", + "n": _base64url_encode_int(value=public_numbers.n), + "e": _base64url_encode_int(value=public_numbers.e), + "kid": kid, + "alg": "RS256", + "use": "sig", + } + return private_key, jwk + + +def _encode_rsa_jwt( + private_key, + issuer: str, + audience: str, + kid: str, + extra_claims: Optional[dict] = None, +) -> str: + private_key_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + current_time = int(time.time()) + claims = { + "sub": "test-subject", + "iss": issuer, + "aud": audience, + "iat": current_time, + "exp": current_time + 300, + } + if extra_claims: + claims.update(extra_claims) + + return jwt.encode( + claims, + private_key_pem, + algorithm="RS256", + headers={"kid": kid}, + ) + + +def _get_jwt_handler_with_issuer_keys(issuers: list, keys_by_url: dict) -> JWTHandler: + cache = DualCache() + for jwks_url, keys in keys_by_url.items(): + cache.set_cache( + key=f"litellm_jwt_auth_keys_{jwks_url}", + value=keys, + ) + + jwt_handler = JWTHandler() + jwt_handler.update_environment( + prisma_client=None, + user_api_key_cache=cache, + litellm_jwtauth=LiteLLM_JWTAuth(issuers=issuers), + ) + return jwt_handler + + +@pytest.mark.asyncio +async def test_multi_issuer_jwt_validates_selected_issuer_and_maps_claims( + monkeypatch, +): + monkeypatch.delenv("JWT_AUDIENCE", raising=False) + monkeypatch.delenv("JWT_PUBLIC_KEY_URL", raising=False) + + issuer_one = "https://issuer-one.example.com" + issuer_two = "https://issuer-two.example.com" + issuer_one_jwks_url = f"{issuer_one}/keys" + issuer_two_jwks_url = f"{issuer_two}/keys" + shared_kid = "shared-kid" + + _, issuer_one_jwk = _get_rsa_key_and_jwk(kid=shared_kid) + issuer_two_private_key, issuer_two_jwk = _get_rsa_key_and_jwk(kid=shared_kid) + + jwt_handler = _get_jwt_handler_with_issuer_keys( + issuers=[ + { + "issuer": issuer_one, + "jwks_url": issuer_one_jwks_url, + "audience": "audience-one", + "user_id_jwt_field": "email", + "user_email_jwt_field": "email", + }, + { + "issuer": issuer_two, + "jwks_url": issuer_two_jwks_url, + "audience": "audience-two", + "user_id_jwt_field": "repository_owner", + "team_id_jwt_field": "repository", + }, + ], + keys_by_url={ + issuer_one_jwks_url: [issuer_one_jwk], + issuer_two_jwks_url: [issuer_two_jwk], + }, + ) + + token = _encode_rsa_jwt( + private_key=issuer_two_private_key, + issuer=issuer_two, + audience="audience-two", + kid=shared_kid, + extra_claims={ + "repository_owner": "example-org", + "repository": "example-org/litellm-fork", + }, + ) + + claims = await jwt_handler.auth_jwt(token=token) + + assert claims[JWTHandler.LITELLM_JWT_ISSUER_CLAIM] == issuer_two + assert jwt_handler.get_user_id(token=claims, default_value=None) == ("example-org") + assert jwt_handler.get_team_id(token=claims, default_value=None) == ( + "example-org/litellm-fork" + ) + + +@pytest.mark.asyncio +async def test_multi_issuer_jwt_maps_kubernetes_namespace_claim(monkeypatch): + monkeypatch.delenv("JWT_AUDIENCE", raising=False) + monkeypatch.delenv("JWT_PUBLIC_KEY_URL", raising=False) + + issuer = "https://oidc.eks.eu-west-1.amazonaws.com/id/test-cluster" + jwks_url = f"{issuer}/keys" + private_key, jwk = _get_rsa_key_and_jwk(kid="k8s-key") + jwt_handler = _get_jwt_handler_with_issuer_keys( + issuers=[ + { + "issuer": issuer, + "jwks_url": jwks_url, + "audience": None, + "disable_audience_validation": True, + "user_id_jwt_field": "kubernetes\\.io.namespace", + } + ], + keys_by_url={jwks_url: [jwk]}, + ) + token = _encode_rsa_jwt( + private_key=private_key, + issuer=issuer, + audience="kubernetes.default.svc", + kid="k8s-key", + extra_claims={"kubernetes.io": {"namespace": "example-namespace"}}, + ) + + claims = await jwt_handler.auth_jwt(token=token) + + assert ( + jwt_handler.get_user_id(token=claims, default_value=None) == "example-namespace" + ) + + +@pytest.mark.asyncio +async def test_multi_issuer_jwt_falls_back_to_global_jwks_for_unknown_issuer( + monkeypatch, +): + """Unknown ``iss`` claims fall through to the global ``JWT_PUBLIC_KEY_URL`` + path so adding the new ``issuers`` config to a live deployment doesn't + break tokens minted by issuers that still rely on the legacy global JWKS. + """ + monkeypatch.delenv("JWT_AUDIENCE", raising=False) + monkeypatch.delenv("JWT_PUBLIC_KEY_URL", raising=False) + + configured_issuer = "https://issuer.example.com" + unknown_issuer = "https://unknown-issuer.example.com" + global_jwks_url = "https://global.example.com/keys" + monkeypatch.setenv("JWT_PUBLIC_KEY_URL", global_jwks_url) + + configured_private_key, configured_jwk = _get_rsa_key_and_jwk(kid="configured-key") + unknown_private_key, unknown_jwk = _get_rsa_key_and_jwk(kid="global-key") + jwt_handler = _get_jwt_handler_with_issuer_keys( + issuers=[ + { + "issuer": configured_issuer, + "jwks_url": f"{configured_issuer}/keys", + "audience": "expected-audience", + } + ], + keys_by_url={ + f"{configured_issuer}/keys": [configured_jwk], + global_jwks_url: [unknown_jwk], + }, + ) + token = _encode_rsa_jwt( + private_key=unknown_private_key, + issuer=unknown_issuer, + audience="expected-audience", + kid="global-key", + ) + + claims = await jwt_handler.auth_jwt(token=token) + + assert claims["iss"] == unknown_issuer + + +@pytest.mark.asyncio +async def test_multi_issuer_jwt_unknown_issuer_without_global_jwks_rejected( + monkeypatch, +): + """When there is no ``JWT_PUBLIC_KEY_URL`` to fall back to, an unknown + ``iss`` claim still fails — the fallback path raises ``Missing JWT + Public Key URL`` rather than the legacy ``Unsupported JWT issuer``. + """ + monkeypatch.delenv("JWT_AUDIENCE", raising=False) + monkeypatch.delenv("JWT_PUBLIC_KEY_URL", raising=False) + + configured_issuer = "https://issuer.example.com" + private_key, jwk = _get_rsa_key_and_jwk(kid="issuer-key") + jwt_handler = _get_jwt_handler_with_issuer_keys( + issuers=[ + { + "issuer": configured_issuer, + "jwks_url": f"{configured_issuer}/keys", + "audience": "expected-audience", + } + ], + keys_by_url={f"{configured_issuer}/keys": [jwk]}, + ) + token = _encode_rsa_jwt( + private_key=private_key, + issuer="https://unknown-issuer.example.com", + audience="expected-audience", + kid="issuer-key", + ) + + with pytest.raises(Exception) as exc: + await jwt_handler.auth_jwt(token=token) + + assert "Missing JWT Public Key URL" in str(exc.value) + + +@pytest.mark.asyncio +async def test_multi_issuer_jwt_rejects_wrong_audience(monkeypatch): + monkeypatch.delenv("JWT_AUDIENCE", raising=False) + monkeypatch.delenv("JWT_PUBLIC_KEY_URL", raising=False) + + issuer = "https://issuer.example.com" + jwks_url = f"{issuer}/keys" + private_key, jwk = _get_rsa_key_and_jwk(kid="issuer-key") + jwt_handler = _get_jwt_handler_with_issuer_keys( + issuers=[ + { + "issuer": issuer, + "jwks_url": jwks_url, + "audience": "expected-audience", + } + ], + keys_by_url={jwks_url: [jwk]}, + ) + token = _encode_rsa_jwt( + private_key=private_key, + issuer=issuer, + audience="wrong-audience", + kid="issuer-key", + ) + + with pytest.raises(Exception) as exc: + await jwt_handler.auth_jwt(token=token) + + assert "Validation fails" in str(exc.value) + + +@pytest.mark.asyncio +async def test_multi_issuer_jwt_same_kid_does_not_cross_issuer_keys(monkeypatch): + monkeypatch.delenv("JWT_AUDIENCE", raising=False) + monkeypatch.delenv("JWT_PUBLIC_KEY_URL", raising=False) + + issuer_one = "https://issuer-one.example.com" + issuer_two = "https://issuer-two.example.com" + issuer_one_jwks_url = f"{issuer_one}/keys" + issuer_two_jwks_url = f"{issuer_two}/keys" + shared_kid = "shared-kid" + issuer_one_private_key, issuer_one_jwk = _get_rsa_key_and_jwk(kid=shared_kid) + _, issuer_two_jwk = _get_rsa_key_and_jwk(kid=shared_kid) + jwt_handler = _get_jwt_handler_with_issuer_keys( + issuers=[ + { + "issuer": issuer_one, + "jwks_url": issuer_one_jwks_url, + "audience": "audience-one", + }, + { + "issuer": issuer_two, + "jwks_url": issuer_two_jwks_url, + "audience": "audience-two", + }, + ], + keys_by_url={ + issuer_one_jwks_url: [issuer_one_jwk], + issuer_two_jwks_url: [issuer_two_jwk], + }, + ) + token = _encode_rsa_jwt( + private_key=issuer_one_private_key, + issuer=issuer_two, + audience="audience-two", + kid=shared_kid, + ) + + with pytest.raises(Exception) as exc: + await jwt_handler.auth_jwt(token=token) + + assert "Validation fails" in str(exc.value) + + +@pytest.mark.asyncio +async def test_multi_issuer_jwt_missing_mapped_claim_is_optional(monkeypatch): + """Configured issuer claim mappings are advisory, not mandatory. + + When the token simply omits a mapped field (e.g. a service-to-service token + with no ``email`` claim), JWT auth still succeeds and the normalized claim + is just absent — matching the global ``litellm_jwtauth`` behaviour. + """ + monkeypatch.delenv("JWT_AUDIENCE", raising=False) + monkeypatch.delenv("JWT_PUBLIC_KEY_URL", raising=False) + + issuer = "https://issuer.example.com" + jwks_url = f"{issuer}/keys" + private_key, jwk = _get_rsa_key_and_jwk(kid="issuer-key") + jwt_handler = _get_jwt_handler_with_issuer_keys( + issuers=[ + { + "issuer": issuer, + "jwks_url": jwks_url, + "audience": "expected-audience", + "user_id_jwt_field": "email", + } + ], + keys_by_url={jwks_url: [jwk]}, + ) + token = _encode_rsa_jwt( + private_key=private_key, + issuer=issuer, + audience="expected-audience", + kid="issuer-key", + ) + + claims = await jwt_handler.auth_jwt(token=token) + + assert claims[JWTHandler.LITELLM_JWT_ISSUER_CLAIM] == issuer + assert JWTHandler.LITELLM_USER_ID_CLAIM not in claims + + +def test_multi_issuer_jwt_requires_audience_unless_explicitly_disabled( + monkeypatch, +): + monkeypatch.delenv("JWT_AUDIENCE", raising=False) + monkeypatch.delenv("JWT_PUBLIC_KEY_URL", raising=False) + + issuer = "https://issuer.example.com" + jwks_url = f"{issuer}/keys" + + with pytest.raises(Exception) as exc: + LiteLLM_JWTAuth( + issuers=[ + { + "issuer": issuer, + "jwks_url": jwks_url, + } + ] + ) + + assert "must configure audience" in str(exc.value) + + +@pytest.mark.asyncio +async def test_global_jwt_ignores_user_supplied_internal_claims(monkeypatch): + monkeypatch.delenv("JWT_AUDIENCE", raising=False) + monkeypatch.delenv("JWT_ISSUER", raising=False) + + jwks_url = "https://global-issuer.example.com/keys" + monkeypatch.setenv("JWT_PUBLIC_KEY_URL", jwks_url) + + private_key, jwk = _get_rsa_key_and_jwk(kid="global-key") + cache = DualCache() + cache.set_cache(key=f"litellm_jwt_auth_keys_{jwks_url}", value=[jwk]) + + jwt_handler = JWTHandler() + jwt_handler.update_environment( + prisma_client=None, + user_api_key_cache=cache, + litellm_jwtauth=LiteLLM_JWTAuth( + user_id_jwt_field="email", + user_email_jwt_field="email", + team_id_jwt_field="team.id", + team_ids_jwt_field="teams", + org_id_jwt_field="org.id", + end_user_id_jwt_field="end_user.id", + ), + ) + token = _encode_rsa_jwt( + private_key=private_key, + issuer="https://global-issuer.example.com", + audience="some-other-client", + kid="global-key", + extra_claims={ + "email": "real-user@example.com", + "team": {"id": "real-team"}, + "teams": ["real-team", "secondary-team"], + "org": {"id": "real-org"}, + "end_user": {"id": "real-end-user"}, + JWTHandler.LITELLM_JWT_ISSUER_CLAIM: "https://issuer.example.com", + JWTHandler.LITELLM_USER_ID_CLAIM: "victim-user", + JWTHandler.LITELLM_USER_EMAIL_CLAIM: "victim@example.com", + JWTHandler.LITELLM_TEAM_ID_CLAIM: "victim-team", + JWTHandler.LITELLM_TEAM_IDS_CLAIM: ["victim-team"], + JWTHandler.LITELLM_ORG_ID_CLAIM: "victim-org", + JWTHandler.LITELLM_END_USER_ID_CLAIM: "victim-end-user", + }, + ) + + claims = await jwt_handler.auth_jwt(token=token) + + assert jwt_handler.get_user_id(token=claims, default_value=None) == ( + "real-user@example.com" + ) + assert jwt_handler.get_user_email(token=claims, default_value=None) == ( + "real-user@example.com" + ) + assert jwt_handler.get_team_id(token=claims, default_value=None) == "real-team" + assert jwt_handler.get_team_ids_from_jwt(token=claims) == [ + "real-team", + "secondary-team", + ] + assert jwt_handler.get_org_id(token=claims, default_value=None) == "real-org" + assert jwt_handler.get_end_user_id(token=claims, default_value=None) == ( + "real-end-user" + ) + + +@pytest.mark.asyncio +async def test_multi_issuer_jwt_strips_unmapped_internal_claims(monkeypatch): + monkeypatch.delenv("JWT_AUDIENCE", raising=False) + monkeypatch.delenv("JWT_PUBLIC_KEY_URL", raising=False) + + issuer = "https://issuer.example.com" + jwks_url = f"{issuer}/keys" + private_key, jwk = _get_rsa_key_and_jwk(kid="issuer-key") + jwt_handler = _get_jwt_handler_with_issuer_keys( + issuers=[ + { + "issuer": issuer, + "jwks_url": jwks_url, + "audience": "expected-audience", + "user_email_jwt_field": "email", + } + ], + keys_by_url={jwks_url: [jwk]}, + ) + token = _encode_rsa_jwt( + private_key=private_key, + issuer=issuer, + audience="expected-audience", + kid="issuer-key", + extra_claims={ + "email": "real-user@example.com", + JWTHandler.LITELLM_USER_ID_CLAIM: "victim-user", + JWTHandler.LITELLM_TEAM_ID_CLAIM: "victim-team", + }, + ) + + claims = await jwt_handler.auth_jwt(token=token) + + assert JWTHandler.LITELLM_USER_ID_CLAIM not in claims + assert JWTHandler.LITELLM_TEAM_ID_CLAIM not in claims + assert jwt_handler.get_user_id(token=claims, default_value=None) is None + assert jwt_handler.get_team_id(token=claims, default_value=None) is None + assert jwt_handler.get_user_email(token=claims, default_value=None) == ( + "real-user@example.com" + ) + + +@pytest.mark.asyncio +async def test_multi_issuer_jwt_does_not_emit_unscoped_global_warning( + monkeypatch, caplog +): + monkeypatch.delenv("JWT_AUDIENCE", raising=False) + monkeypatch.delenv("JWT_ISSUER", raising=False) + monkeypatch.delenv("JWT_PUBLIC_KEY_URL", raising=False) + JWTHandler._unscoped_jwt_warning_emitted = False + + issuer = "https://issuer.example.com" + jwks_url = f"{issuer}/keys" + private_key, jwk = _get_rsa_key_and_jwk(kid="issuer-key") + jwt_handler = _get_jwt_handler_with_issuer_keys( + issuers=[ + { + "issuer": issuer, + "jwks_url": jwks_url, + "audience": "expected-audience", + } + ], + keys_by_url={jwks_url: [jwk]}, + ) + token = _encode_rsa_jwt( + private_key=private_key, + issuer=issuer, + audience="expected-audience", + kid="issuer-key", + ) + + with caplog.at_level(logging.WARNING): + await jwt_handler.auth_jwt(token=token) + + assert "Tokens minted by any application" not in caplog.text + assert JWTHandler._unscoped_jwt_warning_emitted is False diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py index 95c826daa8e..7753378ab4f 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py @@ -1,12 +1,9 @@ import json import os import sys -from unittest import mock -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, call as mock_call, patch -import orjson import pytest -from fastapi import FastAPI, Request from fastapi.testclient import TestClient sys.path.insert( @@ -19,7 +16,6 @@ MCPRequestHandler, ) from litellm.proxy._types import SpecialHeaders, UserAPIKeyAuth -from litellm.proxy.auth.user_api_key_auth import user_api_key_auth @pytest.mark.asyncio @@ -453,7 +449,7 @@ async def mock_user_api_key_auth(api_key, request): with patch( "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_user_api_key_auth, - ) as mock_auth: + ): # Call the method ( auth_result, @@ -998,6 +994,284 @@ async def test_legitimate_well_known_path_still_bypasses_auth(self): assert isinstance(auth_result, UserAPIKeyAuth) +@pytest.mark.asyncio +class TestMCPPassthroughColdStartAdmission: + @staticmethod + def _make_passthrough_server(): + server = MagicMock() + server.is_oauth_passthrough = True + return server + + async def test_cold_start_ignores_header_without_path_target(self): + from fastapi import HTTPException + + scope = { + "type": "http", + "method": "POST", + "path": "/mcp", + "headers": [(b"x-mcp-servers", b"passthrough_server")], + } + + async def mock_user_api_key_auth_fails(api_key, request): + raise HTTPException(status_code=401, detail="Invalid API key") + + with ( + patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", + side_effect=mock_user_api_key_auth_fails, + ), + patch( + "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" + ) as mock_mgr, + patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp._is_mcp_passthrough_cold_start" + ) as mock_cold_start, + ): + mock_mgr.get_mcp_server_by_name.return_value = ( + TestMCPPassthroughColdStartAdmission._make_passthrough_server() + ) + with pytest.raises(HTTPException) as exc_info: + await MCPRequestHandler.process_mcp_request(scope) + + assert exc_info.value.status_code == 401 + # Cold-start admission must not fire for the aggregate ``/mcp`` + # route — only path-targeted routes are eligible for OAuth + # discovery admission. + mock_cold_start.assert_not_called() + + async def test_cold_start_rejects_server_specific_authorization_header(self): + from fastapi import HTTPException + + scope = { + "type": "http", + "method": "POST", + "path": "/mcp/passthrough_server", + "headers": [ + ( + b"x-mcp-passthrough_server-authorization", + b"Bearer upstream-token", + ) + ], + } + + async def mock_user_api_key_auth_fails(api_key, request): + raise HTTPException(status_code=401, detail="Invalid API key") + + with ( + patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", + side_effect=mock_user_api_key_auth_fails, + ), + patch( + "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" + ) as mock_mgr, + ): + mock_mgr.get_mcp_server_by_name.return_value = ( + TestMCPPassthroughColdStartAdmission._make_passthrough_server() + ) + with pytest.raises(HTTPException) as exc_info: + await MCPRequestHandler.process_mcp_request(scope) + + assert exc_info.value.status_code == 401 + + async def test_cold_start_rejects_legacy_mcp_auth_header(self): + from fastapi import HTTPException + + scope = { + "type": "http", + "method": "POST", + "path": "/mcp/passthrough_server", + "headers": [(b"x-mcp-auth", b"Bearer upstream-token")], + } + + async def mock_user_api_key_auth_fails(api_key, request): + raise HTTPException(status_code=401, detail="Invalid API key") + + with ( + patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", + side_effect=mock_user_api_key_auth_fails, + ), + patch( + "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" + ) as mock_mgr, + ): + mock_mgr.get_mcp_server_by_name.return_value = ( + TestMCPPassthroughColdStartAdmission._make_passthrough_server() + ) + with pytest.raises(HTTPException) as exc_info: + await MCPRequestHandler.process_mcp_request(scope) + + assert exc_info.value.status_code == 401 + + async def test_cold_start_fails_closed_when_client_ip_hides_server(self): + from fastapi import HTTPException + + scope = { + "type": "http", + "method": "POST", + "path": "/mcp/passthrough_server", + "headers": [], + } + + async def mock_user_api_key_auth_fails(api_key, request): + raise HTTPException(status_code=401, detail="Invalid API key") + + with ( + patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", + side_effect=mock_user_api_key_auth_fails, + ), + patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.IPAddressUtils.get_mcp_client_ip", + return_value="203.0.113.10", + ), + patch( + "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" + ) as mock_mgr, + ): + mock_mgr.get_mcp_server_by_name.return_value = None + with pytest.raises(HTTPException) as exc_info: + await MCPRequestHandler.process_mcp_request(scope) + + assert exc_info.value.status_code == 401 + mock_mgr.get_mcp_server_by_name.assert_any_call( + "passthrough_server", client_ip="203.0.113.10" + ) + + async def test_cold_start_propagates_non_401_http_error(self): + from fastapi import HTTPException + + scope = { + "type": "http", + "method": "POST", + "path": "/mcp/passthrough_server", + "headers": [], + } + + async def mock_user_api_key_auth_forbidden(api_key, request): + raise HTTPException(status_code=403, detail="Forbidden") + + with ( + patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", + side_effect=mock_user_api_key_auth_forbidden, + ), + patch( + "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" + ) as mock_mgr, + ): + mock_mgr.get_mcp_server_by_name.return_value = ( + TestMCPPassthroughColdStartAdmission._make_passthrough_server() + ) + with pytest.raises(HTTPException) as exc_info: + await MCPRequestHandler.process_mcp_request(scope) + + assert exc_info.value.status_code == 403 + + async def test_cold_start_propagates_non_auth_proxy_exception(self): + from litellm.proxy._types import ProxyException + + scope = { + "type": "http", + "method": "POST", + "path": "/mcp/passthrough_server", + "headers": [], + } + + async def mock_user_api_key_auth_server_error(api_key, request): + raise ProxyException( + message="Internal error", + type="server_error", + param=None, + code=500, + ) + + with ( + patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", + side_effect=mock_user_api_key_auth_server_error, + ), + patch( + "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" + ) as mock_mgr, + ): + mock_mgr.get_mcp_server_by_name.return_value = ( + TestMCPPassthroughColdStartAdmission._make_passthrough_server() + ) + with pytest.raises(ProxyException): + await MCPRequestHandler.process_mcp_request(scope) + + async def test_cold_start_allows_401_for_path_passthrough_target(self): + from fastapi import HTTPException + + scope = { + "type": "http", + "method": "POST", + "path": "/mcp/passthrough_server", + "headers": [], + } + + async def mock_user_api_key_auth_fails(api_key, request): + raise HTTPException(status_code=401, detail="Invalid API key") + + with ( + patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", + side_effect=mock_user_api_key_auth_fails, + ), + patch( + "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" + ) as mock_mgr, + ): + mock_mgr.get_mcp_server_by_name.return_value = ( + TestMCPPassthroughColdStartAdmission._make_passthrough_server() + ) + auth_result, *_rest = await MCPRequestHandler.process_mcp_request(scope) + + assert isinstance(auth_result, UserAPIKeyAuth) + mock_mgr.get_mcp_server_by_name.assert_any_call( + "passthrough_server", client_ip="" + ) + + async def test_cold_start_allows_proxy_exception_401_for_path_target(self): + from litellm.proxy._types import ProxyException + + scope = { + "type": "http", + "method": "POST", + "path": "/mcp/passthrough_server", + "headers": [], + } + + async def mock_user_api_key_auth_fails(api_key, request): + raise ProxyException( + message="Authentication Error", + type="auth_error", + param="api_key", + code=401, + ) + + with ( + patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", + side_effect=mock_user_api_key_auth_fails, + ), + patch( + "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" + ) as mock_mgr, + ): + mock_mgr.get_mcp_server_by_name.return_value = ( + TestMCPPassthroughColdStartAdmission._make_passthrough_server() + ) + auth_result, *_rest = await MCPRequestHandler.process_mcp_request(scope) + + assert isinstance(auth_result, UserAPIKeyAuth) + mock_mgr.get_mcp_server_by_name.assert_any_call( + "passthrough_server", client_ip="" + ) + + @pytest.mark.asyncio class TestMCPOAuth2FallbackTargetGating: """ @@ -1009,9 +1283,14 @@ class TestMCPOAuth2FallbackTargetGating: """ @staticmethod - def _make_server(auth_type): + def _make_server(auth_type, is_oauth_passthrough=False): server = MagicMock() server.auth_type = auth_type + # MagicMock would otherwise auto-create truthy stand-ins for any + # attribute access (including ``is_oauth_passthrough``), which + # would silently flip the passthrough fallback gate on. Pin the + # boolean explicitly so non-passthrough fixtures stay non-passthrough. + server.is_oauth_passthrough = is_oauth_passthrough return server async def test_fallback_blocked_when_target_is_not_oauth2(self): @@ -1113,6 +1392,88 @@ async def mock_user_api_key_auth_fails(api_key, request): auth_result, *_rest = await MCPRequestHandler.process_mcp_request(scope) assert isinstance(auth_result, UserAPIKeyAuth) + async def test_fallback_allowed_when_target_is_passthrough(self): + """ + Cold-start return per RFC 9728 / MCP Authorization spec: client + discovered the upstream IdP via the gateway's protected-resource + metadata, completed OAuth, and is returning with + ``Authorization: Bearer ``. The bearer is not a + LiteLLM key but the target is a pass-through server, so admission + falls back to anonymous and forwards the bearer upstream. + """ + from fastapi import HTTPException + + from litellm.types.mcp import MCPAuth + + scope = { + "type": "http", + "method": "POST", + "path": "/mcp/passthrough_server", + "headers": [(b"authorization", b"Bearer upstream-token-xyz")], + } + + async def mock_user_api_key_auth_fails(api_key, request): + raise HTTPException(status_code=401, detail="Invalid API key") + + with ( + patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", + side_effect=mock_user_api_key_auth_fails, + ), + patch( + "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" + ) as mock_mgr, + ): + mock_mgr.get_mcp_server_by_name.return_value = ( + TestMCPOAuth2FallbackTargetGating._make_server( + auth_type=MCPAuth.none, + is_oauth_passthrough=True, + ) + ) + auth_result, *_rest = await MCPRequestHandler.process_mcp_request(scope) + assert isinstance(auth_result, UserAPIKeyAuth) + assert auth_result.api_key is None + + async def test_fallback_blocked_when_client_ip_hides_oauth2_target(self): + from fastapi import HTTPException + + scope = { + "type": "http", + "method": "POST", + "path": "/mcp/hidden_oauth2_server", + "headers": [(b"authorization", b"Bearer upstream-token")], + } + + async def mock_user_api_key_auth_fails(api_key, request): + raise HTTPException(status_code=401, detail="Invalid API key") + + with ( + patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", + side_effect=mock_user_api_key_auth_fails, + ), + patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.IPAddressUtils.get_mcp_client_ip", + return_value="203.0.113.10", + ), + patch( + "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" + ) as mock_mgr, + ): + mock_mgr.get_mcp_server_by_name.return_value = None + with pytest.raises(HTTPException) as exc_info: + await MCPRequestHandler.process_mcp_request(scope) + + assert exc_info.value.status_code == 401 + # Lookup may run twice — once for the oauth2-target fallback gate + # and once for the passthrough-target fallback gate. Both must + # resolve to ``None`` (hidden by client IP) so neither bypass + # opens. Use ``assert_any_call`` to assert the IP-scoped lookup + # happened without locking the count. + mock_mgr.get_mcp_server_by_name.assert_any_call( + "hidden_oauth2_server", client_ip="203.0.113.10" + ) + async def test_fallback_blocked_when_any_target_in_header_is_not_oauth2(self): """ x-mcp-servers can list multiple targets. If ANY of them is non-OAuth2, @@ -1241,6 +1602,39 @@ def test_build_mcp_server_table_preserves_delegate_auth_to_upstream(self): is False ) + def test_build_mcp_server_table_preserves_oauth_passthrough(self): + """Registry → API list rows must expose oauth_passthrough for the UI. + + ``oauth_passthrough`` is the dedicated non-oauth2 pass-through opt-in, + distinct from ``delegate_auth_to_upstream`` (oauth2-only). Both must + round-trip independently so neither flag silently implies the other. + """ + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + MCPServerManager, + ) + from litellm.types.mcp import MCPAuth + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + manager = MCPServerManager() + passthrough = MCPServer( + server_id="passthrough-1", + name="passthrough", + transport="http", + auth_type=MCPAuth.none, + extra_headers=["Authorization"], + oauth_passthrough=True, + available_on_public_internet=True, + ) + row = manager._build_mcp_server_table(passthrough) + assert row.oauth_passthrough is True + # The oauth2-only flag must remain independent and default off. + assert row.delegate_auth_to_upstream is False + + not_passthrough = passthrough.model_copy(update={"oauth_passthrough": False}) + assert ( + manager._build_mcp_server_table(not_passthrough).oauth_passthrough is False + ) + async def test_delegate_skips_litellm_auth_with_no_authorization(self): """ oauth2 + delegate_auth_to_upstream=True, no Authorization header at @@ -1806,9 +2200,12 @@ async def test_delegate_does_not_bypass_on_extra_path_segment(self): delegate_auth_to_upstream=True, ) - def lookup_by_name(name): + def lookup_by_name(name, **_kwargs): # Only the *exact* delegated name resolves. Anything else (e.g. # ``delegated_server/extra``) returns None so the bypass fails. + # ``**_kwargs`` accepts the ``client_ip`` kwarg the cold-start + # admission path now forwards (real signature: + # ``get_mcp_server_by_name(name, client_ip=None)``). if name == "delegated_server": return delegate_server return None @@ -1869,7 +2266,10 @@ async def test_delegate_ignores_x_mcp_servers_header_for_mcp_paths(self): auth_type=MCPAuth.api_key, ) - def lookup_by_name(name): + def lookup_by_name(name, **_kwargs): + # ``**_kwargs`` accepts the ``client_ip`` kwarg the cold-start + # admission path now forwards (real signature: + # ``get_mcp_server_by_name(name, client_ip=None)``). return { "delegated_server": delegate_server, "non_delegate_server": non_delegate, @@ -2342,7 +2742,6 @@ async def mock_user_api_key_auth(api_key, request): mock_auth.assert_called_once() -@pytest.mark.asyncio def test_mcp_path_based_server_segregation(monkeypatch): # Import the MCP server FastAPI app and context getter from litellm.proxy._experimental.mcp_server.server import app, get_auth_context @@ -2956,6 +3355,89 @@ async def test_get_allowed_tools_for_server_agent_no_restriction(self): ) assert sorted(result) == ["tool_a", "tool_b"] + async def test_get_agent_object_permission_uses_shared_helper(self): + """``_get_agent_object_permission`` must resolve the agent's + ``object_permission_id`` and then defer to the shared + ``get_object_permission`` helper so cache entries are shared with the + org / team / key paths.""" + from litellm.caching.dual_cache import DualCache + + cache = DualCache() + agent_row = MagicMock() + agent_row.object_permission_id = "perm-xyz" + prisma_client = MagicMock() + prisma_client.db.litellm_agentstable.find_unique = AsyncMock( + return_value=agent_row + ) + user_api_key_auth = UserAPIKeyAuth( + api_key="test-key", + user_id="test-user", + agent_id="agent-shared", + ) + expected_perm = MagicMock() + + with ( + patch("litellm.proxy.proxy_server.prisma_client", prisma_client), + patch("litellm.proxy.proxy_server.user_api_key_cache", cache), + patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock()), + patch( + "litellm.proxy.auth.auth_checks.get_object_permission", + new_callable=AsyncMock, + return_value=expected_perm, + ) as mock_get_perm, + ): + result = await MCPRequestHandler._get_agent_object_permission( + user_api_key_auth + ) + assert result is expected_perm + mock_get_perm.assert_awaited_once() + assert mock_get_perm.await_args.kwargs["object_permission_id"] == "perm-xyz" + + # Second call: the agent_id -> object_permission_id mapping is + # cached, so the agent row is not re-fetched. + prisma_client.db.litellm_agentstable.find_unique.reset_mock() + await MCPRequestHandler._get_agent_object_permission(user_api_key_auth) + prisma_client.db.litellm_agentstable.find_unique.assert_not_called() + + async def test_get_agent_object_permission_caches_missing_permission(self): + """When the agent has no ``object_permission_id`` the sentinel must be + cached so subsequent requests do not hit the DB again.""" + from litellm.caching.dual_cache import DualCache + + cache = DualCache() + agent_row = MagicMock() + agent_row.object_permission_id = None + prisma_client = MagicMock() + prisma_client.db.litellm_agentstable.find_unique = AsyncMock( + return_value=agent_row + ) + user_api_key_auth = UserAPIKeyAuth( + api_key="test-key", + user_id="test-user", + agent_id="agent-no-perm", + ) + + with ( + patch("litellm.proxy.proxy_server.prisma_client", prisma_client), + patch("litellm.proxy.proxy_server.user_api_key_cache", cache), + patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock()), + patch( + "litellm.proxy.auth.auth_checks.get_object_permission", + new_callable=AsyncMock, + ) as mock_get_perm, + ): + assert ( + await MCPRequestHandler._get_agent_object_permission(user_api_key_auth) + is None + ) + assert ( + await MCPRequestHandler._get_agent_object_permission(user_api_key_auth) + is None + ) + + mock_get_perm.assert_not_awaited() + prisma_client.db.litellm_agentstable.find_unique.assert_awaited_once() + @pytest.mark.asyncio async def test_tool_permission_servers_included_in_allowed_servers(): diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py index c8789e0b0a6..da66d60aed8 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py @@ -1515,7 +1515,7 @@ async def test_oauth_protected_resource_returns_empty_scopes_when_none(): mock_request.headers = {} try: - response = _build_oauth_protected_resource_response( + response = await _build_oauth_protected_resource_response( request=mock_request, mcp_server_name="atlassian_mcp", use_standard_pattern=False, @@ -2005,7 +2005,7 @@ async def test_discovery_root_does_not_expose_private_server_for_external_client request=mock_request, mcp_server_name=None, ) - resource_response = _build_oauth_protected_resource_response( + resource_response = await _build_oauth_protected_resource_response( request=mock_request, mcp_server_name=None, use_standard_pattern=False, diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough.py new file mode 100644 index 00000000000..ad78609ee18 --- /dev/null +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough.py @@ -0,0 +1,474 @@ +"""Unit tests for MCP OAuth passthrough metadata behavior. + +Covers: +- `MCPServer.is_oauth_passthrough` property semantics. +- `/.well-known/oauth-protected-resource/...` pass-through branch (proxies + upstream metadata, normalizes the `resource` field, caches, and surfaces + network errors as HTTP 502). +""" + +import asyncio +import sys +import time +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest +from fastapi import HTTPException, Request + +sys.path.insert(0, "../../../../../") + + +from litellm.proxy._experimental.mcp_server import discoverable_endpoints +from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + _OAUTH_METADATA_CACHE, + _OAUTH_METADATA_FETCH_LOCKS, + _build_oauth_protected_resource_response, +) +from litellm.proxy._types import MCPTransport +from litellm.types.mcp import MCPAuth +from litellm.types.mcp_server.mcp_server_manager import MCPServer + + +@pytest.fixture(autouse=True) +def _mock_mcp_client_ip(): + """Bypass IP-based access control in tests.""" + with patch( + "litellm.proxy._experimental.mcp_server.discoverable_endpoints" + ".IPAddressUtils.get_mcp_client_ip", + return_value=None, + ): + yield + + +@pytest.fixture(autouse=True) +def _clear_metadata_cache(): + """Prevent cross-test cache bleed for the oauth-protected-resource TTL cache.""" + _OAUTH_METADATA_CACHE.clear() + _OAUTH_METADATA_FETCH_LOCKS.clear() + yield + _OAUTH_METADATA_CACHE.clear() + _OAUTH_METADATA_FETCH_LOCKS.clear() + + +def _make_request(base_url: str = "https://gateway.example.com/") -> Request: + request = MagicMock(spec=Request) + request.base_url = base_url + request.headers = {} + return request + + +# -------------------------------------------------------------------------- +# is_oauth_passthrough property +# -------------------------------------------------------------------------- + + +def test_is_oauth_passthrough_true_when_none_auth_and_authorization_header(): + server = MCPServer( + server_id="s1", + name="s1", + transport=MCPTransport.http, + auth_type=MCPAuth.none, + extra_headers=["Authorization"], + oauth_passthrough=True, + ) + assert server.is_oauth_passthrough is True + + +def test_is_oauth_passthrough_true_when_auth_type_none_and_mixed_case_header(): + server = MCPServer( + server_id="s1", + name="s1", + transport=MCPTransport.http, + auth_type=None, + extra_headers=["authorization", "x-request-id"], + oauth_passthrough=True, + ) + assert server.is_oauth_passthrough is True + + +def test_is_oauth_passthrough_false_for_oauth2_server(): + server = MCPServer( + server_id="s1", + name="s1", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + extra_headers=["Authorization"], + oauth_passthrough=True, + ) + assert server.is_oauth_passthrough is False + + +def test_is_oauth_passthrough_false_without_authorization_header(): + server = MCPServer( + server_id="s1", + name="s1", + transport=MCPTransport.http, + auth_type=MCPAuth.none, + extra_headers=["x-api-key"], + oauth_passthrough=True, + ) + assert server.is_oauth_passthrough is False + + +def test_is_oauth_passthrough_false_without_extra_headers(): + server = MCPServer( + server_id="s1", + name="s1", + transport=MCPTransport.http, + auth_type=MCPAuth.none, + oauth_passthrough=True, + ) + assert server.is_oauth_passthrough is False + + +def test_is_oauth_passthrough_false_without_oauth_passthrough_flag(): + """The detection flag must be set explicitly. Without it, the legacy + behavior is preserved for servers that forward Authorization for + non-OAuth reasons (static bearer tokens, custom auth schemes).""" + server = MCPServer( + server_id="s1", + name="s1", + transport=MCPTransport.http, + auth_type=MCPAuth.none, + extra_headers=["Authorization"], + # oauth_passthrough defaults to False + ) + assert server.is_oauth_passthrough is False + + +def test_is_oauth_passthrough_false_when_oauth_passthrough_explicitly_false(): + server = MCPServer( + server_id="s1", + name="s1", + transport=MCPTransport.http, + auth_type=MCPAuth.none, + extra_headers=["Authorization"], + oauth_passthrough=False, + ) + assert server.is_oauth_passthrough is False + + +def test_is_oauth_passthrough_false_when_only_delegate_auth_to_upstream_set(): + """Regression guard: ``delegate_auth_to_upstream`` is the oauth2-only + PKCE-bypass flag and must NOT, on its own, turn a non-oauth2 server into + an OAuth pass-through server. Pass-through requires the dedicated + ``oauth_passthrough`` opt-in. This protects existing deployments that set + ``delegate_auth_to_upstream`` from silently gaining pass-through behavior. + """ + server = MCPServer( + server_id="s1", + name="s1", + transport=MCPTransport.http, + auth_type=MCPAuth.none, + extra_headers=["Authorization"], + delegate_auth_to_upstream=True, + # oauth_passthrough intentionally left at its default (False) + ) + assert server.is_oauth_passthrough is False + + +# -------------------------------------------------------------------------- +# _build_oauth_protected_resource_response: pass-through branch +# -------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_oauth_protected_resource_passthrough_proxies_upstream_metadata(): + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + + global_mcp_server_manager.registry.clear() + passthrough_server = MCPServer( + server_id="passthrough-1", + name="sample_docs", + server_name="sample_docs", + alias="sample_docs", + url="https://upstream.example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.none, + extra_headers=["Authorization"], + oauth_passthrough=True, + ) + global_mcp_server_manager.registry[passthrough_server.server_id] = ( + passthrough_server + ) + + upstream_payload = { + "resource": "https://upstream.example.com/mcp", + "authorization_servers": ["https://okta.example.com/oauth2/default"], + "scopes_supported": ["openid", "profile"], + "bearer_methods_supported": ["header"], + } + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = upstream_payload + mock_client = MagicMock() + mock_client.get = AsyncMock(return_value=mock_response) + + with patch.object( + discoverable_endpoints, "get_async_httpx_client", return_value=mock_client + ): + result = await _build_oauth_protected_resource_response( + request=_make_request(), + mcp_server_name="sample_docs", + use_standard_pattern=True, + ) + + assert result["authorization_servers"] == [ + "https://okta.example.com/oauth2/default" + ] + # resource is normalized to the gateway URL so bearers are sent back to us + assert result["resource"].endswith("/mcp/sample_docs") + assert result["scopes_supported"] == ["openid", "profile"] + + +@pytest.mark.asyncio +async def test_oauth_protected_resource_passthrough_cache_hit(): + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + + global_mcp_server_manager.registry.clear() + passthrough_server = MCPServer( + server_id="passthrough-2", + name="sample_docs", + server_name="sample_docs", + alias="sample_docs", + url="https://upstream.example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.none, + extra_headers=["Authorization"], + oauth_passthrough=True, + ) + global_mcp_server_manager.registry[passthrough_server.server_id] = ( + passthrough_server + ) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "authorization_servers": ["https://okta.example.com"], + } + mock_client = MagicMock() + mock_client.get = AsyncMock(return_value=mock_response) + + with patch.object( + discoverable_endpoints, "get_async_httpx_client", return_value=mock_client + ): + await _build_oauth_protected_resource_response( + request=_make_request(), + mcp_server_name="sample_docs", + use_standard_pattern=True, + ) + await _build_oauth_protected_resource_response( + request=_make_request(), + mcp_server_name="sample_docs", + use_standard_pattern=True, + ) + + assert mock_client.get.await_count == 1 + + +def test_oauth_metadata_cache_prunes_to_max_size(): + now = 1_000_000.0 + max_size = discoverable_endpoints._OAUTH_METADATA_CACHE_MAX_SIZE + + for index in range(max_size + 10): + _OAUTH_METADATA_CACHE[(f"server-{index}", f"https://upstream/{index}")] = ( + now + index + 1, + {"index": index}, + ) + + discoverable_endpoints._prune_oauth_metadata_cache(now) + + assert len(_OAUTH_METADATA_CACHE) == max_size + assert ("server-0", "https://upstream/0") not in _OAUTH_METADATA_CACHE + assert ( + f"server-{max_size + 9}", + f"https://upstream/{max_size + 9}", + ) in _OAUTH_METADATA_CACHE + + +def test_oauth_metadata_fetch_locks_pruned_alongside_cache(): + now = 1_000_000.0 + cached_key = ("server-active", "https://upstream/active") + expired_key = ("server-expired", "https://upstream/expired") + orphan_key = ("server-orphan", "https://upstream/orphan") + + _OAUTH_METADATA_CACHE[cached_key] = (now + 100, {"index": 0}) + _OAUTH_METADATA_CACHE[expired_key] = (now - 1, {"index": 1}) + + _OAUTH_METADATA_FETCH_LOCKS[cached_key] = asyncio.Lock() + _OAUTH_METADATA_FETCH_LOCKS[expired_key] = asyncio.Lock() + _OAUTH_METADATA_FETCH_LOCKS[orphan_key] = asyncio.Lock() + + discoverable_endpoints._prune_oauth_metadata_cache(now) + + assert cached_key in _OAUTH_METADATA_FETCH_LOCKS + assert expired_key not in _OAUTH_METADATA_FETCH_LOCKS + assert orphan_key not in _OAUTH_METADATA_FETCH_LOCKS + + +@pytest.mark.asyncio +async def test_oauth_metadata_fetch_locks_held_lock_preserved_during_prune(): + held_key = ("server-busy", "https://upstream/busy") + held_lock = asyncio.Lock() + _OAUTH_METADATA_FETCH_LOCKS[held_key] = held_lock + + async with held_lock: + discoverable_endpoints._prune_oauth_metadata_cache(time.time()) + assert held_key in _OAUTH_METADATA_FETCH_LOCKS + + +@pytest.mark.asyncio +async def test_oauth_metadata_cache_expired_entry_is_refetched(): + passthrough_server = MCPServer( + server_id="expired-cache-server", + name="sample_docs", + server_name="sample_docs", + alias="sample_docs", + url="https://upstream.example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.none, + extra_headers=["Authorization"], + oauth_passthrough=True, + ) + _OAUTH_METADATA_CACHE[(passthrough_server.server_id, passthrough_server.url)] = ( + 0, + {"authorization_servers": ["https://stale.example.com"]}, + ) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "authorization_servers": ["https://fresh.example.com"], + } + mock_client = MagicMock() + mock_client.get = AsyncMock(return_value=mock_response) + + with patch.object( + discoverable_endpoints, "get_async_httpx_client", return_value=mock_client + ): + result = await discoverable_endpoints.fetch_upstream_oauth_protected_resource( + passthrough_server + ) + + assert result == {"authorization_servers": ["https://fresh.example.com"]} + assert mock_client.get.await_count == 1 + + +@pytest.mark.asyncio +async def test_oauth_protected_resource_passthrough_network_error_returns_502(): + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + + global_mcp_server_manager.registry.clear() + passthrough_server = MCPServer( + server_id="passthrough-3", + name="sample_docs", + server_name="sample_docs", + alias="sample_docs", + url="https://upstream.example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.none, + extra_headers=["Authorization"], + oauth_passthrough=True, + ) + global_mcp_server_manager.registry[passthrough_server.server_id] = ( + passthrough_server + ) + + mock_client = MagicMock() + mock_client.get = AsyncMock(side_effect=httpx.ConnectError("boom")) + + with patch.object( + discoverable_endpoints, "get_async_httpx_client", return_value=mock_client + ): + with pytest.raises(HTTPException) as exc_info: + await _build_oauth_protected_resource_response( + request=_make_request(), + mcp_server_name="sample_docs", + use_standard_pattern=True, + ) + + assert exc_info.value.status_code == 502 + + +@pytest.mark.asyncio +async def test_fetch_upstream_metadata_returns_none_when_not_all_candidates_network_fail(): + passthrough_server = MCPServer( + server_id="passthrough-partial-network", + name="sample_docs", + server_name="sample_docs", + alias="sample_docs", + url="https://upstream.example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.none, + extra_headers=["Authorization"], + oauth_passthrough=True, + ) + + not_found_response = MagicMock() + not_found_response.status_code = 404 + mock_client = MagicMock() + mock_client.get = AsyncMock( + side_effect=[not_found_response, httpx.ConnectError("path fallback failed")] + ) + + with patch.object( + discoverable_endpoints, "get_async_httpx_client", return_value=mock_client + ): + result = await discoverable_endpoints.fetch_upstream_oauth_protected_resource( + passthrough_server + ) + + assert result is None + assert mock_client.get.await_count == 2 + + +@pytest.mark.asyncio +async def test_oauth_protected_resource_gateway_managed_unchanged(): + """Regression guard: OAuth2 servers still advertise the gateway as AS.""" + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + + global_mcp_server_manager.registry.clear() + oauth2_server = MCPServer( + server_id="oauth2-1", + name="keycloak_whoami", + server_name="keycloak_whoami", + alias="keycloak_whoami", + url="https://upstream.example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + client_id="cid", + client_secret="cs", + authorization_url="https://keycloak/auth", + token_url="https://keycloak/token", + scopes=["read"], + ) + global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server + + # If the code mistakenly fetched upstream metadata for a gateway-managed + # server, this spy would catch it. + mock_client = MagicMock() + mock_client.get = AsyncMock() + + with patch.object( + discoverable_endpoints, "get_async_httpx_client", return_value=mock_client + ): + result = await _build_oauth_protected_resource_response( + request=_make_request(), + mcp_server_name="keycloak_whoami", + use_standard_pattern=True, + ) + + mock_client.get.assert_not_awaited() + assert result["authorization_servers"] == [ + "https://gateway.example.com/keycloak_whoami" + ] + assert result["scopes_supported"] == ["read"] diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough_cold_start.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough_cold_start.py new file mode 100644 index 00000000000..3e934577a66 --- /dev/null +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough_cold_start.py @@ -0,0 +1,156 @@ +"""Unit tests for MCP OAuth passthrough cold-start route behavior.""" + +import sys + +import pytest + +sys.path.insert(0, "../../../../../") + +from litellm.proxy._types import MCPTransport +from litellm.types.mcp import MCPAuth +from litellm.types.mcp_server.mcp_server_manager import MCPServer + + +def _make_scope(path: str, headers: list = None) -> dict: + """Build a minimal ASGI HTTP scope for testing.""" + raw_headers = [(key.encode(), value.encode()) for key, value in (headers or [])] + return { + "type": "http", + "method": "POST", + "path": path, + "headers": raw_headers, + "query_string": b"", + "server": ("localhost", 4000), + "scheme": "http", + } + + +@pytest.mark.parametrize( + "route,expected_metadata_path", + [ + ( + "/mcp/sample_docs", + "/.well-known/oauth-protected-resource/mcp/sample_docs", + ), + ( + "/sample_docs/mcp", + "/.well-known/oauth-protected-resource/sample_docs/mcp", + ), + ], +) +def test_passthrough_cold_start_emits_401_with_matching_resource_metadata( + route, expected_metadata_path +): + """No auth headers on a passthrough server route emits matching metadata.""" + from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import ( + _is_mcp_passthrough_cold_start, + _parse_mcp_server_names_from_path, + ) + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + + global_mcp_server_manager.registry.clear() + passthrough_server = MCPServer( + server_id="pt-cold-start", + name="sample_docs", + server_name="sample_docs", + alias="sample_docs", + url="https://upstream.example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.none, + extra_headers=["Authorization"], + oauth_passthrough=True, + ) + global_mcp_server_manager.registry[passthrough_server.server_id] = ( + passthrough_server + ) + + if route.startswith("/mcp/"): + scope = _make_scope(route) + else: + scope = _make_scope("/mcp/sample_docs") + scope["_original_path"] = route + + servers = _parse_mcp_server_names_from_path(scope.get("path", "")) + assert _is_mcp_passthrough_cold_start(servers, client_ip=None) is True + + server_name = "sample_docs" + base_url = "http://localhost:4000" + path = scope.get("_original_path") or scope.get("path", "") or "" + if path.startswith(f"/{server_name}/mcp"): + resource_metadata_url = ( + f"{base_url}/.well-known/oauth-protected-resource/{server_name}/mcp" + ) + else: + resource_metadata_url = ( + f"{base_url}/.well-known/oauth-protected-resource/mcp/{server_name}" + ) + + assert resource_metadata_url == f"{base_url}{expected_metadata_path}", ( + f"resource_metadata_url {resource_metadata_url!r} does not match " + f"expected {base_url + expected_metadata_path!r}" + ) + + +def test_is_mcp_passthrough_cold_start_false_for_oauth2_server(): + """Gateway-managed OAuth2 servers must not trigger the cold-start bypass.""" + from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import ( + _is_mcp_passthrough_cold_start, + ) + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + + global_mcp_server_manager.registry.clear() + oauth2_server = MCPServer( + server_id="oauth2-cold", + name="keycloak_whoami", + server_name="keycloak_whoami", + alias="keycloak_whoami", + url="https://upstream.example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + client_id="cid", + client_secret="cs", + authorization_url="https://keycloak/auth", + token_url="https://keycloak/token", + ) + global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server + + result = _is_mcp_passthrough_cold_start(["keycloak_whoami"], client_ip=None) + assert result is False + + +def test_is_mcp_passthrough_cold_start_false_for_empty_servers(): + """Aggregate /mcp route (no server list) must not trigger bypass.""" + from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import ( + _is_mcp_passthrough_cold_start, + ) + + assert _is_mcp_passthrough_cold_start(None, client_ip=None) is False + assert _is_mcp_passthrough_cold_start([], client_ip=None) is False + + +@pytest.mark.parametrize( + "path,expected", + [ + ("/mcp/sample_docs", ["sample_docs"]), + # Server names may contain at most one slash (mirrors + # ``_extract_target_server_names_from_path``), so when more than two + # segments follow ``/mcp/`` the first two are treated as the name. + ("/mcp/sample_docs/tools/list", ["sample_docs/tools"]), + ("/mcp/custom_solutions/user_123", ["custom_solutions/user_123"]), + ("/sample_docs/mcp", ["sample_docs"]), + ("/sample_docs/mcp/tools/list", ["sample_docs"]), + ("/mcp", None), + ("/mcp/", None), + ("/other/path", None), + ], +) +def test_parse_mcp_server_names_from_path(path, expected): + from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import ( + _parse_mcp_server_names_from_path, + ) + + assert _parse_mcp_server_names_from_path(path) == expected diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough_tools.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough_tools.py new file mode 100644 index 00000000000..d900f690c57 --- /dev/null +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough_tools.py @@ -0,0 +1,197 @@ +"""Unit tests for MCP OAuth passthrough tool-fetch behavior.""" + +import sys +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest + +sys.path.insert(0, "../../../../../") + +from litellm.proxy._experimental.mcp_server.exceptions import MCPUpstreamAuthError +from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + MCPServerManager, + _extract_upstream_auth_failure, +) +from litellm.proxy._types import MCPTransport +from litellm.types.mcp import MCPAuth +from litellm.types.mcp_server.mcp_server_manager import MCPServer + + +def test_extract_upstream_auth_failure_finds_401_in_http_status_error(): + response = httpx.Response( + status_code=401, + headers={"www-authenticate": 'Bearer resource_metadata="https://x"'}, + request=httpx.Request("GET", "https://upstream/mcp"), + ) + exc = httpx.HTTPStatusError("401", request=response.request, response=response) + + result = _extract_upstream_auth_failure(exc) + assert result == (401, 'Bearer resource_metadata="https://x"') + + +def test_extract_upstream_auth_failure_walks_exception_group(): + response = httpx.Response( + status_code=401, + headers={"www-authenticate": "Bearer"}, + request=httpx.Request("GET", "https://upstream/mcp"), + ) + inner = httpx.HTTPStatusError("401", request=response.request, response=response) + + try: + raise ExceptionGroup("wrapped", [inner]) # noqa: F821 (PEP 654, py3.11+) + except Exception as group: + result = _extract_upstream_auth_failure(group) + + assert result == (401, "Bearer") + + +def test_extract_upstream_auth_failure_returns_none_for_non_auth(): + assert _extract_upstream_auth_failure(RuntimeError("boom")) is None + + +@pytest.mark.asyncio +async def test_fetch_tools_from_passthrough_raises_on_upstream_401(): + manager = MCPServerManager() + passthrough_server = MCPServer( + server_id="p1", + name="sample_docs", + url="https://upstream/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.none, + extra_headers=["Authorization"], + oauth_passthrough=True, + ) + + response = httpx.Response( + status_code=401, + headers={"www-authenticate": 'Bearer resource_metadata="https://upstream"'}, + request=httpx.Request("GET", "https://upstream/mcp"), + ) + upstream_error = httpx.HTTPStatusError( + "401", request=response.request, response=response + ) + + mock_client = MagicMock() + mock_client.list_tools = AsyncMock(side_effect=upstream_error) + + with pytest.raises(MCPUpstreamAuthError) as exc_info: + await manager._fetch_tools_with_timeout( + mock_client, passthrough_server.name, server=passthrough_server + ) + + assert exc_info.value.status_code == 401 + assert exc_info.value.www_authenticate == ( + 'Bearer resource_metadata="https://upstream"' + ) + assert exc_info.value.server_name == "sample_docs" + mock_client.list_tools.assert_awaited_with(raise_on_error=True) + + +@pytest.mark.asyncio +async def test_fetch_tools_from_passthrough_returns_tools_on_success(): + manager = MCPServerManager() + passthrough_server = MCPServer( + server_id="p1", + name="sample_docs", + url="https://upstream/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.none, + extra_headers=["Authorization"], + oauth_passthrough=True, + ) + + tool = MagicMock() + tool.name = "list_documents" + mock_client = MagicMock() + mock_client.list_tools = AsyncMock(return_value=[tool]) + + tools = await manager._fetch_tools_with_timeout( + mock_client, passthrough_server.name, server=passthrough_server + ) + assert tools == [tool] + + +def test_to_http_exception_preserves_upstream_www_authenticate(): + err = MCPUpstreamAuthError( + status_code=401, + www_authenticate='Bearer resource_metadata="https://upstream/.well-known/oauth-protected-resource"', + server_name="sample_docs", + ) + + http_exc = err.to_http_exception() + assert http_exc.status_code == 401 + assert http_exc.headers == { + "www-authenticate": 'Bearer resource_metadata="https://upstream/.well-known/oauth-protected-resource"' + } + + +def test_to_http_exception_skips_fabrication_when_base_url_missing(): + """Without ``base_url`` we cannot build an RFC 9728 §3.2-compliant absolute + URI, so we omit the fabricated ``WWW-Authenticate`` challenge entirely + instead of emitting a relative URI strict clients reject.""" + err = MCPUpstreamAuthError( + status_code=401, + www_authenticate=None, + server_name="sample_docs", + ) + + http_exc = err.to_http_exception() + assert http_exc.status_code == 401 + assert http_exc.headers is None + + +def test_to_http_exception_fabricates_absolute_resource_metadata_with_base_url(): + err = MCPUpstreamAuthError( + status_code=401, + www_authenticate=None, + server_name="sample_docs", + ) + + http_exc = err.to_http_exception(base_url="https://gateway.example.com/") + assert http_exc.status_code == 401 + assert http_exc.headers == { + "www-authenticate": 'Bearer resource_metadata="https://gateway.example.com/.well-known/oauth-protected-resource/mcp/sample_docs"' + } + + +def test_to_http_exception_skips_challenge_for_non_401_status(): + err = MCPUpstreamAuthError( + status_code=403, + www_authenticate=None, + server_name="sample_docs", + ) + + http_exc = err.to_http_exception() + assert http_exc.status_code == 403 + assert http_exc.headers is None + + +@pytest.mark.asyncio +async def test_fetch_tools_from_gateway_managed_swallows_errors(): + """Regression guard: non-pass-through servers keep returning [] on errors.""" + manager = MCPServerManager() + oauth2_server = MCPServer( + server_id="o1", + name="keycloak_whoami", + url="https://upstream/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + ) + + response = httpx.Response( + status_code=401, + headers={}, + request=httpx.Request("GET", "https://upstream/mcp"), + ) + upstream_error = httpx.HTTPStatusError( + "401", request=response.request, response=response + ) + mock_client = MagicMock() + mock_client.list_tools = AsyncMock(side_effect=upstream_error) + + tools = await manager._fetch_tools_with_timeout( + mock_client, oauth2_server.name, server=oauth2_server + ) + assert tools == [] + mock_client.list_tools.assert_awaited_with(raise_on_error=False) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py index fb21e4ee110..755d1b95335 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py @@ -131,13 +131,129 @@ def test_prepare_mcp_server_headers_case_insensitive_extra_headers(): mcp_server_auth_headers=None, mcp_auth_header=None, oauth2_headers=None, - raw_headers={"authorization": "Bearer token"}, + raw_headers={ + "x-litellm-api-key": "Bearer sk-litellm-key", + "authorization": "Bearer token", + }, ) assert server_auth_header is None assert extra_headers == {"Authorization": "Bearer token"} +def test_prepare_mcp_server_headers_passthrough_strips_authorization_without_admission_header(): + try: + from litellm.proxy._experimental.mcp_server.server import ( + _prepare_mcp_server_headers, + ) + except ImportError: + pytest.skip("MCP server not available") + + server = MCPServer( + server_id="server-passthrough-no-admission", + name="server", + transport=MCPTransport.http, + auth_type=MCPAuth.none, + extra_headers=["Authorization", "x-request-id"], + oauth_passthrough=True, + ) + + server_auth_header, extra_headers = _prepare_mcp_server_headers( + server=server, + mcp_server_auth_headers=None, + mcp_auth_header=None, + oauth2_headers=None, + raw_headers={ + "authorization": "Bearer sk-litellm-key", + "x-request-id": "req-789", + }, + ) + + assert server_auth_header is None + assert extra_headers == {"x-request-id": "req-789"} + + +def test_prepare_mcp_server_headers_passthrough_forwards_authorization_for_anonymous_admission(): + """Cold-start return per RFC 9728: client admits anonymously through + the pass-through fallback in :meth:`MCPRequestHandler.process_mcp_request` + (``user_api_key_auth.api_key is None``) and the ``Authorization`` bearer + is the upstream OAuth token — it must be forwarded, not stripped.""" + try: + from litellm.proxy._experimental.mcp_server.server import ( + _prepare_mcp_server_headers, + ) + except ImportError: + pytest.skip("MCP server not available") + + from litellm.proxy._types import UserAPIKeyAuth + + server = MCPServer( + server_id="server-passthrough-anon-admission", + name="server", + transport=MCPTransport.http, + auth_type=MCPAuth.none, + extra_headers=["Authorization", "x-request-id"], + oauth_passthrough=True, + ) + + server_auth_header, extra_headers = _prepare_mcp_server_headers( + server=server, + mcp_server_auth_headers=None, + mcp_auth_header=None, + oauth2_headers=None, + raw_headers={ + "authorization": "Bearer upstream-oauth-token", + "x-request-id": "req-790", + }, + user_api_key_auth=UserAPIKeyAuth(), + ) + + assert server_auth_header is None + assert extra_headers == { + "Authorization": "Bearer upstream-oauth-token", + "x-request-id": "req-790", + } + + +def test_prepare_mcp_server_headers_passthrough_strips_authorization_for_authenticated_admission(): + """When admission validated ``Authorization`` as a LiteLLM key + (``user_api_key_auth.api_key`` is set, no explicit ``x-litellm-api-key``), + the bearer must still be stripped to avoid leaking the gateway key + upstream.""" + try: + from litellm.proxy._experimental.mcp_server.server import ( + _prepare_mcp_server_headers, + ) + except ImportError: + pytest.skip("MCP server not available") + + from litellm.proxy._types import UserAPIKeyAuth + + server = MCPServer( + server_id="server-passthrough-authenticated", + name="server", + transport=MCPTransport.http, + auth_type=MCPAuth.none, + extra_headers=["Authorization", "x-request-id"], + oauth_passthrough=True, + ) + + server_auth_header, extra_headers = _prepare_mcp_server_headers( + server=server, + mcp_server_auth_headers=None, + mcp_auth_header=None, + oauth2_headers=None, + raw_headers={ + "authorization": "Bearer sk-litellm-key", + "x-request-id": "req-791", + }, + user_api_key_auth=UserAPIKeyAuth(api_key="sk-litellm-key"), + ) + + assert server_auth_header is None + assert extra_headers == {"x-request-id": "req-791"} + + def test_prepare_mcp_server_headers_oauth2_m2m_omits_litellm_caller_authorization(): """M2M OAuth must not put caller Bearer (LiteLLM API key) into extra_headers (#23652).""" try: @@ -514,6 +630,7 @@ async def test_mcp_get_prompt_success(): mcp_auth_header=None, oauth2_headers=None, raw_headers=None, + user_api_key_auth=user_api_key_auth, ) mock_manager.get_prompt_from_server.assert_awaited_once_with( server=server, @@ -575,6 +692,7 @@ async def test_mcp_read_resource_success(): mcp_auth_header=None, oauth2_headers=None, raw_headers=None, + user_api_key_auth=user_api_key_auth, ) mock_manager.read_resource_from_server.assert_awaited_once_with( server=server, diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py index 2db9845c765..ec690aef629 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -480,6 +480,167 @@ async def capture_create_mcp_client( assert captured_extra_headers == {"Authorization": "Bearer token"} assert isinstance(result, CallToolResult) + @pytest.mark.asyncio + async def test_call_regular_mcp_tool_passthrough_strips_authorization_when_admission_consumed_litellm_key( + self, + ): + """OAuth pass-through must not forward the caller's Authorization to upstream + when LiteLLM admission consumed the bearer as its API key — otherwise the + LiteLLM key the caller used for admission would leak upstream.""" + from litellm.proxy._types import UserAPIKeyAuth + + manager = MCPServerManager() + server = MCPServer( + server_id="server-passthrough-call", + name="passthrough-server", + url="https://example.com", + transport=MCPTransport.http, + auth_type=MCPAuth.none, + extra_headers=["Authorization", "x-request-id"], + oauth_passthrough=True, + ) + + mock_client = AsyncMock() + mock_client.call_tool = AsyncMock( + return_value=CallToolResult(content=[], isError=False) + ) + captured_extra_headers = None + + async def capture_create_mcp_client( + server, mcp_auth_header, extra_headers, stdio_env, subject_token=None + ): # pragma: no cover - helper + nonlocal captured_extra_headers + captured_extra_headers = extra_headers + return mock_client + + manager._create_mcp_client = AsyncMock(side_effect=capture_create_mcp_client) + + await manager._call_regular_mcp_tool( + mcp_server=server, + original_tool_name="tool", + arguments={}, + tasks=[], + mcp_auth_header=None, + mcp_server_auth_headers=None, + oauth2_headers=None, + raw_headers={ + "authorization": "Bearer sk-litellm-key", + "x-request-id": "req-123", + }, + proxy_logging_obj=None, + user_api_key_auth=UserAPIKeyAuth(api_key="sk-litellm-key"), + ) + + assert captured_extra_headers == {"x-request-id": "req-123"} + + @pytest.mark.asyncio + async def test_call_regular_mcp_tool_passthrough_forwards_authorization_with_admission_header( + self, + ): + """OAuth pass-through forwards Authorization upstream when x-litellm-api-key + provides admission — in that case Authorization carries the upstream OAuth + bearer, not the LiteLLM key.""" + from litellm.proxy._types import UserAPIKeyAuth + + manager = MCPServerManager() + server = MCPServer( + server_id="server-passthrough-call-admission", + name="passthrough-server", + url="https://example.com", + transport=MCPTransport.http, + auth_type=MCPAuth.none, + extra_headers=["Authorization"], + oauth_passthrough=True, + ) + + mock_client = AsyncMock() + mock_client.call_tool = AsyncMock( + return_value=CallToolResult(content=[], isError=False) + ) + captured_extra_headers = None + + async def capture_create_mcp_client( + server, mcp_auth_header, extra_headers, stdio_env, subject_token=None + ): # pragma: no cover - helper + nonlocal captured_extra_headers + captured_extra_headers = extra_headers + return mock_client + + manager._create_mcp_client = AsyncMock(side_effect=capture_create_mcp_client) + + await manager._call_regular_mcp_tool( + mcp_server=server, + original_tool_name="tool", + arguments={}, + tasks=[], + mcp_auth_header=None, + mcp_server_auth_headers=None, + oauth2_headers=None, + raw_headers={ + "x-litellm-api-key": "Bearer sk-litellm-key", + "authorization": "Bearer upstream-oauth-bearer", + }, + proxy_logging_obj=None, + user_api_key_auth=UserAPIKeyAuth(api_key="sk-litellm-key"), + ) + + assert captured_extra_headers == { + "Authorization": "Bearer upstream-oauth-bearer" + } + + @pytest.mark.asyncio + async def test_call_regular_mcp_tool_passthrough_forwards_authorization_for_anonymous_admission( + self, + ): + """OAuth pass-through cold-start return (RFC 9728): the caller's only + credential is the upstream bearer in Authorization, and LiteLLM admission + is anonymous (no api_key on user_api_key_auth). Authorization must be + forwarded so the delegated flow can complete.""" + from litellm.proxy._types import UserAPIKeyAuth + + manager = MCPServerManager() + server = MCPServer( + server_id="server-passthrough-call-anon", + name="passthrough-server", + url="https://example.com", + transport=MCPTransport.http, + auth_type=MCPAuth.none, + extra_headers=["Authorization"], + oauth_passthrough=True, + ) + + mock_client = AsyncMock() + mock_client.call_tool = AsyncMock( + return_value=CallToolResult(content=[], isError=False) + ) + captured_extra_headers = None + + async def capture_create_mcp_client( + server, mcp_auth_header, extra_headers, stdio_env, subject_token=None + ): # pragma: no cover - helper + nonlocal captured_extra_headers + captured_extra_headers = extra_headers + return mock_client + + manager._create_mcp_client = AsyncMock(side_effect=capture_create_mcp_client) + + await manager._call_regular_mcp_tool( + mcp_server=server, + original_tool_name="tool", + arguments={}, + tasks=[], + mcp_auth_header=None, + mcp_server_auth_headers=None, + oauth2_headers=None, + raw_headers={"authorization": "Bearer upstream-oauth-bearer"}, + proxy_logging_obj=None, + user_api_key_auth=UserAPIKeyAuth(api_key=None), + ) + + assert captured_extra_headers == { + "Authorization": "Bearer upstream-oauth-bearer" + } + @pytest.mark.asyncio async def test_get_prompts_from_server_success(self): """Ensure prompts are fetched and prefixed when requested.""" diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py index 593facd9279..6433e0f6360 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py @@ -544,6 +544,78 @@ async def fake_get_tools( assert result["error"] is None assert result["message"] == "Successfully retrieved tools" + @pytest.mark.parametrize("upstream_status", [401, 403]) + async def test_upstream_auth_failure_surfaces_status_and_challenge( + self, monkeypatch, upstream_status + ): + """A single-server pass-through request whose upstream rejects the token + must surface the upstream status (401 or 403) plus its WWW-Authenticate + challenge, not collapse into a 200 ``unexpected_error`` body.""" + from litellm.proxy._experimental.mcp_server.exceptions import ( + MCPUpstreamAuthError, + ) + + class StubServer: + alias = "server-1" + server_name = "server-1" + name = "passthrough" + allowed_tools = None + mcp_info = {"server_name": "passthrough"} + available_on_public_internet = True + + stub_server = StubServer() + + async def fake_contexts(user_api_key_auth): + return [user_api_key_auth] + + async def fake_get_allowed_mcp_servers(*args, **kwargs): + return ["server-1"] + + challenge = 'Bearer resource_metadata="https://upstream/.well-known"' + + async def fake_get_tools(*args, **kwargs): + raise MCPUpstreamAuthError( + status_code=upstream_status, + www_authenticate=challenge, + server_name="passthrough", + ) + + monkeypatch.setattr( + rest_endpoints, + "build_effective_auth_contexts", + fake_contexts, + raising=False, + ) + monkeypatch.setattr( + rest_endpoints.global_mcp_server_manager, + "get_allowed_mcp_servers", + fake_get_allowed_mcp_servers, + raising=False, + ) + monkeypatch.setattr( + rest_endpoints.global_mcp_server_manager, + "get_mcp_server_by_id", + lambda server_id: stub_server if server_id == "server-1" else None, + raising=False, + ) + monkeypatch.setattr( + rest_endpoints, + "_get_tools_for_single_server", + fake_get_tools, + raising=False, + ) + + request = _build_request(path="/mcp-rest/tools/list", method="GET") + with pytest.raises(HTTPException) as exc_info: + await rest_endpoints.list_tool_rest_api( + request, + server_id="server-1", + user_api_key_dict=UserAPIKeyAuth(), + ) + + assert exc_info.value.status_code == upstream_status + assert exc_info.value.headers == {"www-authenticate": challenge} + async def test_name_resolution_finds_server_by_uuid(self, monkeypatch): """When server_id is a name string, it should be resolved to its UUID and used for the tools lookup when the UUID is in allowed_server_ids.""" diff --git a/tests/test_litellm/proxy/auth/test_handle_jwt.py b/tests/test_litellm/proxy/auth/test_handle_jwt.py index 14119f7ad4e..cca6754956f 100644 --- a/tests/test_litellm/proxy/auth/test_handle_jwt.py +++ b/tests/test_litellm/proxy/auth/test_handle_jwt.py @@ -3179,3 +3179,611 @@ def test_build_decode_kwargs_no_warning_when_scoped( if "neither JWT_AUDIENCE nor JWT_ISSUER" in r.getMessage() ] assert matching == [] + + +def _base64url_encode_int(value: int) -> str: + import base64 + + value_bytes = value.to_bytes((value.bit_length() + 7) // 8, "big") + return base64.urlsafe_b64encode(value_bytes).decode("utf-8").rstrip("=") + + +def _get_rsa_key_and_jwk(kid: str): + from cryptography.hazmat.primitives.asymmetric import rsa + + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + public_numbers = private_key.public_key().public_numbers() + jwk = { + "kty": "RSA", + "n": _base64url_encode_int(value=public_numbers.n), + "e": _base64url_encode_int(value=public_numbers.e), + "kid": kid, + "alg": "RS256", + "use": "sig", + } + return private_key, jwk + + +def _encode_rsa_jwt( + private_key, + issuer: str, + audience: str, + kid: str, + extra_claims: Optional[dict] = None, +) -> str: + import time + + import jwt + from cryptography.hazmat.primitives import serialization + + private_key_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + current_time = int(time.time()) + claims = { + "sub": "test-subject", + "iss": issuer, + "aud": audience, + "iat": current_time, + "exp": current_time + 300, + } + if extra_claims: + claims.update(extra_claims) + + return jwt.encode( + claims, + private_key_pem, + algorithm="RS256", + headers={"kid": kid}, + ) + + +def _get_jwt_handler_with_issuer_keys(issuers: list, keys_by_url: dict) -> JWTHandler: + from litellm.caching.dual_cache import DualCache + + cache = DualCache() + for jwks_url, keys in keys_by_url.items(): + cache.set_cache( + key=f"litellm_jwt_auth_keys_{jwks_url}", + value=keys, + ) + + jwt_handler = JWTHandler() + jwt_handler.update_environment( + prisma_client=None, + user_api_key_cache=cache, + litellm_jwtauth=LiteLLM_JWTAuth(issuers=issuers), + ) + return jwt_handler + + +@pytest.mark.asyncio +async def test_get_public_key_fetches_and_caches_jwks_response(): + from unittest.mock import AsyncMock, MagicMock + + from litellm.caching.dual_cache import DualCache + + jwt_handler = JWTHandler() + cache = DualCache() + jwt_handler.update_environment( + prisma_client=None, + user_api_key_cache=cache, + litellm_jwtauth=LiteLLM_JWTAuth(public_key_ttl=123), + ) + expected_key_id = "cached-key" + _, jwk = _get_rsa_key_and_jwk(kid=expected_key_id) + mock_response = MagicMock() + mock_response.json.return_value = {"keys": [jwk]} + jwt_handler.http_handler.get = AsyncMock(return_value=mock_response) + + public_key = await jwt_handler._get_public_key_from_jwks_url( + jwks_url="https://issuer.example.com/keys", + kid=expected_key_id, + ) + + assert public_key == jwk + cached_keys = await cache.async_get_cache( + key="litellm_jwt_auth_keys_https://issuer.example.com/keys" + ) + assert cached_keys == [jwk] + + +@pytest.mark.asyncio +async def test_get_public_key_tries_next_jwks_url_when_kid_missing(monkeypatch): + from litellm.caching.dual_cache import DualCache + + first_jwks_url = "https://first.example.com/keys" + second_jwks_url = "https://second.example.com/keys" + monkeypatch.setenv("JWT_PUBLIC_KEY_URL", f"{first_jwks_url}, {second_jwks_url},,") + _, first_jwk = _get_rsa_key_and_jwk(kid="first-key") + _, second_jwk = _get_rsa_key_and_jwk(kid="second-key") + cache = DualCache() + cache.set_cache(key=f"litellm_jwt_auth_keys_{first_jwks_url}", value=[first_jwk]) + cache.set_cache(key=f"litellm_jwt_auth_keys_{second_jwks_url}", value=[second_jwk]) + jwt_handler = JWTHandler() + jwt_handler.update_environment( + prisma_client=None, + user_api_key_cache=cache, + litellm_jwtauth=LiteLLM_JWTAuth(), + ) + + public_key = await jwt_handler.get_public_key(kid="second-key") + + assert public_key == second_jwk + + +def test_get_jwks_url_for_issuer_falls_back_to_discovery_document(): + jwt_handler = JWTHandler() + issuer_config = LiteLLM_JWTAuth( + issuers=[ + { + "issuer": "https://issuer.example.com/tenant/", + "disable_audience_validation": True, + } + ] + ).issuers[0] + + jwks_url = jwt_handler._get_jwks_url_for_issuer(issuer_config=issuer_config) + + assert ( + jwks_url == "https://issuer.example.com/tenant/.well-known/openid-configuration" + ) + + +@pytest.mark.asyncio +async def test_multi_issuer_jwt_validates_selected_issuer_and_maps_claims( + monkeypatch, +): + monkeypatch.delenv("JWT_AUDIENCE", raising=False) + monkeypatch.delenv("JWT_PUBLIC_KEY_URL", raising=False) + + issuer_one = "https://issuer-one.example.com" + issuer_two = "https://issuer-two.example.com" + issuer_one_jwks_url = f"{issuer_one}/keys" + issuer_two_jwks_url = f"{issuer_two}/keys" + shared_kid = "shared-kid" + + _, issuer_one_jwk = _get_rsa_key_and_jwk(kid=shared_kid) + issuer_two_private_key, issuer_two_jwk = _get_rsa_key_and_jwk(kid=shared_kid) + + jwt_handler = _get_jwt_handler_with_issuer_keys( + issuers=[ + { + "issuer": issuer_one, + "jwks_url": issuer_one_jwks_url, + "audience": "audience-one", + "user_id_jwt_field": "email", + "user_email_jwt_field": "email", + }, + { + "issuer": issuer_two, + "jwks_url": issuer_two_jwks_url, + "audience": "audience-two", + "user_id_jwt_field": "repository_owner", + "team_id_jwt_field": "repository", + }, + ], + keys_by_url={ + issuer_one_jwks_url: [issuer_one_jwk], + issuer_two_jwks_url: [issuer_two_jwk], + }, + ) + + token = _encode_rsa_jwt( + private_key=issuer_two_private_key, + issuer=issuer_two, + audience="audience-two", + kid=shared_kid, + extra_claims={ + "repository_owner": "example-org", + "repository": "example-org/litellm-fork", + }, + ) + + claims = await jwt_handler.auth_jwt(token=token) + + assert claims[JWTHandler.LITELLM_JWT_ISSUER_CLAIM] == issuer_two + assert jwt_handler.get_user_id(token=claims, default_value=None) == "example-org" + assert jwt_handler.get_team_id(token=claims, default_value=None) == ( + "example-org/litellm-fork" + ) + + +@pytest.mark.asyncio +async def test_multi_issuer_jwt_maps_kubernetes_namespace_claim(monkeypatch): + monkeypatch.delenv("JWT_AUDIENCE", raising=False) + monkeypatch.delenv("JWT_PUBLIC_KEY_URL", raising=False) + + issuer = "https://oidc.eks.eu-west-1.amazonaws.com/id/test-cluster" + jwks_url = f"{issuer}/keys" + private_key, jwk = _get_rsa_key_and_jwk(kid="k8s-key") + jwt_handler = _get_jwt_handler_with_issuer_keys( + issuers=[ + { + "issuer": issuer, + "jwks_url": jwks_url, + "audience": None, + "disable_audience_validation": True, + "user_id_jwt_field": "kubernetes\\.io.namespace", + } + ], + keys_by_url={jwks_url: [jwk]}, + ) + token = _encode_rsa_jwt( + private_key=private_key, + issuer=issuer, + audience="kubernetes.default.svc", + kid="k8s-key", + extra_claims={"kubernetes.io": {"namespace": "example-namespace"}}, + ) + + claims = await jwt_handler.auth_jwt(token=token) + + assert ( + jwt_handler.get_user_id(token=claims, default_value=None) == "example-namespace" + ) + + +@pytest.mark.asyncio +async def test_multi_issuer_jwt_unknown_issuer_falls_back_to_global_jwks(monkeypatch): + """Tokens whose ``iss`` is not in the configured issuers list fall through + to the legacy ``JWT_PUBLIC_KEY_URL`` path so operators can add the new + ``issuers`` list to a live deployment without breaking existing tokens + minted by non-configured IdPs. With no global JWKS configured, the legacy + path surfaces a ``Missing JWT Public Key URL from environment.`` error. + """ + monkeypatch.delenv("JWT_AUDIENCE", raising=False) + monkeypatch.delenv("JWT_PUBLIC_KEY_URL", raising=False) + + configured_issuer = "https://issuer.example.com" + private_key, jwk = _get_rsa_key_and_jwk(kid="issuer-key") + jwt_handler = _get_jwt_handler_with_issuer_keys( + issuers=[ + { + "issuer": configured_issuer, + "jwks_url": f"{configured_issuer}/keys", + "audience": "expected-audience", + } + ], + keys_by_url={f"{configured_issuer}/keys": [jwk]}, + ) + token = _encode_rsa_jwt( + private_key=private_key, + issuer="https://unknown-issuer.example.com", + audience="expected-audience", + kid="issuer-key", + ) + + with pytest.raises(Exception) as exc: + await jwt_handler.auth_jwt(token=token) + + assert "Missing JWT Public Key URL from environment." in str(exc.value) + + +@pytest.mark.asyncio +async def test_multi_issuer_jwt_rejects_wrong_audience(monkeypatch): + monkeypatch.delenv("JWT_AUDIENCE", raising=False) + monkeypatch.delenv("JWT_PUBLIC_KEY_URL", raising=False) + + issuer = "https://issuer.example.com" + jwks_url = f"{issuer}/keys" + private_key, jwk = _get_rsa_key_and_jwk(kid="issuer-key") + jwt_handler = _get_jwt_handler_with_issuer_keys( + issuers=[ + { + "issuer": issuer, + "jwks_url": jwks_url, + "audience": "expected-audience", + } + ], + keys_by_url={jwks_url: [jwk]}, + ) + token = _encode_rsa_jwt( + private_key=private_key, + issuer=issuer, + audience="wrong-audience", + kid="issuer-key", + ) + + with pytest.raises(Exception) as exc: + await jwt_handler.auth_jwt(token=token) + + assert "Validation fails" in str(exc.value) + + +@pytest.mark.asyncio +async def test_multi_issuer_jwt_same_kid_does_not_cross_issuer_keys(monkeypatch): + monkeypatch.delenv("JWT_AUDIENCE", raising=False) + monkeypatch.delenv("JWT_PUBLIC_KEY_URL", raising=False) + + issuer_one = "https://issuer-one.example.com" + issuer_two = "https://issuer-two.example.com" + issuer_one_jwks_url = f"{issuer_one}/keys" + issuer_two_jwks_url = f"{issuer_two}/keys" + shared_kid = "shared-kid" + issuer_one_private_key, issuer_one_jwk = _get_rsa_key_and_jwk(kid=shared_kid) + _, issuer_two_jwk = _get_rsa_key_and_jwk(kid=shared_kid) + jwt_handler = _get_jwt_handler_with_issuer_keys( + issuers=[ + { + "issuer": issuer_one, + "jwks_url": issuer_one_jwks_url, + "audience": "audience-one", + }, + { + "issuer": issuer_two, + "jwks_url": issuer_two_jwks_url, + "audience": "audience-two", + }, + ], + keys_by_url={ + issuer_one_jwks_url: [issuer_one_jwk], + issuer_two_jwks_url: [issuer_two_jwk], + }, + ) + token = _encode_rsa_jwt( + private_key=issuer_one_private_key, + issuer=issuer_two, + audience="audience-two", + kid=shared_kid, + ) + + with pytest.raises(Exception) as exc: + await jwt_handler.auth_jwt(token=token) + + assert "Validation fails" in str(exc.value) + + +@pytest.mark.asyncio +async def test_multi_issuer_jwt_missing_mapped_claim_leaves_user_id_unset( + monkeypatch, +): + """Mapped issuer claims behave like the global ``litellm_jwtauth`` path — + present claims override the normalised value, missing ones simply leave + the corresponding LiteLLM-internal claim absent (rather than failing the + JWT outright). This keeps multi-issuer auth tolerant of tokens that omit + optional fields like email or org id. + """ + monkeypatch.delenv("JWT_AUDIENCE", raising=False) + monkeypatch.delenv("JWT_PUBLIC_KEY_URL", raising=False) + + issuer = "https://issuer.example.com" + jwks_url = f"{issuer}/keys" + private_key, jwk = _get_rsa_key_and_jwk(kid="issuer-key") + jwt_handler = _get_jwt_handler_with_issuer_keys( + issuers=[ + { + "issuer": issuer, + "jwks_url": jwks_url, + "audience": "expected-audience", + "user_id_jwt_field": "email", + } + ], + keys_by_url={jwks_url: [jwk]}, + ) + token = _encode_rsa_jwt( + private_key=private_key, + issuer=issuer, + audience="expected-audience", + kid="issuer-key", + ) + + claims = await jwt_handler.auth_jwt(token=token) + + assert claims[jwt_handler.LITELLM_JWT_ISSUER_CLAIM] == issuer + assert jwt_handler.LITELLM_USER_ID_CLAIM not in claims + + +def test_multi_issuer_jwt_requires_audience_unless_explicitly_disabled( + monkeypatch, +): + monkeypatch.delenv("JWT_AUDIENCE", raising=False) + monkeypatch.delenv("JWT_PUBLIC_KEY_URL", raising=False) + + issuer = "https://issuer.example.com" + jwks_url = f"{issuer}/keys" + + with pytest.raises(Exception) as exc: + LiteLLM_JWTAuth( + issuers=[ + { + "issuer": issuer, + "jwks_url": jwks_url, + } + ] + ) + + assert "must configure audience" in str(exc.value) + + +def test_multi_issuer_jwt_rejects_audience_with_disable_audience_validation(): + issuer = "https://issuer.example.com" + jwks_url = f"{issuer}/keys" + + with pytest.raises(Exception) as exc: + LiteLLM_JWTAuth( + issuers=[ + { + "issuer": issuer, + "jwks_url": jwks_url, + "audience": "some-audience", + "disable_audience_validation": True, + } + ] + ) + + assert "cannot set audience and disable_audience_validation=True together" in str( + exc.value + ) + + +@pytest.mark.asyncio +async def test_global_jwt_ignores_user_supplied_internal_claims(monkeypatch): + from litellm.caching.dual_cache import DualCache + + monkeypatch.delenv("JWT_AUDIENCE", raising=False) + monkeypatch.delenv("JWT_ISSUER", raising=False) + + jwks_url = "https://global-issuer.example.com/keys" + monkeypatch.setenv("JWT_PUBLIC_KEY_URL", jwks_url) + + private_key, jwk = _get_rsa_key_and_jwk(kid="global-key") + cache = DualCache() + cache.set_cache(key=f"litellm_jwt_auth_keys_{jwks_url}", value=[jwk]) + + jwt_handler = JWTHandler() + jwt_handler.update_environment( + prisma_client=None, + user_api_key_cache=cache, + litellm_jwtauth=LiteLLM_JWTAuth( + user_id_jwt_field="email", + user_email_jwt_field="email", + team_id_jwt_field="team.id", + team_ids_jwt_field="teams", + org_id_jwt_field="org.id", + end_user_id_jwt_field="end_user.id", + ), + ) + token = _encode_rsa_jwt( + private_key=private_key, + issuer="https://global-issuer.example.com", + audience="some-other-client", + kid="global-key", + extra_claims={ + "email": "real-user@example.com", + "team": {"id": "real-team"}, + "teams": ["real-team", "secondary-team"], + "org": {"id": "real-org"}, + "end_user": {"id": "real-end-user"}, + JWTHandler.LITELLM_JWT_ISSUER_CLAIM: "https://issuer.example.com", + JWTHandler.LITELLM_USER_ID_CLAIM: "victim-user", + JWTHandler.LITELLM_USER_EMAIL_CLAIM: "victim@example.com", + JWTHandler.LITELLM_TEAM_ID_CLAIM: "victim-team", + JWTHandler.LITELLM_TEAM_IDS_CLAIM: ["victim-team"], + JWTHandler.LITELLM_ORG_ID_CLAIM: "victim-org", + JWTHandler.LITELLM_END_USER_ID_CLAIM: "victim-end-user", + }, + ) + + claims = await jwt_handler.auth_jwt(token=token) + + assert jwt_handler.get_user_id(token=claims, default_value=None) == ( + "real-user@example.com" + ) + assert jwt_handler.get_user_email(token=claims, default_value=None) == ( + "real-user@example.com" + ) + assert jwt_handler.get_team_id(token=claims, default_value=None) == "real-team" + assert jwt_handler.get_team_ids_from_jwt(token=claims) == [ + "real-team", + "secondary-team", + ] + assert jwt_handler.get_org_id(token=claims, default_value=None) == "real-org" + assert jwt_handler.get_end_user_id(token=claims, default_value=None) == ( + "real-end-user" + ) + + +@pytest.mark.asyncio +async def test_multi_issuer_jwt_strips_unmapped_internal_claims(monkeypatch): + monkeypatch.delenv("JWT_AUDIENCE", raising=False) + monkeypatch.delenv("JWT_PUBLIC_KEY_URL", raising=False) + + issuer = "https://issuer.example.com" + jwks_url = f"{issuer}/keys" + private_key, jwk = _get_rsa_key_and_jwk(kid="issuer-key") + jwt_handler = _get_jwt_handler_with_issuer_keys( + issuers=[ + { + "issuer": issuer, + "jwks_url": jwks_url, + "audience": "expected-audience", + "user_email_jwt_field": "email", + } + ], + keys_by_url={jwks_url: [jwk]}, + ) + token = _encode_rsa_jwt( + private_key=private_key, + issuer=issuer, + audience="expected-audience", + kid="issuer-key", + extra_claims={ + "email": "real-user@example.com", + JWTHandler.LITELLM_USER_ID_CLAIM: "victim-user", + JWTHandler.LITELLM_TEAM_ID_CLAIM: "victim-team", + }, + ) + + claims = await jwt_handler.auth_jwt(token=token) + + assert JWTHandler.LITELLM_USER_ID_CLAIM not in claims + assert JWTHandler.LITELLM_TEAM_ID_CLAIM not in claims + assert jwt_handler.get_user_id(token=claims, default_value=None) is None + assert jwt_handler.get_team_id(token=claims, default_value=None) is None + assert jwt_handler.get_user_email(token=claims, default_value=None) == ( + "real-user@example.com" + ) + + +@pytest.mark.asyncio +async def test_multi_issuer_jwt_does_not_emit_unscoped_global_warning( + monkeypatch, caplog +): + import logging + + monkeypatch.delenv("JWT_AUDIENCE", raising=False) + monkeypatch.delenv("JWT_ISSUER", raising=False) + monkeypatch.delenv("JWT_PUBLIC_KEY_URL", raising=False) + JWTHandler._unscoped_jwt_warning_emitted = False + + issuer = "https://issuer.example.com" + jwks_url = f"{issuer}/keys" + private_key, jwk = _get_rsa_key_and_jwk(kid="issuer-key") + jwt_handler = _get_jwt_handler_with_issuer_keys( + issuers=[ + { + "issuer": issuer, + "jwks_url": jwks_url, + "audience": "expected-audience", + } + ], + keys_by_url={jwks_url: [jwk]}, + ) + token = _encode_rsa_jwt( + private_key=private_key, + issuer=issuer, + audience="expected-audience", + kid="issuer-key", + ) + + with caplog.at_level(logging.WARNING): + await jwt_handler.auth_jwt(token=token) + + assert "Tokens minted by any application" not in caplog.text + assert JWTHandler._unscoped_jwt_warning_emitted is False + + +def test_build_decode_kwargs_warns_for_unscoped_global_fallback_in_mixed_deployment( + monkeypatch, _reset_unscoped_warning_flag, caplog +): + """The unscoped-fallback warning must fire even when per-issuer configs + are set. In mixed deployments, tokens whose ``iss`` does not match any + configured issuer fall through to the global path; if env-var scoping is + absent that fallback IS unscoped, and the operator needs to be told.""" + import logging + + monkeypatch.delenv("JWT_AUDIENCE", raising=False) + monkeypatch.delenv("JWT_ISSUER", raising=False) + caplog.set_level(logging.WARNING) + + JWTHandler._build_decode_kwargs() + + matching = [ + r + for r in caplog.records + if "neither JWT_AUDIENCE nor JWT_ISSUER" in r.getMessage() + ] + assert len(matching) == 1 diff --git a/tests/test_litellm/proxy/auth/test_mcp_ip_filtering.py b/tests/test_litellm/proxy/auth/test_mcp_ip_filtering.py index 3b13ef3641f..9444e4ebd2d 100644 --- a/tests/test_litellm/proxy/auth/test_mcp_ip_filtering.py +++ b/tests/test_litellm/proxy/auth/test_mcp_ip_filtering.py @@ -5,8 +5,9 @@ external callers only see servers with available_on_public_internet=True. """ -import ipaddress -from unittest.mock import patch +from unittest.mock import MagicMock, patch + +from fastapi import Request from litellm.proxy.auth.ip_address_utils import IPAddressUtils from litellm.types.mcp_server.mcp_server_manager import MCPServer @@ -58,6 +59,75 @@ def test_fails_closed_on_bad_input(self): assert IPAddressUtils.is_internal_ip("not-an-ip") is False +class TestMCPClientIPExtraction: + def test_fails_closed_when_xff_enabled_without_trusted_proxy_ranges(self): + request = MagicMock(spec=Request) + request.client = MagicMock() + request.client.host = "203.0.113.5" + request.headers = {"x-forwarded-for": "10.0.0.1"} + + result = IPAddressUtils.get_mcp_client_ip( + request, + general_settings={"use_x_forwarded_for": True}, + ) + + # XFF is untrusted (no mcp_trusted_proxy_ranges) so it must be ignored, + # and we must not trust the direct peer either: fail closed so the caller + # is classified as external and is_internal_ip("") is False. + assert result == "" + assert IPAddressUtils.is_internal_ip(result) is False + + def test_private_proxy_peer_does_not_grant_internal_access(self): + # Regression: behind an internal reverse proxy with use_x_forwarded_for + # enabled but mcp_trusted_proxy_ranges unset, the direct peer is the + # proxy's private IP. Returning it would mis-classify an external caller + # as internal and expose available_on_public_internet=false servers. + request = MagicMock(spec=Request) + request.client = MagicMock() + request.client.host = "10.0.0.7" + request.headers = {"x-forwarded-for": "8.8.8.8"} + + result = IPAddressUtils.get_mcp_client_ip( + request, + general_settings={"use_x_forwarded_for": True}, + ) + + assert result == "" + assert IPAddressUtils.is_internal_ip(result) is False + + def test_honours_xff_from_trusted_proxy(self): + request = MagicMock(spec=Request) + request.client = MagicMock() + request.client.host = "10.0.0.5" + request.headers = {"x-forwarded-for": "192.168.1.10"} + + result = IPAddressUtils.get_mcp_client_ip( + request, + general_settings={ + "use_x_forwarded_for": True, + "mcp_trusted_proxy_ranges": ["10.0.0.0/8"], + }, + ) + + assert result == "192.168.1.10" + + def test_ignores_xff_from_untrusted_direct_caller(self): + request = MagicMock(spec=Request) + request.client = MagicMock() + request.client.host = "203.0.113.5" + request.headers = {"x-forwarded-for": "10.0.0.1"} + + result = IPAddressUtils.get_mcp_client_ip( + request, + general_settings={ + "use_x_forwarded_for": True, + "mcp_trusted_proxy_ranges": ["10.0.0.0/8"], + }, + ) + + assert result == "203.0.113.5" + + class TestMCPServerIPFiltering: """Tests that external callers only see public MCP servers.""" diff --git a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py index 5d66c184495..a5c8320a5cc 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py @@ -1747,6 +1747,37 @@ async def test_mcp_oauth_user_api_key_auth_internal_delegate_bypasses( assert isinstance(result, UserAPIKeyAuth) auth_builder_mock.assert_not_called() + def test_mcp_oauth_authorize_token_routes_use_browser_auth_dependency(self): + from fastapi.routing import APIRoute + + from litellm.proxy.management_endpoints.mcp_management_endpoints import ( + _mcp_oauth_user_api_key_auth, + router, + ) + + oauth_routes = { + route.path: route + for route in router.routes + if isinstance(route, APIRoute) + and route.path + in { + "/v1/mcp/server/oauth/{server_id}/authorize", + "/v1/mcp/server/oauth/{server_id}/token", + } + } + + assert set(oauth_routes) == { + "/v1/mcp/server/oauth/{server_id}/authorize", + "/v1/mcp/server/oauth/{server_id}/token", + } + for route in oauth_routes.values(): + dependency_names = { + dependant.name + for dependant in route.dependant.dependencies + if dependant.call is _mcp_oauth_user_api_key_auth + } + assert dependency_names == {None, "user_api_key_dict"} + @pytest.mark.asyncio async def test_mcp_authorize_proxies_to_discoverable_endpoint(self): from litellm.proxy.management_endpoints.mcp_management_endpoints import ( diff --git a/ui/litellm-dashboard/src/components/mcp_tools/MCPPermissionManagement.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/MCPPermissionManagement.test.tsx index 393c9e4a619..2cbfce320af 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/MCPPermissionManagement.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/MCPPermissionManagement.test.tsx @@ -51,6 +51,68 @@ const renderWithForm = (props = {}) => { expect(toggle).toHaveAttribute("aria-checked", "false"); }); + const renderWithInitialValues = ( + initialValues: Record, + props = {}, + ) => { + const Wrapper: React.FC = ({ children }) => { + const [form] = Form.useForm(); + return ( +
+ {/* In the real app auth_type is registered by the parent form; the + component only watches it. Register a hidden field here so + Form.useWatch("auth_type") resolves the initial value. */} + + {children} +
+ ); + }; + return render( + + + , + ); + }; + + it("shows only the oauth2 PKCE-delegation toggle for oauth2 servers", async () => { + renderWithInitialValues({ allow_all_keys: false, auth_type: "oauth2" }); + await expandPanel(); + expect( + screen.getByText("Delegate auth to upstream (PKCE passthrough)"), + ).toBeInTheDocument(); + // The non-oauth2 pass-through toggle must NOT appear for oauth2 servers. + expect(screen.queryByText("OAuth pass-through")).not.toBeInTheDocument(); + }); + + it("shows only the OAuth pass-through toggle for none-auth servers forwarding Authorization", async () => { + renderWithInitialValues({ + allow_all_keys: false, + auth_type: "none", + extra_headers: ["Authorization"], + }); + await expandPanel(); + expect(screen.getByText("OAuth pass-through")).toBeInTheDocument(); + // The oauth2-only PKCE delegation toggle must NOT appear here. + expect( + screen.queryByText("Delegate auth to upstream (PKCE passthrough)"), + ).not.toBeInTheDocument(); + }); + + it("hides both upstream-auth toggles for none-auth servers without an Authorization header", async () => { + renderWithInitialValues({ + allow_all_keys: false, + auth_type: "none", + extra_headers: ["x-api-key"], + }); + await expandPanel(); + expect(screen.queryByText("OAuth pass-through")).not.toBeInTheDocument(); + expect( + screen.queryByText("Delegate auth to upstream (PKCE passthrough)"), + ).not.toBeInTheDocument(); + }); + it("should reflect allow_all_keys when editing an existing server", async () => { renderWithForm({ mcpServer: { diff --git a/ui/litellm-dashboard/src/components/mcp_tools/MCPPermissionManagement.tsx b/ui/litellm-dashboard/src/components/mcp_tools/MCPPermissionManagement.tsx index 58848df39a0..b5f0fa2e7eb 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/MCPPermissionManagement.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/MCPPermissionManagement.tsx @@ -25,6 +25,21 @@ const MCPPermissionManagement: React.FC = ({ const form = Form.useFormInstance(); const watchedAuthType = Form.useWatch("auth_type", form); const isOAuth2 = watchedAuthType === AUTH_TYPE.OAUTH2; + const isNoneAuth = watchedAuthType === AUTH_TYPE.NONE || watchedAuthType == null; + const watchedExtraHeaders = Form.useWatch("extra_headers", form); + const hasAuthorizationHeader = Array.isArray(watchedExtraHeaders) + && watchedExtraHeaders.some( + (h) => typeof h === "string" && h.toLowerCase() === "authorization", + ); + // Two distinct, independent opt-ins: + // - delegate_auth_to_upstream: oauth2 servers only (PKCE passthrough — + // bypass LiteLLM admission). + // - oauth_passthrough: auth_type=none + Authorization in extra_headers + // (OAuth pass-through: proxy upstream oauth-protected-resource, emit 401 + // challenges, propagate upstream 401/403). + // Kept as separate flags so neither silently implies the other and existing + // oauth2 servers can't regress into pass-through behavior. + const canEnableOAuthPassthrough = isNoneAuth && hasAuthorizationHeader; const watchedDelegateAuth = Form.useWatch("delegate_auth_to_upstream", form); const watchedPublicInternet = Form.useWatch("available_on_public_internet", form); const showInternalDelegatePkceWarning = @@ -51,22 +66,34 @@ const MCPPermissionManagement: React.FC = ({ if (typeof mcpServer.delegate_auth_to_upstream === "boolean") { form.setFieldValue("delegate_auth_to_upstream", mcpServer.delegate_auth_to_upstream); } + if (typeof mcpServer.oauth_passthrough === "boolean") { + form.setFieldValue("oauth_passthrough", mcpServer.oauth_passthrough); + } } else { form.setFieldValue("allow_all_keys", false); form.setFieldValue("available_on_public_internet", true); form.setFieldValue("delegate_auth_to_upstream", false); + form.setFieldValue("oauth_passthrough", false); } }, [mcpServer, form]); - // delegate_auth_to_upstream is only honored server-side when auth_type=oauth2. + // delegate_auth_to_upstream is only honored server-side for oauth2 servers. // Force it back to false whenever the user switches away from oauth2 so a - // stale toggle value doesn't get persisted with another auth type. + // stale toggle value doesn't get persisted unexpectedly. useEffect(() => { if (!isOAuth2) { form.setFieldValue("delegate_auth_to_upstream", false); } }, [isOAuth2, form]); + // oauth_passthrough is only honored for auth_type=none servers that forward + // Authorization upstream. Force it back to false otherwise. + useEffect(() => { + if (!canEnableOAuthPassthrough) { + form.setFieldValue("oauth_passthrough", false); + } + }, [canEnableOAuthPassthrough, form]); + return ( = ({ )} + {canEnableOAuthPassthrough && ( +
+
+ + OAuth pass-through + + + + +

+ Forward upstream OAuth discovery and 401 challenges so clients negotiate OAuth directly with the upstream MCP server. +

+
+ + + +
+ )} + {showInternalDelegatePkceWarning && ( = ({ allow_all_keys: allowAllKeysRaw, available_on_public_internet: availableOnPublicInternetRaw, delegate_auth_to_upstream: delegateAuthToUpstreamRaw, + oauth_passthrough: oauthPassthroughRaw, token_validation_json: rawTokenValidationJson, ...restValues } = values; @@ -393,6 +394,7 @@ const CreateMCPServer: React.FC = ({ allow_all_keys: Boolean(allowAllKeysRaw), available_on_public_internet: Boolean(availableOnPublicInternetRaw), delegate_auth_to_upstream: Boolean(delegateAuthToUpstreamRaw), + oauth_passthrough: Boolean(oauthPassthroughRaw), static_headers: staticHeaders, ...(tokenValidation !== null && { token_validation: tokenValidation }), }; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx index 1f2864f6759..9fef034123f 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx @@ -216,6 +216,41 @@ describe("MCPServerEdit (delegate auth)", () => { expect(payload.auth_type).toBe("none"); expect(payload.delegate_auth_to_upstream).toBe(false); }); + + it("does not enable oauth_passthrough for an oauth2 server", async () => { + vi.mocked(networking.updateMCPServer).mockResolvedValue({ + ...interactiveOAuthServer, + oauth_passthrough: false, + }); + + render( + , + ); + + const saveButtons = screen.getAllByRole("button", { name: "Save Changes" }); + await act(async () => { + fireEvent.click(saveButtons[0]); + }); + + await waitFor(() => { + expect(networking.updateMCPServer).toHaveBeenCalledTimes(1); + }); + + const [, payload] = vi.mocked(networking.updateMCPServer).mock.calls[0]; + expect(payload.auth_type).toBe("oauth2"); + // oauth_passthrough is non-oauth2 only — must be forced false here. + expect(payload.oauth_passthrough).toBe(false); + }); }); describe("MCPServerEdit (interactive OAuth)", () => { diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx index 9278d41c3e3..6b79ec3e52a 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx @@ -385,6 +385,7 @@ const MCPServerEdit: React.FC = ({ allow_all_keys: allowAllKeysRaw, available_on_public_internet: availableOnPublicInternetRaw, delegate_auth_to_upstream: delegateAuthToUpstreamRaw, + oauth_passthrough: oauthPassthroughRaw, token_validation_json: rawTokenValidationJson, ...restValues } = values; @@ -554,14 +555,34 @@ const MCPServerEdit: React.FC = ({ allow_all_keys: Boolean(allowAllKeysRaw ?? mcpServer.allow_all_keys), available_on_public_internet: Boolean(availableOnPublicInternetRaw ?? mcpServer.available_on_public_internet), // ``delegate_auth_to_upstream`` is only honored server-side for - // ``auth_type=oauth2``. The Form.Item is conditionally rendered so the - // value drops out of the form on auth_type change; force false for any - // non-oauth2 server to avoid persisting a stale ``true`` that would - // silently re-activate if auth_type is later switched back to oauth2. - delegate_auth_to_upstream: - restValues.auth_type === AUTH_TYPE.OAUTH2 + // ``auth_type=oauth2`` (PKCE passthrough). The Form.Item is + // conditionally rendered so the value drops out of the form on + // auth_type change; force false for any other configuration to avoid + // persisting a stale ``true`` that would silently re-activate if the + // configuration is later switched back. + delegate_auth_to_upstream: (() => { + const isOauth2 = restValues.auth_type === AUTH_TYPE.OAUTH2; + return isOauth2 ? Boolean(delegateAuthToUpstreamRaw ?? mcpServer.delegate_auth_to_upstream) - : false, + : false; + })(), + // ``oauth_passthrough`` is the dedicated, non-oauth2 opt-in. It is only + // honored for ``auth_type=none`` servers that forward ``Authorization`` + // upstream. Kept separate from ``delegate_auth_to_upstream`` so enabling + // pass-through never regresses oauth2 servers. Force false otherwise. + oauth_passthrough: (() => { + const isNoneAuth = + restValues.auth_type === AUTH_TYPE.NONE || restValues.auth_type == null; + const extraHeaders = Array.isArray(restValues.extra_headers) + ? restValues.extra_headers + : []; + const hasAuthorizationHeader = extraHeaders.some( + (h: unknown) => typeof h === "string" && h.toLowerCase() === "authorization", + ); + return isNoneAuth && hasAuthorizationHeader + ? Boolean(oauthPassthroughRaw ?? mcpServer.oauth_passthrough) + : false; + })(), // Include token_validation when it is set (non-null) or when clearing an existing value ...(tokenValidation !== null || mcpServer.token_validation ? { token_validation: tokenValidation } diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_view.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_view.tsx index 5a8035d4e0b..87e8b77837c 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_view.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_view.tsx @@ -290,6 +290,27 @@ export const MCPServerView: React.FC = ({ )} + {handleAuth(mcpServer.auth_type) !== "oauth2" && + Array.isArray(mcpServer.extra_headers) && + mcpServer.extra_headers.some( + (h) => typeof h === "string" && h.toLowerCase() === "authorization", + ) && ( +
+ OAuth Pass-through +
+ {mcpServer.oauth_passthrough ? ( + + + Enabled + + ) : ( + + Disabled + + )} +
+
+ )}
Access Groups
diff --git a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx index 9a8f2e8f514..c198cf1b9b1 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx @@ -210,6 +210,7 @@ export interface MCPServer { allow_all_keys?: boolean; available_on_public_internet?: boolean; delegate_auth_to_upstream?: boolean; + oauth_passthrough?: boolean; /** Stdio-only fields (present when transport === 'stdio') */ command?: string | null;