mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-08 08:08:37 +00:00
* refactor(mcp): drop the experimental label from the MCP server and client paths Move litellm/proxy/_experimental/mcp_server to litellm/proxy/mcp_server and litellm/experimental_mcp_client to litellm/mcp_client, with the mirrored tests moved under tests/unit/proxy/mcp_server and tests/unit/mcp_client. Both old packages stay as shims that warn with DeprecationWarning, re-export the new package's public names and alias every submodule in sys.modules so deep imports keep resolving to the same objects. The OpenAPI docs gate now puts the merge base's enterprise package on PYTHONPATH so its base counts do not mix the checked-out enterprise code with the base litellm tree Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * ci(mcp): keep tests/unit/proxy/_experimental as a sharded root for the shim tests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(mcp): alias namespace subpackages and bind attributes in the deprecated MCP shims pkgutil.walk_packages skipped mcp_server/auth because it has no __init__.py, so old imports of litellm.proxy._experimental.mcp_server.auth.* failed, and sys.modules entries alone left litellm.experimental_mcp_client.tools unreachable as an attribute. Walk the package on disk and setattr each alias on its old parent. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
15480 lines
635 KiB
Python
15480 lines
635 KiB
Python
"""Tests for MCP OAuth discoverable endpoints"""
|
|
|
|
import hashlib
|
|
import json
|
|
import time
|
|
from base64 import urlsafe_b64encode
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import TYPE_CHECKING, Final, Literal
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi import HTTPException, Request, Response
|
|
from typing_extensions import ReadOnly, TypedDict
|
|
|
|
from litellm.proxy._types import LiteLLM_MCPServerTable
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterator
|
|
|
|
import httpx
|
|
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
|
from fastapi import APIRouter
|
|
from respx import MockRouter
|
|
|
|
from litellm.proxy.auth.handle_jwt import JWTHandler
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
|
|
class _BridgeRegistrationPayload(TypedDict, total=False):
|
|
client_name: ReadOnly[str]
|
|
grant_types: ReadOnly[list[str]]
|
|
response_types: ReadOnly[list[str]]
|
|
token_endpoint_auth_method: ReadOnly[str]
|
|
redirect_uris: ReadOnly[list[str]]
|
|
|
|
|
|
def _stored_grant(access_token="access-token", refresh_token=None, expires_in_seconds=None, expires_at=None):
|
|
credential = {"type": "oauth2", "access_token": access_token}
|
|
if refresh_token is not None:
|
|
credential["refresh_token"] = refresh_token
|
|
if expires_in_seconds is not None:
|
|
credential["expires_at"] = (datetime.now(timezone.utc) + timedelta(seconds=expires_in_seconds)).isoformat()
|
|
if expires_at is not None:
|
|
credential["expires_at"] = expires_at
|
|
return credential
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("fields", "egress_has_token"),
|
|
[
|
|
(None, False),
|
|
({"access_token": "", "refresh_token": "refresh-token"}, False),
|
|
({}, True),
|
|
({"expires_at": "never"}, True),
|
|
({"expires_in_seconds": 600}, True),
|
|
({"expires_in_seconds": 30}, False),
|
|
({"expires_in_seconds": -300}, False),
|
|
({"expires_in_seconds": -300, "refresh_token": ""}, False),
|
|
({"expires_in_seconds": 30, "refresh_token": "refresh-token"}, True),
|
|
({"expires_in_seconds": -300, "refresh_token": "refresh-token"}, True),
|
|
],
|
|
)
|
|
async def test_vendor_credential_state_agrees_with_egress_token_resolution(monkeypatch, fields, egress_has_token):
|
|
from litellm.proxy.mcp_server import db as mcp_db
|
|
from litellm.proxy.mcp_server import discoverable_endpoints
|
|
|
|
monkeypatch.setattr(mcp_db, "MCP_PER_USER_TOKEN_EXPIRY_BUFFER_SECONDS", 60)
|
|
credential = _stored_grant(**fields) if fields is not None else None
|
|
read = AsyncMock(return_value=credential)
|
|
refresh = AsyncMock(return_value=_stored_grant(access_token="fresh-token", expires_in_seconds=3600))
|
|
prisma = MagicMock()
|
|
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", prisma)
|
|
monkeypatch.setattr(mcp_db, "get_user_oauth_credential", read)
|
|
monkeypatch.setattr(mcp_db, "refresh_user_oauth_token", refresh)
|
|
|
|
connect = await discoverable_endpoints._vendor_credential_state("user-1", "server-1")
|
|
read.assert_awaited_once_with(prisma, "user-1", "server-1")
|
|
refresh.assert_not_awaited()
|
|
egress = await mcp_db.resolve_valid_user_oauth_token(
|
|
user_id="user-1", server=MagicMock(), cred=credential, prisma_client=prisma
|
|
)
|
|
|
|
assert (egress is not None) is egress_has_token
|
|
assert connect == ("present" if egress_has_token else "absent")
|
|
|
|
|
|
# Fixture to mock IP address check for all MCP tests
|
|
# This prevents tests from failing due to IP-based access control
|
|
@pytest.fixture(autouse=True)
|
|
def mock_mcp_client_ip():
|
|
"""Mock IPAddressUtils.get_mcp_client_ip to return None for all tests.
|
|
|
|
This bypasses IP-based access control in tests, since the MCP server's
|
|
available_on_public_internet defaults to False and mock requests don't
|
|
have proper client IP context.
|
|
"""
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.IPAddressUtils.get_mcp_client_ip",
|
|
return_value=None,
|
|
):
|
|
yield
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def isolate_global_mcp_registry(monkeypatch):
|
|
"""Restore the module-global MCP server registry after each test.
|
|
|
|
Tests here register servers on ``global_mcp_server_manager`` directly; without a
|
|
restore, entries leak into other test modules sharing the same worker and break
|
|
assertions over the full registry contents.
|
|
"""
|
|
from litellm.proxy.mcp_server.catalog import CatalogSnapshots
|
|
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
|
|
|
|
monkeypatch.setattr(global_mcp_server_manager, "catalog", CatalogSnapshots(global_mcp_server_manager))
|
|
snapshot = dict(global_mcp_server_manager.registry)
|
|
yield
|
|
global_mcp_server_manager.registry.clear()
|
|
global_mcp_server_manager.registry.update(snapshot)
|
|
|
|
|
|
def _mock_callback_request(base_url: str = "http://localhost:3000/"):
|
|
"""Return a MagicMock Request for callback/authorize same-origin tests.
|
|
|
|
The callback handler only uses ``request`` to compute the proxy's own
|
|
base URL via ``get_request_base_url`` (which reads ``request.base_url``
|
|
and trusted ``X-Forwarded-*`` headers). A simple MagicMock with the
|
|
right attributes is sufficient.
|
|
"""
|
|
req = MagicMock(spec=Request)
|
|
req.client = None
|
|
req.base_url = base_url
|
|
req.headers = {}
|
|
req.cookies = {}
|
|
return req
|
|
|
|
|
|
def _unresolved_oauth_server():
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
return MCPServer(
|
|
server_id="cold-oauth-server",
|
|
name="cold_oauth_server",
|
|
server_name="cold_oauth_server",
|
|
alias="cold_oauth_server",
|
|
url="https://mcp.example.com/mcp",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
oauth2_flow="authorization_code",
|
|
client_id="client-id",
|
|
)
|
|
|
|
|
|
def _resolved_oauth_metadata():
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPOAuthMetadata
|
|
|
|
return MCPOAuthMetadata(
|
|
authorization_url="https://idp.example.com/authorize",
|
|
token_url="https://idp.example.com/token",
|
|
registration_url="https://idp.example.com/register",
|
|
scopes=["mcp.read"],
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authorize_resolves_cold_oauth_metadata(monkeypatch):
|
|
"""The route hands the registered server to the flow, whose deferred-discovery join resolves
|
|
the cold metadata; the redirect must land on the discovered authorization endpoint."""
|
|
from litellm.proxy.mcp_server import discoverable_endpoints
|
|
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
|
|
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-test-salt-for-lit-6255")
|
|
server = _unresolved_oauth_server()
|
|
global_mcp_server_manager.registry[server.server_id] = server
|
|
global_mcp_server_manager._set_oauth_discovery_deferred(server.server_id, True)
|
|
request = _mock_callback_request("https://litellm.example.com/")
|
|
|
|
with patch.object(
|
|
global_mcp_server_manager,
|
|
"_discover_oauth_metadata_for_server",
|
|
new=AsyncMock(return_value=_resolved_oauth_metadata()),
|
|
) as discovery:
|
|
response = await discoverable_endpoints.authorize(
|
|
request=request,
|
|
client_id="client-id",
|
|
mcp_server_name=server.server_name,
|
|
redirect_uri="http://127.0.0.1:60108/callback",
|
|
)
|
|
|
|
discovery.assert_awaited_once_with(server)
|
|
assert response.status_code == 307
|
|
assert response.headers["location"].startswith("https://idp.example.com/authorize")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_resolves_cold_oauth_metadata():
|
|
"""The route hands the registered server to the exchange, whose deferred-discovery join
|
|
resolves the cold metadata; the exchange must post to the discovered token endpoint."""
|
|
from litellm.proxy.mcp_server import discoverable_endpoints
|
|
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
|
|
|
|
server = _unresolved_oauth_server()
|
|
global_mcp_server_manager.registry[server.server_id] = server
|
|
global_mcp_server_manager._set_oauth_discovery_deferred(server.server_id, True)
|
|
request = _mock_callback_request("https://litellm.example.com/")
|
|
fake_http_response = MagicMock()
|
|
fake_http_response.json.return_value = {"access_token": "tok", "token_type": "Bearer"}
|
|
fake_http_response.raise_for_status = MagicMock()
|
|
fake_http_client = MagicMock()
|
|
fake_http_client.post = AsyncMock(return_value=fake_http_response)
|
|
|
|
with (
|
|
patch.object(
|
|
global_mcp_server_manager,
|
|
"_discover_oauth_metadata_for_server",
|
|
new=AsyncMock(return_value=_resolved_oauth_metadata()),
|
|
) as discovery,
|
|
patch.object(
|
|
discoverable_endpoints,
|
|
"get_async_httpx_client",
|
|
new=lambda llm_provider: fake_http_client,
|
|
),
|
|
):
|
|
response = await discoverable_endpoints.token_endpoint(
|
|
request=request,
|
|
grant_type="refresh_token",
|
|
client_id="client-id",
|
|
refresh_token="refresh-token",
|
|
mcp_server_name=server.server_name,
|
|
)
|
|
|
|
discovery.assert_awaited_once_with(server)
|
|
assert response.status_code == 200
|
|
assert fake_http_client.post.await_args.args[0] == "https://idp.example.com/token"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_resolves_cold_oauth_metadata():
|
|
"""The route hands the registered server to the registration flow, whose deferred-discovery
|
|
join resolves the cold metadata; DCR must post to the discovered registration endpoint."""
|
|
from litellm.proxy.mcp_server import discoverable_endpoints
|
|
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
|
|
|
|
server = _unresolved_oauth_server().model_copy(update={"client_id": None})
|
|
global_mcp_server_manager.registry[server.server_id] = server
|
|
global_mcp_server_manager._set_oauth_discovery_deferred(server.server_id, True)
|
|
request = _mock_callback_request("https://litellm.example.com/")
|
|
fake_http_response = MagicMock()
|
|
fake_http_response.json.return_value = {"client_id": "generated-client", "client_secret": "generated-secret"}
|
|
fake_http_response.raise_for_status = MagicMock()
|
|
fake_http_client = MagicMock()
|
|
fake_http_client.post = AsyncMock(return_value=fake_http_response)
|
|
|
|
with (
|
|
patch.object(
|
|
global_mcp_server_manager,
|
|
"_discover_oauth_metadata_for_server",
|
|
new=AsyncMock(return_value=_resolved_oauth_metadata()),
|
|
) as discovery,
|
|
patch.object(discoverable_endpoints, "read_request_body", new=AsyncMock(return_value={})),
|
|
patch.object(
|
|
discoverable_endpoints,
|
|
"get_async_httpx_client",
|
|
new=lambda llm_provider: fake_http_client,
|
|
),
|
|
):
|
|
response = await discoverable_endpoints.register_client(request=request, mcp_server_name=server.server_name)
|
|
|
|
discovery.assert_awaited_once_with(server)
|
|
assert response.status_code == 200
|
|
assert fake_http_client.post.await_args.args[0] == "https://idp.example.com/register"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_route_bridge_missing_registration_url_joins_discovery():
|
|
"""A clientless DCR bridge whose authorize and token urls are admin-entered still relays
|
|
registration upstream: the flow must join deferred discovery for the missing registration
|
|
endpoint instead of short-circuiting to dummy credentials because authorization resolves."""
|
|
import json
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server import discoverable_endpoints
|
|
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
server = MCPServer(
|
|
server_id="bridge-partial-metadata",
|
|
name="bridge_partial_metadata",
|
|
server_name="bridge_partial_metadata",
|
|
alias="bridge_partial_metadata",
|
|
url="https://mcp.example.com/mcp",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth_delegate,
|
|
dcr_bridge=True,
|
|
client_id=None,
|
|
authorization_url="https://idp.example.com/authorize",
|
|
token_url="https://idp.example.com/token",
|
|
)
|
|
global_mcp_server_manager.registry[server.server_id] = server
|
|
global_mcp_server_manager._set_oauth_discovery_deferred(server.server_id, True)
|
|
request = _mock_callback_request("https://litellm.example.com/")
|
|
fake_http_response = MagicMock()
|
|
fake_http_response.json.return_value = {"client_id": "generated-client", "client_secret": "generated-secret"}
|
|
fake_http_response.raise_for_status = MagicMock()
|
|
fake_http_client = MagicMock()
|
|
fake_http_client.post = AsyncMock(return_value=fake_http_response)
|
|
|
|
with (
|
|
patch.object( # test-quality-ok: innermost discovery seam on a module-global manager; the route-to-flow join under test stays real
|
|
global_mcp_server_manager,
|
|
"_discover_oauth_metadata_for_server",
|
|
new=AsyncMock(return_value=_resolved_oauth_metadata()),
|
|
) as discovery,
|
|
patch.object( # test-quality-ok: the MagicMock Request carries no body; this seam feeds the RFC 7591 redirect_uris
|
|
discoverable_endpoints,
|
|
"read_request_body",
|
|
new=AsyncMock(return_value={"redirect_uris": ["https://client.example.com/cb"]}),
|
|
),
|
|
patch.object( # test-quality-ok: keeps the DCR POST off the network so its target URL can be asserted
|
|
discoverable_endpoints,
|
|
"get_async_httpx_client",
|
|
new=lambda llm_provider: fake_http_client,
|
|
),
|
|
):
|
|
response = await discoverable_endpoints.register_client(request=request, mcp_server_name=server.server_name)
|
|
|
|
discovery.assert_awaited_once_with(server)
|
|
assert fake_http_client.post.await_args.args[0] == "https://idp.example.com/register"
|
|
assert fake_http_client.post.await_args.kwargs["json"]["redirect_uris"] == ["https://client.example.com/cb"]
|
|
assert response.status_code == 200
|
|
assert json.loads(response.body.decode("utf-8"))["client_id"] == "generated-client"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_route_bridge_missing_registration_url_joins_discovery():
|
|
"""A clientless DCR bridge rebuilt with an admin-entered token url but without its discovered
|
|
registration endpoint must rejoin discovery at the exchange: the relay-vs-callback arm hinges
|
|
on the registration url, so skipping discovery would swap the client's own redirect_uri for
|
|
the gateway callback and the upstream would reject the code."""
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server import discoverable_endpoints
|
|
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
server = MCPServer(
|
|
server_id="bridge-partial-token-metadata",
|
|
name="bridge_partial_token_metadata",
|
|
server_name="bridge_partial_token_metadata",
|
|
alias="bridge_partial_token_metadata",
|
|
url="https://mcp.example.com/mcp",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.true_passthrough,
|
|
dcr_bridge=True,
|
|
client_id=None,
|
|
authorization_url="https://idp.example.com/authorize",
|
|
token_url="https://idp.example.com/token",
|
|
)
|
|
global_mcp_server_manager.registry[server.server_id] = server
|
|
global_mcp_server_manager._set_oauth_discovery_deferred(server.server_id, True)
|
|
request = _mock_callback_request("https://litellm.example.com/")
|
|
fake_http_response = MagicMock()
|
|
fake_http_response.json.return_value = {"access_token": "tok", "token_type": "Bearer"}
|
|
fake_http_response.raise_for_status = MagicMock()
|
|
fake_http_client = MagicMock()
|
|
fake_http_client.post = AsyncMock(return_value=fake_http_response)
|
|
|
|
with (
|
|
patch.object( # test-quality-ok: innermost discovery seam on a module-global manager; the route-to-exchange join under test stays real
|
|
global_mcp_server_manager,
|
|
"_discover_oauth_metadata_for_server",
|
|
new=AsyncMock(return_value=_resolved_oauth_metadata()),
|
|
) as discovery,
|
|
patch.object( # test-quality-ok: keeps the upstream token POST off the network so its redirect_uri arm can be asserted
|
|
discoverable_endpoints,
|
|
"get_async_httpx_client",
|
|
new=lambda llm_provider: fake_http_client,
|
|
),
|
|
):
|
|
response = await discoverable_endpoints.token_endpoint(
|
|
request=request,
|
|
grant_type="authorization_code",
|
|
code="upstream-code",
|
|
redirect_uri="https://client.example.com/cb",
|
|
client_id="dcr-client-id",
|
|
mcp_server_name=server.server_name,
|
|
)
|
|
|
|
discovery.assert_awaited_once_with(server)
|
|
assert response.status_code == 200
|
|
assert fake_http_client.post.await_args.kwargs["data"]["redirect_uri"] == "https://client.example.com/cb"
|
|
|
|
|
|
@pytest.fixture
|
|
def trust_xff():
|
|
"""Force ``IPAddressUtils.is_request_from_trusted_proxy`` to True.
|
|
|
|
Tests that exercise X-Forwarded-* parsing logic opt into this fixture.
|
|
The trust gate's own behaviour is covered by
|
|
``test_get_request_base_url_xff_trust_gate``.
|
|
"""
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.IPAddressUtils.is_request_from_trusted_proxy",
|
|
return_value=True,
|
|
):
|
|
yield
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authorize_endpoint_includes_response_type():
|
|
"""Test that authorize endpoint includes response_type=code parameter (fixes #15684)"""
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
authorize,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
# Clear registry
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
# Create mock OAuth2 server
|
|
oauth2_server = MCPServer(
|
|
server_id="test_oauth_server",
|
|
name="test_oauth",
|
|
server_name="test_oauth",
|
|
alias="test_oauth",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id="test_client_id",
|
|
client_secret="test_client_secret",
|
|
authorization_url="https://provider.com/oauth/authorize",
|
|
token_url="https://provider.com/oauth/token",
|
|
scopes=["read", "write"],
|
|
)
|
|
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
|
|
|
|
# Mock request
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://litellm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
# Mock the encryption functions to avoid needing a signing key
|
|
with patch("litellm.proxy.mcp_server.discoverable_endpoints.encrypt_value_helper") as mock_encrypt:
|
|
mock_encrypt.return_value = "mocked_encrypted_state"
|
|
|
|
# Call authorize endpoint
|
|
response = await authorize(
|
|
request=mock_request,
|
|
client_id="test_client_id",
|
|
mcp_server_name="test_oauth",
|
|
redirect_uri="http://127.0.0.1:60108/callback",
|
|
state="test_state",
|
|
)
|
|
|
|
# Verify response is a redirect
|
|
assert response.status_code == 307 # FastAPI RedirectResponse default
|
|
|
|
# Verify response_type is in the redirect URL
|
|
assert "response_type=code" in response.headers["location"]
|
|
assert "https://provider.com/oauth/authorize" in response.headers["location"]
|
|
assert "client_id=test_client_id" in response.headers["location"]
|
|
assert "scope=read+write" in response.headers["location"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("auth_type_value", ["true_passthrough", "oauth_delegate"])
|
|
async def test_authorize_endpoint_allows_client_forwarded_modes(auth_type_value):
|
|
"""The browser-only Authorize relays the gateway authorize flow for the client-forwarded
|
|
token modes; the oauth2-only gate must let them through and redirect to the upstream IdP."""
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
authorize,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
server = MCPServer(
|
|
server_id="test_cf_server",
|
|
name="test_cf",
|
|
server_name="test_cf",
|
|
alias="test_cf",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth(auth_type_value),
|
|
# Discovery stamps these onto the in-memory registry entry at build time.
|
|
authorization_url="https://provider.com/oauth/authorize",
|
|
token_url="https://provider.com/oauth/token",
|
|
)
|
|
global_mcp_server_manager.registry[server.server_id] = server
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://litellm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
with patch("litellm.proxy.mcp_server.discoverable_endpoints.encrypt_value_helper") as mock_encrypt:
|
|
mock_encrypt.return_value = "mocked_encrypted_state"
|
|
|
|
response = await authorize(
|
|
request=mock_request,
|
|
client_id="dcr_client_id",
|
|
mcp_server_name="test_cf",
|
|
redirect_uri="http://127.0.0.1:60108/callback",
|
|
state="test_state",
|
|
)
|
|
|
|
assert response.status_code == 307
|
|
assert "https://provider.com/oauth/authorize" in response.headers["location"]
|
|
assert "client_id=dcr_client_id" in response.headers["location"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authorize_endpoint_preserves_existing_query_params():
|
|
"""Test that authorize endpoint merges OAuth params with existing query params in authorization_url"""
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
authorize,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
# Authorization URL already has query params (e.g. multi-tenant OAuth)
|
|
oauth2_server = MCPServer(
|
|
server_id="test_oauth_server",
|
|
name="test_oauth",
|
|
server_name="test_oauth",
|
|
alias="test_oauth",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id="test_client_id",
|
|
client_secret="test_client_secret",
|
|
authorization_url="https://provider.com/oauth/authorize?tenant=system",
|
|
token_url="https://provider.com/oauth/token",
|
|
scopes=["read", "write"],
|
|
)
|
|
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://litellm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
with patch("litellm.proxy.mcp_server.discoverable_endpoints.encrypt_value_helper") as mock_encrypt:
|
|
mock_encrypt.return_value = "mocked_encrypted_state"
|
|
|
|
response = await authorize(
|
|
request=mock_request,
|
|
client_id="test_client_id",
|
|
mcp_server_name="test_oauth",
|
|
redirect_uri="http://127.0.0.1:60108/callback",
|
|
state="test_state",
|
|
)
|
|
|
|
location = response.headers["location"]
|
|
|
|
# Must NOT have double '?' — existing params must be merged correctly
|
|
assert location.count("?") == 1, f"Expected exactly one '?' in URL but got {location.count('?')}: {location}"
|
|
assert "tenant=system" in location
|
|
assert "client_id=test_client_id" in location
|
|
assert "response_type=code" in location
|
|
assert "scope=read+write" in location
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("auth_mode", ["direct", "true_passthrough", "oauth_delegate"])
|
|
@pytest.mark.parametrize(
|
|
("authorization_url", "expected_access_type", "expected_prompt"),
|
|
[
|
|
("https://accounts.google.com/o/oauth2/v2/auth", ["offline"], ["consent"]),
|
|
("https://accounts.google.com/o/oauth2/v2/auth?prompt=select_account", ["offline"], ["select_account"]),
|
|
("https://accounts.google.com/o/oauth2/v2/auth?access_type=online&prompt=none", ["online"], ["none"]),
|
|
("https://provider.example/oauth/authorize", None, None),
|
|
("https://accounts.google.com.example/oauth/authorize", None, None),
|
|
],
|
|
)
|
|
async def test_authorize_preserves_contract_with_provider_defaults(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
auth_mode: str,
|
|
authorization_url: str,
|
|
expected_access_type: list[str] | None,
|
|
expected_prompt: list[str] | None,
|
|
) -> None:
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import authorize_with_server
|
|
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", "isolated-oauth-test-salt")
|
|
is_bridge: Final = auth_mode != "direct"
|
|
server: Final = _bridge_server(
|
|
auth_type=MCPAuth(auth_mode) if is_bridge else MCPAuth.oauth2,
|
|
dcr_bridge=is_bridge,
|
|
client_id=None if is_bridge else "configured-client",
|
|
authorization_url=authorization_url + ("&" if "?" in authorization_url else "?") + "scope=configured-scope",
|
|
)
|
|
response: Final = await authorize_with_server(
|
|
request=_bridge_mock_request(),
|
|
mcp_server=server,
|
|
client_id="caller-client",
|
|
redirect_uri=_BRIDGE_CLIENT_REDIRECT if is_bridge else "http://127.0.0.1:60108/callback",
|
|
state="caller-state",
|
|
scope="requested-scope",
|
|
code_challenge="caller-challenge",
|
|
code_challenge_method="S256",
|
|
)
|
|
query: Final = parse_qs(urlparse(response.headers["location"]).query)
|
|
|
|
assert response.status_code == 307
|
|
assert query.get("access_type") == expected_access_type
|
|
assert query.get("prompt") == expected_prompt
|
|
assert query["scope"] == ["requested-scope"]
|
|
assert query["code_challenge"] == ["caller-challenge"]
|
|
assert query["code_challenge_method"] == ["S256"]
|
|
assert query["client_id"] == ["caller-client" if is_bridge else "configured-client"]
|
|
if is_bridge:
|
|
assert query["redirect_uri"] == [_BRIDGE_CLIENT_REDIRECT]
|
|
assert query["state"] == ["caller-state"]
|
|
assert "set-cookie" not in response.headers
|
|
else:
|
|
assert query["redirect_uri"] == ["https://litellm.example.com/callback"]
|
|
assert query["state"] != ["caller-state"]
|
|
assert "set-cookie" in response.headers
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authorize_endpoint_forwards_pkce_parameters():
|
|
"""Test that authorize endpoint forwards PKCE parameters (code_challenge and code_challenge_method)"""
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
authorize,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
# Clear registry
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
# Create mock OAuth2 server (simulating Google OAuth)
|
|
oauth2_server = MCPServer(
|
|
server_id="google_mcp",
|
|
name="google_mcp",
|
|
server_name="google_mcp",
|
|
alias="google_mcp",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id="669428968603-test.apps.googleusercontent.com",
|
|
client_secret="GOCSPX-test_secret",
|
|
authorization_url="https://accounts.google.com/o/oauth2/v2/auth",
|
|
token_url="https://oauth2.googleapis.com/token",
|
|
scopes=["https://www.googleapis.com/auth/drive", "openid", "email"],
|
|
)
|
|
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
|
|
|
|
# Mock request
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://litellm-proxy.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
# Mock the encryption function
|
|
with patch("litellm.proxy.mcp_server.discoverable_endpoints.encrypt_value_helper") as mock_encrypt:
|
|
mock_encrypt.return_value = "mocked_encrypted_state_with_pkce"
|
|
|
|
# Call authorize endpoint with PKCE parameters
|
|
response = await authorize(
|
|
request=mock_request,
|
|
client_id="669428968603-test.apps.googleusercontent.com",
|
|
mcp_server_name="google_mcp",
|
|
redirect_uri="http://localhost:60108/callback",
|
|
state="test_client_state",
|
|
code_challenge="x6YH_qgwbvOzbsHDuL1sW9gYkR9-gObUiIB5RkPwxDk",
|
|
code_challenge_method="S256",
|
|
)
|
|
|
|
# Verify response is a redirect
|
|
assert response.status_code == 307
|
|
|
|
# Verify PKCE parameters are included in the redirect URL
|
|
location = response.headers["location"]
|
|
assert "https://accounts.google.com/o/oauth2/v2/auth" in location
|
|
assert "code_challenge=x6YH_qgwbvOzbsHDuL1sW9gYkR9-gObUiIB5RkPwxDk" in location
|
|
assert "code_challenge_method=S256" in location
|
|
assert "client_id=669428968603-test.apps.googleusercontent.com" in location
|
|
assert "response_type=code" in location
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_endpoint_forwards_code_verifier():
|
|
"""Test that token endpoint forwards code_verifier for PKCE flow"""
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
token_endpoint,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
# Clear registry
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
# Create mock OAuth2 server
|
|
oauth2_server = MCPServer(
|
|
server_id="google_mcp",
|
|
name="google_mcp",
|
|
server_name="google_mcp",
|
|
alias="google_mcp",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id="669428968603-test.apps.googleusercontent.com",
|
|
client_secret="GOCSPX-test_secret",
|
|
authorization_url="https://accounts.google.com/o/oauth2/v2/auth",
|
|
token_url="https://oauth2.googleapis.com/token",
|
|
scopes=["https://www.googleapis.com/auth/drive", "openid", "email"],
|
|
)
|
|
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
|
|
|
|
# Mock request
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://litellm-proxy.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
# Mock httpx client response
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"access_token": "ya29.test_access_token",
|
|
"token_type": "Bearer",
|
|
"expires_in": 3599,
|
|
"scope": "openid email https://www.googleapis.com/auth/drive",
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
# Mock the async httpx client with AsyncMock for async methods
|
|
from unittest.mock import AsyncMock
|
|
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client"
|
|
) as mock_get_client:
|
|
mock_async_client = MagicMock()
|
|
# Use AsyncMock for the async post method
|
|
mock_async_client.post = AsyncMock(return_value=mock_response)
|
|
mock_get_client.return_value = mock_async_client
|
|
|
|
# Call token endpoint with code_verifier
|
|
response = await token_endpoint(
|
|
request=mock_request,
|
|
grant_type="authorization_code",
|
|
code="4/test_authorization_code",
|
|
redirect_uri="http://localhost:60108/callback",
|
|
client_id="669428968603-test.apps.googleusercontent.com",
|
|
mcp_server_name="google_mcp",
|
|
client_secret="GOCSPX-test_secret",
|
|
code_verifier="test_code_verifier_from_client",
|
|
)
|
|
|
|
# Verify that the token endpoint was called with code_verifier
|
|
mock_async_client.post.assert_called_once()
|
|
call_args = mock_async_client.post.call_args
|
|
|
|
# Check the data parameter includes code_verifier
|
|
assert call_args[1]["data"]["code_verifier"] == "test_code_verifier_from_client"
|
|
assert call_args[1]["data"]["code"] == "4/test_authorization_code"
|
|
assert call_args[1]["data"]["client_id"] == "669428968603-test.apps.googleusercontent.com"
|
|
assert call_args[1]["data"]["client_secret"] == "GOCSPX-test_secret"
|
|
assert call_args[1]["data"]["grant_type"] == "authorization_code"
|
|
|
|
# Verify response
|
|
response_data = response.body
|
|
import json
|
|
|
|
token_data = json.loads(response_data)
|
|
assert token_data["access_token"] == "ya29.test_access_token"
|
|
assert token_data["token_type"] == "Bearer"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("server_name", [None, "missing"])
|
|
async def test_register_client_without_mcp_server_name_returns_dummy(server_name):
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
register_client,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
# Clear registry to ensure no OAuth2 servers exist (otherwise resolver would find one)
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://proxy.litellm.example/"
|
|
mock_request.headers = {}
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.read_request_body",
|
|
new=AsyncMock(return_value={}),
|
|
):
|
|
result = await register_client(request=mock_request, mcp_server_name=server_name)
|
|
|
|
assert result == {
|
|
"client_id": server_name or "dummy_client",
|
|
"client_secret": "dummy",
|
|
"redirect_uris": ["https://proxy.litellm.example/callback"],
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("use_root", [False, True])
|
|
async def test_register_client_returns_existing_server_credentials(use_root):
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
register_client,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
oauth2_server = MCPServer(
|
|
server_id="stored_server",
|
|
name="stored_server",
|
|
server_name="stored_server",
|
|
alias="stored_server",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id="existing-client",
|
|
client_secret="existing-secret",
|
|
authorization_url="https://provider.example/oauth/authorize",
|
|
token_url="https://provider.example/oauth/token",
|
|
)
|
|
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://proxy.litellm.example/"
|
|
mock_request.headers = {}
|
|
|
|
try:
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.read_request_body",
|
|
new=AsyncMock(return_value={}),
|
|
):
|
|
result = await register_client(
|
|
request=mock_request, mcp_server_name=None if use_root else oauth2_server.server_name
|
|
)
|
|
finally:
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
assert result == {
|
|
"client_id": "stored_server",
|
|
"client_secret": "dummy",
|
|
"redirect_uris": ["https://proxy.litellm.example/callback"],
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_client_remote_registration_success():
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
register_client,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
oauth2_server = MCPServer(
|
|
server_id="remote_server",
|
|
name="remote_server",
|
|
server_name="remote_server",
|
|
alias="remote_server",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id=None,
|
|
client_secret=None,
|
|
authorization_url="https://provider.example/oauth/authorize",
|
|
token_url="https://provider.example/oauth/token",
|
|
registration_url="https://provider.example/oauth/register",
|
|
)
|
|
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://proxy.litellm.example/"
|
|
mock_request.headers = {}
|
|
|
|
request_payload = {
|
|
"client_name": "Litellm Proxy",
|
|
"grant_types": ["authorization_code", "refresh_token"],
|
|
"response_types": ["code"],
|
|
"token_endpoint_auth_method": "client_secret_post",
|
|
}
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"client_id": "generated-client",
|
|
"client_secret": "generated-secret",
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_async_client = MagicMock()
|
|
mock_async_client.post = AsyncMock(return_value=mock_response)
|
|
|
|
try:
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.read_request_body",
|
|
new=AsyncMock(return_value=request_payload),
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=mock_async_client,
|
|
),
|
|
):
|
|
response = await register_client(request=mock_request, mcp_server_name=oauth2_server.server_name)
|
|
finally:
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
import json
|
|
|
|
assert response.status_code == 200
|
|
payload = json.loads(response.body.decode("utf-8"))
|
|
assert payload == mock_response.json.return_value
|
|
|
|
mock_async_client.post.assert_called_once()
|
|
call_args = mock_async_client.post.call_args
|
|
assert call_args.args[0] == oauth2_server.registration_url
|
|
assert call_args.kwargs["headers"] == {
|
|
"Content-Type": "application/json",
|
|
"Accept": "application/json",
|
|
}
|
|
assert call_args.kwargs["json"]["redirect_uris"] == ["https://proxy.litellm.example/callback"]
|
|
assert call_args.kwargs["json"]["grant_types"] == request_payload["grant_types"]
|
|
assert call_args.kwargs["json"]["token_endpoint_auth_method"] == request_payload["token_endpoint_auth_method"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_client_non_bridge_returns_client_redirect_not_gateway_callback():
|
|
"""Regression for the DCR self-redirect loop (#33699). A plain oauth2 DCR server relays the
|
|
gateway's own /callback upstream, which is correct for the relay leg, but the client-facing
|
|
/register response must echo the CLIENT's own redirect_uris. A Rovo-style upstream echoes back
|
|
whatever redirect_uris it was registered with (here the gateway callback); returning that
|
|
verbatim makes a spec-compliant DCR client adopt /callback as its own redirect and loop."""
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import register_client
|
|
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
oauth2_server = MCPServer(
|
|
server_id="rovo_like",
|
|
name="rovo_like",
|
|
server_name="rovo_like",
|
|
alias="rovo_like",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id=None,
|
|
client_secret=None,
|
|
authorization_url="https://provider.example/oauth/authorize",
|
|
token_url="https://provider.example/oauth/token",
|
|
registration_url="https://provider.example/oauth/register",
|
|
)
|
|
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://proxy.litellm.example/"
|
|
mock_request.headers = {}
|
|
|
|
client_redirect = "https://open-webui.example/oauth/oidc/callback"
|
|
request_payload = {
|
|
"client_name": "Open WebUI",
|
|
"grant_types": ["authorization_code", "refresh_token"],
|
|
"response_types": ["code"],
|
|
"redirect_uris": [client_redirect],
|
|
}
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"client_id": "upstream-generated-client-id",
|
|
"client_secret": "upstream-generated-secret",
|
|
"redirect_uris": ["https://proxy.litellm.example/callback"],
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_async_client = MagicMock()
|
|
mock_async_client.post = AsyncMock(return_value=mock_response)
|
|
|
|
try:
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.read_request_body",
|
|
new=AsyncMock(return_value=request_payload),
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=mock_async_client,
|
|
),
|
|
):
|
|
response = await register_client(request=mock_request, mcp_server_name=oauth2_server.server_name)
|
|
finally:
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
payload = json.loads(response.body.decode("utf-8"))
|
|
assert payload["redirect_uris"] == [client_redirect]
|
|
assert payload["client_id"] == "upstream-generated-client-id"
|
|
assert mock_async_client.post.call_args.kwargs["json"]["redirect_uris"] == [
|
|
"https://proxy.litellm.example/callback"
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_client_admin_client_id_echoes_client_redirect_uris():
|
|
"""A server with an admin-configured client_id short-circuits registration to a placeholder
|
|
response, which must still echo the client's own redirect_uris so a DCR client does not adopt
|
|
the gateway /callback and self-redirect loop (#33699)."""
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import register_client
|
|
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
oauth2_server = MCPServer(
|
|
server_id="stored_server",
|
|
name="stored_server",
|
|
server_name="stored_server",
|
|
alias="stored_server",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id="existing-client",
|
|
client_secret="existing-secret",
|
|
authorization_url="https://provider.example/oauth/authorize",
|
|
token_url="https://provider.example/oauth/token",
|
|
)
|
|
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://proxy.litellm.example/"
|
|
mock_request.headers = {}
|
|
|
|
client_redirect = "https://open-webui.example/oauth/oidc/callback"
|
|
|
|
try:
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.read_request_body",
|
|
new=AsyncMock(return_value={"redirect_uris": [client_redirect]}),
|
|
):
|
|
result = await register_client(request=mock_request, mcp_server_name=oauth2_server.server_name)
|
|
finally:
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
assert result == {
|
|
"client_id": "stored_server",
|
|
"client_secret": "dummy",
|
|
"redirect_uris": [client_redirect],
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dcr_full_loop_lands_on_client_redirect_not_gateway_callback(monkeypatch):
|
|
"""End-to-end regression for #33699. A DCR client registers, then completes /authorize and
|
|
/callback. With the fix the client registers and authorizes with its OWN redirect, so /callback
|
|
delivers the code to the client's real endpoint instead of looping back into the gateway
|
|
/callback (whose decrypt of the client's opaque state failed as 'Incorrect padding'). The
|
|
client's separate origin is trusted via MCP_TRUSTED_REDIRECT_ORIGINS."""
|
|
from http.cookies import SimpleCookie
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
_oauth_state_cookie_name,
|
|
authorize_with_server,
|
|
callback,
|
|
register_client,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-test-salt-33699")
|
|
monkeypatch.setenv("MCP_TRUSTED_REDIRECT_ORIGINS", "open-webui.example")
|
|
|
|
client_redirect = "https://open-webui.example/oauth/oidc/callback"
|
|
client_state = "client-opaque-state-777"
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
server = MCPServer(
|
|
server_id="rovo_like",
|
|
name="rovo_like",
|
|
server_name="rovo_like",
|
|
alias="rovo_like",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id=None,
|
|
client_secret=None,
|
|
authorization_url="https://provider.example/oauth/authorize",
|
|
token_url="https://provider.example/oauth/token",
|
|
registration_url="https://provider.example/oauth/register",
|
|
)
|
|
global_mcp_server_manager.registry[server.server_id] = server
|
|
|
|
reg_request = MagicMock(spec=Request)
|
|
reg_request.base_url = "https://proxy.example.com/"
|
|
reg_request.headers = {}
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"client_id": "upstream-generated-client-id",
|
|
"client_secret": "upstream-generated-secret",
|
|
"redirect_uris": ["https://proxy.example.com/callback"],
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_async_client = MagicMock()
|
|
mock_async_client.post = AsyncMock(return_value=mock_response)
|
|
|
|
try:
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.read_request_body",
|
|
new=AsyncMock(
|
|
return_value={
|
|
"client_name": "Open WebUI",
|
|
"redirect_uris": [client_redirect],
|
|
"grant_types": ["authorization_code", "refresh_token"],
|
|
"response_types": ["code"],
|
|
}
|
|
),
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=mock_async_client,
|
|
),
|
|
):
|
|
reg_response = await register_client(request=reg_request, mcp_server_name=server.server_name)
|
|
|
|
reg_payload = json.loads(reg_response.body.decode("utf-8"))
|
|
assert reg_payload["redirect_uris"] == [client_redirect]
|
|
registered_redirect = reg_payload["redirect_uris"][0]
|
|
|
|
authorize_request = MagicMock(spec=Request)
|
|
authorize_request.base_url = "https://proxy.example.com/"
|
|
authorize_request.headers = {}
|
|
authorize_response = await authorize_with_server(
|
|
request=authorize_request,
|
|
mcp_server=server,
|
|
client_id="upstream-generated-client-id",
|
|
redirect_uri=registered_redirect,
|
|
state=client_state,
|
|
code_challenge="challenge",
|
|
code_challenge_method="S256",
|
|
)
|
|
finally:
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
assert authorize_response.status_code == 307
|
|
location = authorize_response.headers["location"]
|
|
upstream_state = parse_qs(urlparse(location).query)["state"][0]
|
|
assert upstream_state != client_state
|
|
assert "redirect_uri=https%3A%2F%2Fproxy.example.com%2Fcallback" in location
|
|
|
|
jar = SimpleCookie()
|
|
jar.load(authorize_response.headers["set-cookie"])
|
|
cookie_name = _oauth_state_cookie_name(upstream_state)
|
|
morsel = jar[cookie_name]
|
|
|
|
callback_request = MagicMock(spec=Request)
|
|
callback_request.base_url = "https://proxy.example.com/"
|
|
callback_request.headers = {}
|
|
callback_request.cookies = {cookie_name: morsel.value}
|
|
|
|
callback_response = await callback(
|
|
request=callback_request,
|
|
code="upstream-auth-code",
|
|
state=upstream_state,
|
|
)
|
|
|
|
assert callback_response.status_code == 302
|
|
final = urlparse(callback_response.headers["location"])
|
|
assert f"{final.scheme}://{final.netloc}{final.path}" == client_redirect
|
|
final_query = parse_qs(final.query)
|
|
assert final_query["code"] == ["upstream-auth-code"]
|
|
assert final_query["state"] == [client_state]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authorize_rejects_untrusted_cross_origin_redirect_with_allowlist_hint(monkeypatch):
|
|
"""Once the client uses its own separate-origin redirect (#33699 fix), an untrusted origin is
|
|
rejected at /authorize. The rejection must point the operator to MCP_TRUSTED_REDIRECT_ORIGINS,
|
|
the mechanism a legitimate separate-origin DCR client needs, not only to PROXY_BASE_URL."""
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import authorize
|
|
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
monkeypatch.delenv("MCP_TRUSTED_REDIRECT_ORIGINS", raising=False)
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
oauth2_server = MCPServer(
|
|
server_id="rovo_like",
|
|
name="rovo_like",
|
|
server_name="rovo_like",
|
|
alias="rovo_like",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id="upstream-client",
|
|
authorization_url="https://provider.example/oauth/authorize",
|
|
token_url="https://provider.example/oauth/token",
|
|
)
|
|
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://proxy.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
try:
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await authorize(
|
|
request=mock_request,
|
|
client_id="upstream-client",
|
|
mcp_server_name="rovo_like",
|
|
redirect_uri="https://open-webui.example/oauth/oidc/callback",
|
|
state="s",
|
|
)
|
|
finally:
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
assert exc_info.value.status_code == 400
|
|
assert "MCP_TRUSTED_REDIRECT_ORIGINS" in exc_info.value.detail["hint"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"malformed_redirect_uris",
|
|
[
|
|
"https://evil.example/cb",
|
|
["https://ok.example/cb", None],
|
|
["https://ok.example/cb", 123],
|
|
["https://ok.example/cb", {"nested": "object"}],
|
|
[""],
|
|
[],
|
|
],
|
|
)
|
|
async def test_register_client_malformed_redirect_uris_falls_back_to_gateway_callback(malformed_redirect_uris):
|
|
"""RFC 7591 redirect_uris is a non-empty array of URI strings. A client that sends any other shape
|
|
(a bare string, a list holding a non-string or empty-string element, or an empty list) must not
|
|
have that value echoed back as its redirect_uris; the register response falls back to the gateway
|
|
callback so downstream never iterates a string as URIs or leaks non-string element types (#33699)."""
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import register_client
|
|
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
oauth2_server = MCPServer(
|
|
server_id="stored_server",
|
|
name="stored_server",
|
|
server_name="stored_server",
|
|
alias="stored_server",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id="existing-client",
|
|
client_secret="existing-secret",
|
|
authorization_url="https://provider.example/oauth/authorize",
|
|
token_url="https://provider.example/oauth/token",
|
|
)
|
|
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://proxy.litellm.example/"
|
|
mock_request.headers = {}
|
|
|
|
try:
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.read_request_body",
|
|
new=AsyncMock(return_value={"redirect_uris": malformed_redirect_uris}),
|
|
):
|
|
result = await register_client(request=mock_request, mcp_server_name=oauth2_server.server_name)
|
|
finally:
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
assert result["redirect_uris"] == ["https://proxy.litellm.example/callback"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_client_valid_multi_redirect_uris_all_echoed():
|
|
"""A well-formed client sending several valid redirect URI strings gets all of them echoed back
|
|
unchanged, so the element-type guard does not narrow a legitimate multi-entry list (#33699)."""
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import register_client
|
|
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
oauth2_server = MCPServer(
|
|
server_id="stored_server",
|
|
name="stored_server",
|
|
server_name="stored_server",
|
|
alias="stored_server",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id="existing-client",
|
|
client_secret="existing-secret",
|
|
authorization_url="https://provider.example/oauth/authorize",
|
|
token_url="https://provider.example/oauth/token",
|
|
)
|
|
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://proxy.litellm.example/"
|
|
mock_request.headers = {}
|
|
|
|
client_redirects = ["https://app.example/cb", "http://127.0.0.1:6274/callback"]
|
|
try:
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.read_request_body",
|
|
new=AsyncMock(return_value={"redirect_uris": client_redirects}),
|
|
):
|
|
result = await register_client(request=mock_request, mcp_server_name=oauth2_server.server_name)
|
|
finally:
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
assert result["redirect_uris"] == client_redirects
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_client_persists_dcr_client_identity():
|
|
"""A dynamic client registration (RFC 7591) must persist the issued client_id /
|
|
client_secret / token_endpoint_auth_method and the token_url onto the server row so
|
|
autonomous refresh can authenticate as the registered client. Without persistence the
|
|
minted client_id is discarded and the refresh_token grant has no client identity.
|
|
|
|
The persist must also stamp oauth2_flow="authorization_code": only the interactive
|
|
flow reaches this persist, and without the stamp the row (client creds + token_url,
|
|
no persisted authorization_url) matches the legacy M2M inference whenever endpoint
|
|
discovery fails at registry build, flipping the server to client_credentials."""
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
register_client_with_server,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
oauth2_server = MCPServer(
|
|
server_id="remote_server",
|
|
name="remote_server",
|
|
server_name="remote_server",
|
|
alias="remote_server",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id=None,
|
|
client_secret=None,
|
|
authorization_url="https://provider.example/oauth/authorize",
|
|
token_url="https://provider.example/oauth/token",
|
|
registration_url="https://provider.example/oauth/register",
|
|
)
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://proxy.litellm.example/"
|
|
mock_request.headers = {}
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"client_id": "generated-client",
|
|
"client_secret": "generated-secret",
|
|
"token_endpoint_auth_method": "client_secret_basic",
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_async_client = MagicMock()
|
|
mock_async_client.post = AsyncMock(return_value=mock_response)
|
|
|
|
prisma = MagicMock()
|
|
prisma.db.litellm_mcpservertable.find_unique = AsyncMock(return_value=None)
|
|
mock_update = AsyncMock(return_value=MagicMock())
|
|
mock_update_server = AsyncMock()
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=mock_async_client,
|
|
),
|
|
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=prisma),
|
|
patch("litellm.proxy.mcp_server.db.update_mcp_server", new=mock_update),
|
|
patch.object(global_mcp_server_manager, "update_server", new=mock_update_server),
|
|
):
|
|
response = await register_client_with_server(
|
|
request=mock_request,
|
|
mcp_server=oauth2_server,
|
|
client_name="Litellm Proxy",
|
|
grant_types=["authorization_code", "refresh_token"],
|
|
response_types=["code"],
|
|
token_endpoint_auth_method="client_secret_basic",
|
|
replace_stale_client=True,
|
|
)
|
|
|
|
import json
|
|
|
|
assert response.status_code == 200
|
|
assert json.loads(response.body.decode("utf-8")) == {
|
|
**mock_response.json.return_value,
|
|
"dcr_issuer": None,
|
|
"dcr_server_url": None,
|
|
"dcr_redirect_uris": ["https://proxy.litellm.example/callback"],
|
|
}
|
|
|
|
mock_update.assert_called_once()
|
|
update_data = mock_update.call_args.kwargs["data"]
|
|
assert update_data.server_id == "remote_server"
|
|
assert update_data.token_url == "https://provider.example/oauth/token"
|
|
assert "authorization_url" not in update_data.fields_set()
|
|
assert update_data.credentials["client_id"] == "generated-client"
|
|
assert update_data.credentials["client_secret"] == "generated-secret"
|
|
assert update_data.credentials["token_endpoint_auth_method"] == "client_secret_basic"
|
|
assert update_data.credentials["redirect_uris"] == ["https://proxy.litellm.example/callback"]
|
|
assert update_data.oauth2_flow == "authorization_code"
|
|
|
|
mock_update_server.assert_called_once()
|
|
|
|
|
|
async def _register_persistence_attempted_for_auth_type(auth_type: MCPAuth) -> bool:
|
|
"""Run register_client_with_server with replace_stale_client=True for a server of ``auth_type``
|
|
and report whether the DCR result was persisted onto the server row. The client-forwarded token
|
|
modes must skip the persist even on the admin path: writing it stamps oauth2_flow and a
|
|
client_id onto a server whose contract is that the gateway stores nothing, which makes a fresh
|
|
pass-through server read as gateway-authorized. The upstream registration must still be relayed
|
|
to the browser either way, since the caller needs the minted client to run its own flow."""
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
register_client_with_server,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
server = MCPServer(
|
|
server_id="pt_server",
|
|
name="pt_server",
|
|
server_name="pt_server",
|
|
alias="pt_server",
|
|
transport=MCPTransport.http,
|
|
auth_type=auth_type,
|
|
client_id=None,
|
|
client_secret=None,
|
|
authorization_url="https://provider.example/oauth/authorize",
|
|
token_url="https://provider.example/oauth/token",
|
|
registration_url="https://provider.example/oauth/register",
|
|
)
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://proxy.litellm.example/"
|
|
mock_request.headers = {}
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"client_id": "generated-client",
|
|
"client_secret": "generated-secret",
|
|
"token_endpoint_auth_method": "none",
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_async_client = MagicMock()
|
|
mock_async_client.post = AsyncMock(return_value=mock_response)
|
|
|
|
prisma = MagicMock()
|
|
prisma.db.litellm_mcpservertable.find_unique = AsyncMock(return_value=None)
|
|
mock_update = AsyncMock(return_value=MagicMock())
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=mock_async_client,
|
|
),
|
|
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=prisma),
|
|
patch("litellm.proxy.mcp_server.db.update_mcp_server", new=mock_update),
|
|
patch.object(global_mcp_server_manager, "update_server", new=AsyncMock()),
|
|
):
|
|
response = await register_client_with_server(
|
|
request=mock_request,
|
|
mcp_server=server,
|
|
client_name="Litellm Proxy",
|
|
grant_types=["authorization_code", "refresh_token"],
|
|
response_types=["code"],
|
|
token_endpoint_auth_method="none",
|
|
replace_stale_client=True,
|
|
)
|
|
|
|
expected_binding = (
|
|
{
|
|
"dcr_issuer": None,
|
|
"dcr_server_url": None,
|
|
"dcr_redirect_uris": ["https://proxy.litellm.example/callback"],
|
|
}
|
|
if auth_type == MCPAuth.oauth2
|
|
else {}
|
|
)
|
|
assert json.loads(response.body.decode("utf-8")) == {**mock_response.json.return_value, **expected_binding}
|
|
return mock_update.await_count > 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("auth_type", [MCPAuth.true_passthrough, MCPAuth.oauth_delegate])
|
|
async def test_register_client_does_not_persist_for_client_forwarded_modes(auth_type):
|
|
"""The admin Authorize path (replace_stale_client=True) must not write the DCR client onto a
|
|
true_passthrough / oauth_delegate server row: the browser still receives the registration, but
|
|
the gateway keeps no OAuth client identity for these modes."""
|
|
assert await _register_persistence_attempted_for_auth_type(auth_type) is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_client_persist_discriminator_oauth2_persists():
|
|
"""Guard the no-persist assertion above against vacuity: the same helper run against a genuine
|
|
oauth2 server DOES persist, so a regression that silently disables persistence everywhere (or a
|
|
helper that never reaches the persist) fails here instead of passing both."""
|
|
assert await _register_persistence_attempted_for_auth_type(MCPAuth.oauth2) is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_client_persists_only_to_its_own_row_when_another_server_shares_the_url():
|
|
"""A fresh server must mint and persist its OWN DCR client even when another server row with
|
|
the same upstream URL already holds one: both the reuse lookup and the persist are keyed by
|
|
server_id, never by URL, so OAuth client identity is not transferable between server entries.
|
|
If either side ever falls back to a URL match, this fails: the fresh server would skip the
|
|
upstream registration (adopting the sibling's client) or persist onto the wrong row."""
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
register_client_with_server,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
shared_url = "https://provider.example/mcp"
|
|
fresh_server = MCPServer(
|
|
server_id="server-b",
|
|
name="server-b",
|
|
server_name="server-b",
|
|
alias="server-b",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
url=shared_url,
|
|
client_id=None,
|
|
client_secret=None,
|
|
authorization_url="https://provider.example/oauth/authorize",
|
|
token_url="https://provider.example/oauth/token",
|
|
registration_url="https://provider.example/oauth/register",
|
|
)
|
|
sibling_row_with_client = MagicMock(server_id="server-a", url=shared_url)
|
|
sibling_row_with_client.credentials = {"client_id": "client-a-do-not-adopt"}
|
|
own_row_without_client = LiteLLM_MCPServerTable.model_validate(fresh_server.model_dump(exclude_none=True))
|
|
own_row_without_client.credentials = {}
|
|
rows_by_server_id = {"server-a": sibling_row_with_client, "server-b": own_row_without_client}
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://proxy.litellm.example/"
|
|
mock_request.headers = {}
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {"client_id": "fresh-client-b", "token_endpoint_auth_method": "none"}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_async_client = MagicMock()
|
|
mock_async_client.post = AsyncMock(return_value=mock_response)
|
|
|
|
mock_update = AsyncMock(return_value=MagicMock())
|
|
|
|
async def _get_row(prisma_client, server_id):
|
|
return rows_by_server_id.get(server_id)
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=mock_async_client,
|
|
),
|
|
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
|
|
patch("litellm.proxy.mcp_server.db.get_mcp_server", new=AsyncMock(side_effect=_get_row)),
|
|
patch("litellm.proxy.mcp_server.db.update_mcp_server", new=mock_update),
|
|
patch.object(global_mcp_server_manager, "update_server", new=AsyncMock()),
|
|
):
|
|
response = await register_client_with_server(
|
|
request=mock_request,
|
|
mcp_server=fresh_server,
|
|
client_name="Litellm Proxy",
|
|
grant_types=["authorization_code", "refresh_token"],
|
|
response_types=["code"],
|
|
token_endpoint_auth_method="none",
|
|
replace_stale_client=True,
|
|
)
|
|
|
|
mock_async_client.post.assert_called_once()
|
|
body = json.loads(response.body.decode("utf-8"))
|
|
assert body["client_id"] == "fresh-client-b"
|
|
|
|
mock_update.assert_called_once()
|
|
update_data = mock_update.call_args.kwargs["data"]
|
|
assert update_data.server_id == "server-b"
|
|
assert update_data.credentials["client_id"] == "fresh-client-b"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_client_does_not_clobber_token_url_when_absent():
|
|
"""When the in-memory server has no token_url, the DCR persist must omit it from the
|
|
partial update rather than passing None, so exclude_unset leaves the token_url column
|
|
untouched instead of overwriting an existing value with NULL."""
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
register_client_with_server,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
oauth2_server = MCPServer(
|
|
server_id="remote_server",
|
|
name="remote_server",
|
|
server_name="remote_server",
|
|
alias="remote_server",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id=None,
|
|
client_secret=None,
|
|
authorization_url="https://provider.example/oauth/authorize",
|
|
token_url=None,
|
|
registration_url="https://provider.example/oauth/register",
|
|
)
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://proxy.litellm.example/"
|
|
mock_request.headers = {}
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {"client_id": "generated-client"}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_async_client = MagicMock()
|
|
mock_async_client.post = AsyncMock(return_value=mock_response)
|
|
|
|
prisma = MagicMock()
|
|
prisma.db.litellm_mcpservertable.find_unique = AsyncMock(return_value=None)
|
|
mock_update = AsyncMock(return_value=MagicMock())
|
|
mock_update_server = AsyncMock()
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=mock_async_client,
|
|
),
|
|
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=prisma),
|
|
patch("litellm.proxy.mcp_server.db.update_mcp_server", new=mock_update),
|
|
patch.object(global_mcp_server_manager, "update_server", new=mock_update_server),
|
|
):
|
|
await register_client_with_server(
|
|
request=mock_request,
|
|
mcp_server=oauth2_server,
|
|
client_name="Litellm Proxy",
|
|
grant_types=["authorization_code", "refresh_token"],
|
|
response_types=["code"],
|
|
token_endpoint_auth_method="none",
|
|
replace_stale_client=True,
|
|
)
|
|
|
|
mock_update.assert_called_once()
|
|
update_data = mock_update.call_args.kwargs["data"]
|
|
assert update_data.credentials["client_id"] == "generated-client"
|
|
assert "token_url" not in update_data.model_fields_set
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_client_reuses_persisted_client_id_for_non_admin_when_registry_is_stale():
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
register_client_with_server,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
oauth2_server = MCPServer(
|
|
server_id="remote_server",
|
|
name="remote_server",
|
|
server_name="remote_server",
|
|
alias="remote_server",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id=None,
|
|
client_secret=None,
|
|
authorization_url="https://provider.example/oauth/authorize",
|
|
token_url="https://provider.example/oauth/token",
|
|
registration_url="https://provider.example/oauth/register",
|
|
)
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://proxy.litellm.example/"
|
|
mock_request.headers = {}
|
|
|
|
persisted_server = LiteLLM_MCPServerTable.model_validate(oauth2_server.model_dump(exclude_none=True))
|
|
persisted_server.credentials = {"client_id": "persisted-client"}
|
|
mock_get_mcp_server = AsyncMock(return_value=persisted_server)
|
|
mock_update_mcp_server = AsyncMock()
|
|
mock_update_server = AsyncMock()
|
|
mock_async_client = MagicMock()
|
|
mock_async_client.post = AsyncMock()
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=mock_async_client,
|
|
),
|
|
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
|
|
patch(
|
|
"litellm.proxy.mcp_server.db.get_mcp_server",
|
|
new=mock_get_mcp_server,
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.db.update_mcp_server",
|
|
new=mock_update_mcp_server,
|
|
),
|
|
patch.object(global_mcp_server_manager, "update_server", new=mock_update_server),
|
|
):
|
|
response = await register_client_with_server(
|
|
request=mock_request,
|
|
mcp_server=oauth2_server,
|
|
client_name="Litellm Proxy",
|
|
grant_types=["authorization_code", "refresh_token"],
|
|
response_types=["code"],
|
|
token_endpoint_auth_method="none",
|
|
replace_stale_client=False,
|
|
)
|
|
|
|
assert response["client_id"] == "remote_server"
|
|
assert oauth2_server.client_id == "persisted-client"
|
|
mock_async_client.post.assert_not_called()
|
|
mock_update_mcp_server.assert_not_called()
|
|
mock_update_server.assert_called_once_with(persisted_server)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_client_reuse_refreshes_request_server_when_manager_update_fails():
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
register_client_with_server,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
oauth2_server = MCPServer(
|
|
server_id="remote_server",
|
|
name="remote_server",
|
|
server_name="remote_server",
|
|
alias="remote_server",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id=None,
|
|
client_secret=None,
|
|
authorization_url="https://provider.example/oauth/authorize",
|
|
token_url="https://provider.example/oauth/token",
|
|
registration_url="https://provider.example/oauth/register",
|
|
)
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://proxy.litellm.example/"
|
|
mock_request.headers = {}
|
|
|
|
persisted_server = LiteLLM_MCPServerTable.model_validate(oauth2_server.model_dump(exclude_none=True))
|
|
persisted_server.credentials = {
|
|
"client_id": "persisted-client",
|
|
"client_secret": "persisted-secret",
|
|
"token_endpoint_auth_method": "client_secret_basic",
|
|
}
|
|
mock_get_mcp_server = AsyncMock(return_value=persisted_server)
|
|
mock_async_client = MagicMock()
|
|
mock_async_client.post = AsyncMock()
|
|
mock_update_server = AsyncMock(side_effect=RuntimeError("registry update failed"))
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=mock_async_client,
|
|
),
|
|
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
|
|
patch(
|
|
"litellm.proxy.mcp_server.db.get_mcp_server",
|
|
new=mock_get_mcp_server,
|
|
),
|
|
patch.object(global_mcp_server_manager, "update_server", new=mock_update_server),
|
|
):
|
|
response = await register_client_with_server(
|
|
request=mock_request,
|
|
mcp_server=oauth2_server,
|
|
client_name="Litellm Proxy",
|
|
grant_types=["authorization_code", "refresh_token"],
|
|
response_types=["code"],
|
|
token_endpoint_auth_method="none",
|
|
replace_stale_client=False,
|
|
)
|
|
|
|
assert response["client_id"] == "remote_server"
|
|
assert oauth2_server.client_id == "persisted-client"
|
|
assert oauth2_server.client_secret == "persisted-secret"
|
|
assert oauth2_server.token_endpoint_auth_method == "client_secret_basic"
|
|
mock_async_client.post.assert_not_called()
|
|
mock_update_server.assert_called_once_with(persisted_server)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("late_winner", [False, True])
|
|
async def test_register_client_returns_reused_client_when_concurrent_persist_wins(late_winner):
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
register_client_with_server,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
oauth2_server = MCPServer(
|
|
server_id="remote_server",
|
|
name="remote_server",
|
|
server_name="remote_server",
|
|
alias="remote_server",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id=None,
|
|
client_secret=None,
|
|
authorization_url="https://provider.example/oauth/authorize",
|
|
token_url="https://provider.example/oauth/token",
|
|
registration_url="https://provider.example/oauth/register",
|
|
)
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://proxy.litellm.example/"
|
|
mock_request.headers = {}
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {"client_id": "generated-client"}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_async_client = MagicMock()
|
|
mock_async_client.post = AsyncMock(return_value=mock_response)
|
|
|
|
persisted_server = LiteLLM_MCPServerTable.model_validate(oauth2_server.model_dump(exclude_none=True))
|
|
persisted_server.credentials = {"client_id": "persisted-client"}
|
|
empty_server = persisted_server.model_copy(update={"credentials": None})
|
|
mock_get_mcp_server = AsyncMock(
|
|
side_effect=[empty_server, empty_server, persisted_server] if late_winner else [empty_server, persisted_server]
|
|
)
|
|
mock_update_mcp_server = AsyncMock(return_value=None)
|
|
mock_update_server = AsyncMock()
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=mock_async_client,
|
|
),
|
|
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
|
|
patch(
|
|
"litellm.proxy.mcp_server.db.get_mcp_server",
|
|
new=mock_get_mcp_server,
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.db.update_mcp_server",
|
|
new=mock_update_mcp_server,
|
|
),
|
|
patch.object(global_mcp_server_manager, "update_server", new=mock_update_server),
|
|
):
|
|
response = await register_client_with_server(
|
|
request=mock_request,
|
|
mcp_server=oauth2_server,
|
|
client_name="Litellm Proxy",
|
|
grant_types=["authorization_code", "refresh_token"],
|
|
response_types=["code"],
|
|
token_endpoint_auth_method="none",
|
|
replace_stale_client=True,
|
|
)
|
|
|
|
assert response["client_id"] == "remote_server"
|
|
assert oauth2_server.client_id == "persisted-client"
|
|
mock_async_client.post.assert_called_once()
|
|
if late_winner:
|
|
mock_update_mcp_server.assert_awaited_once()
|
|
else:
|
|
mock_update_mcp_server.assert_not_called()
|
|
mock_update_server.assert_called_once_with(persisted_server)
|
|
|
|
|
|
def _dcr_redirect_test_server(client_id):
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
return MCPServer(
|
|
server_id="remote_server",
|
|
name="remote_server",
|
|
server_name="remote_server",
|
|
alias="remote_server",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id=client_id,
|
|
client_secret=None,
|
|
authorization_url="https://provider.example/oauth/authorize",
|
|
token_url="https://provider.example/oauth/token",
|
|
registration_url="https://provider.example/oauth/register",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_client_re_registers_when_persisted_redirect_uri_no_longer_matches_origin():
|
|
"""A persisted DCR client is bound to the redirect_uri it was registered with. When the
|
|
proxy's resolved public origin changes, every authorize built for the reused client is
|
|
rejected IdP-side and the server is permanently stranded (GH #32473). A positive mismatch
|
|
between the recorded redirect_uris and the current callback must therefore re-register on
|
|
the admin path and persist the replacement client, with the new binding recorded and the
|
|
old client's secret/auth method cleared rather than merged into the new identity."""
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
register_client_with_server,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
oauth2_server = _dcr_redirect_test_server(client_id="stale-client")
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://proxy.litellm.example/"
|
|
mock_request.headers = {}
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"client_id": "fresh-client",
|
|
"redirect_uris": ["https://proxy.litellm.example/callback"],
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_async_client = MagicMock()
|
|
mock_async_client.post = AsyncMock(return_value=mock_response)
|
|
|
|
persisted_server = LiteLLM_MCPServerTable.model_validate(oauth2_server.model_dump(exclude_none=True))
|
|
persisted_server.credentials = {
|
|
"client_id": "stale-client",
|
|
"client_secret": "stale-secret",
|
|
"token_endpoint_auth_method": "client_secret_basic",
|
|
"redirect_uris": ["https://old-origin.example/callback"],
|
|
}
|
|
mock_get_mcp_server = AsyncMock(return_value=persisted_server)
|
|
mock_update_mcp_server = AsyncMock(return_value=MagicMock())
|
|
mock_update_server = AsyncMock()
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=mock_async_client,
|
|
),
|
|
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
|
|
patch(
|
|
"litellm.proxy.mcp_server.db.get_mcp_server",
|
|
new=mock_get_mcp_server,
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.db.update_mcp_server",
|
|
new=mock_update_mcp_server,
|
|
),
|
|
patch.object(global_mcp_server_manager, "update_server", new=mock_update_server),
|
|
):
|
|
response = await register_client_with_server(
|
|
request=mock_request,
|
|
mcp_server=oauth2_server,
|
|
client_name="Litellm Proxy",
|
|
grant_types=["authorization_code", "refresh_token"],
|
|
response_types=["code"],
|
|
token_endpoint_auth_method="none",
|
|
replace_stale_client=True,
|
|
)
|
|
|
|
mock_async_client.post.assert_called_once()
|
|
register_payload = mock_async_client.post.call_args.kwargs["json"]
|
|
assert register_payload["redirect_uris"] == ["https://proxy.litellm.example/callback"]
|
|
|
|
mock_update_mcp_server.assert_called_once()
|
|
update_data = mock_update_mcp_server.call_args.kwargs["data"]
|
|
assert update_data.credentials["client_id"] == "fresh-client"
|
|
assert update_data.credentials["redirect_uris"] == ["https://proxy.litellm.example/callback"]
|
|
assert update_data.credentials["client_secret"] is None
|
|
assert update_data.credentials["token_endpoint_auth_method"] is None
|
|
|
|
assert response.status_code == 200
|
|
assert json.loads(response.body.decode("utf-8"))["client_id"] == "fresh-client"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_client_grandfathers_persisted_client_without_recorded_redirect_uris():
|
|
"""Clients persisted before redirect_uris were recorded (and admin-configured clients,
|
|
which never get a recording) have nothing to compare against; treating that as a mismatch
|
|
would re-mint a client_id for every existing install on upgrade and orphan all users'
|
|
refresh tokens for those servers. A missing recording must read as a match: no DCR call,
|
|
no persistence write, existing client returned."""
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
register_client_with_server,
|
|
)
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
oauth2_server = _dcr_redirect_test_server(client_id="legacy-client")
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://proxy.litellm.example/"
|
|
mock_request.headers = {}
|
|
|
|
mock_async_client = MagicMock()
|
|
mock_async_client.post = AsyncMock()
|
|
|
|
persisted_server = LiteLLM_MCPServerTable.model_validate(oauth2_server.model_dump(exclude_none=True))
|
|
persisted_server.credentials = {"client_id": "legacy-client"}
|
|
mock_get_mcp_server = AsyncMock(return_value=persisted_server)
|
|
mock_update_mcp_server = AsyncMock()
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=mock_async_client,
|
|
),
|
|
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
|
|
patch(
|
|
"litellm.proxy.mcp_server.db.get_mcp_server",
|
|
new=mock_get_mcp_server,
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.db.update_mcp_server",
|
|
new=mock_update_mcp_server,
|
|
),
|
|
):
|
|
response = await register_client_with_server(
|
|
request=mock_request,
|
|
mcp_server=oauth2_server,
|
|
client_name="Litellm Proxy",
|
|
grant_types=["authorization_code", "refresh_token"],
|
|
response_types=["code"],
|
|
token_endpoint_auth_method="none",
|
|
replace_stale_client=True,
|
|
)
|
|
|
|
mock_async_client.post.assert_not_called()
|
|
mock_update_mcp_server.assert_not_called()
|
|
assert response["client_secret"] == "dummy"
|
|
assert oauth2_server.client_id == "legacy-client"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_client_keeps_persisted_client_when_recorded_redirect_uri_matches_origin():
|
|
"""When the recorded redirect_uris still cover the current callback the persisted client
|
|
is valid; re-registering would orphan refresh tokens for no reason."""
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
register_client_with_server,
|
|
)
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
oauth2_server = _dcr_redirect_test_server(client_id="kept-client")
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://proxy.litellm.example/"
|
|
mock_request.headers = {}
|
|
|
|
mock_async_client = MagicMock()
|
|
mock_async_client.post = AsyncMock()
|
|
|
|
persisted_server = LiteLLM_MCPServerTable.model_validate(oauth2_server.model_dump(exclude_none=True))
|
|
persisted_server.credentials = {
|
|
"client_id": "kept-client",
|
|
"redirect_uris": ["https://proxy.litellm.example/callback"],
|
|
}
|
|
mock_get_mcp_server = AsyncMock(return_value=persisted_server)
|
|
mock_update_mcp_server = AsyncMock()
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=mock_async_client,
|
|
),
|
|
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
|
|
patch(
|
|
"litellm.proxy.mcp_server.db.get_mcp_server",
|
|
new=mock_get_mcp_server,
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.db.update_mcp_server",
|
|
new=mock_update_mcp_server,
|
|
),
|
|
):
|
|
response = await register_client_with_server(
|
|
request=mock_request,
|
|
mcp_server=oauth2_server,
|
|
client_name="Litellm Proxy",
|
|
grant_types=["authorization_code", "refresh_token"],
|
|
response_types=["code"],
|
|
token_endpoint_auth_method="none",
|
|
replace_stale_client=True,
|
|
)
|
|
|
|
mock_async_client.post.assert_not_called()
|
|
mock_update_mcp_server.assert_not_called()
|
|
assert response["client_secret"] == "dummy"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_client_non_admin_reuses_persisted_client_despite_redirect_mismatch():
|
|
"""Callers without replace authority (the public register routes and non-admin users) must
|
|
keep reusing the persisted client even when its recorded redirect_uris mismatch: a spoofable
|
|
forwarded origin must not let any caller re-register and orphan every user's refresh token.
|
|
Only the admin path re-registers."""
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
register_client_with_server,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
oauth2_server = _dcr_redirect_test_server(client_id=None)
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://proxy.litellm.example/"
|
|
mock_request.headers = {}
|
|
|
|
mock_async_client = MagicMock()
|
|
mock_async_client.post = AsyncMock()
|
|
|
|
persisted_server = LiteLLM_MCPServerTable.model_validate(oauth2_server.model_dump(exclude_none=True))
|
|
persisted_server.credentials = {
|
|
"client_id": "persisted-client",
|
|
"redirect_uris": ["https://old-origin.example/callback"],
|
|
}
|
|
mock_get_mcp_server = AsyncMock(return_value=persisted_server)
|
|
mock_update_server = AsyncMock()
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=mock_async_client,
|
|
),
|
|
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
|
|
patch(
|
|
"litellm.proxy.mcp_server.db.get_mcp_server",
|
|
new=mock_get_mcp_server,
|
|
),
|
|
patch.object(global_mcp_server_manager, "update_server", new=mock_update_server),
|
|
):
|
|
response = await register_client_with_server(
|
|
request=mock_request,
|
|
mcp_server=oauth2_server,
|
|
client_name="Litellm Proxy",
|
|
grant_types=["authorization_code", "refresh_token"],
|
|
response_types=["code"],
|
|
token_endpoint_auth_method="none",
|
|
replace_stale_client=False,
|
|
)
|
|
|
|
mock_async_client.post.assert_not_called()
|
|
assert oauth2_server.client_id == "persisted-client"
|
|
assert response["client_secret"] == "dummy"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_client_without_replace_authority_adopts_concurrently_persisted_client(monkeypatch):
|
|
"""Two users connect to a fresh config-declared server at once. Both miss the store at reuse
|
|
time and both mint a client upstream, but the loser's insert finds the row taken and must
|
|
adopt the winner's client (which the winner's browser is already authorizing under) instead of
|
|
overwriting it, and is answered with the gateway's dummy client."""
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server import discoverable_endpoints
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
monkeypatch.setenv("PROXY_BASE_URL", "https://proxy.litellm.example")
|
|
config_server = _dcr_redirect_test_server(client_id=None)
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://proxy.litellm.example/"
|
|
mock_request.headers = {}
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {"client_id": "loser-client", "token_endpoint_auth_method": "none"}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_async_client = MagicMock()
|
|
mock_async_client.post = AsyncMock(return_value=mock_response)
|
|
winner = {"client_id": "winner-client", "redirect_uris": ["https://proxy.litellm.example/callback"]}
|
|
mock_upsert = AsyncMock()
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=mock_async_client,
|
|
),
|
|
patch.object(global_mcp_server_manager, "is_config_declared_server", return_value=True),
|
|
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
|
|
patch("litellm.proxy.mcp_server.db.get_mcp_server", new=AsyncMock(return_value=None)),
|
|
patch("litellm.proxy.mcp_server.db.update_mcp_server", new=AsyncMock(return_value=None)),
|
|
patch(
|
|
"litellm.proxy.mcp_server.db.get_mcp_server_oauth_client_credentials",
|
|
new=AsyncMock(side_effect=[None, winner]),
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.db.create_mcp_server_oauth_client_credentials_if_absent",
|
|
new=AsyncMock(return_value=False),
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.db.upsert_mcp_server_oauth_client_credentials",
|
|
new=mock_upsert,
|
|
),
|
|
):
|
|
response = await discoverable_endpoints.register_client_with_server(
|
|
request=mock_request,
|
|
mcp_server=config_server,
|
|
client_name="Claude Code",
|
|
grant_types=["authorization_code", "refresh_token"],
|
|
response_types=["code"],
|
|
token_endpoint_auth_method="none",
|
|
replace_stale_client=False,
|
|
)
|
|
|
|
mock_async_client.post.assert_called_once()
|
|
mock_upsert.assert_not_called()
|
|
assert config_server.client_id == "winner-client"
|
|
assert response["client_id"] == config_server.server_name
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_persist_without_replace_authority_never_overwrites_a_row_client_it_cannot_apply():
|
|
"""A DB-declared server already holds a shared DCR client that this process cannot adopt (it
|
|
was bound to another server URL). A caller without replace authority must get a failure, not
|
|
fall through to update_mcp_server and overwrite the client every signed-in user depends on."""
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
_persist_dcr_client_registration,
|
|
)
|
|
|
|
server = _dcr_redirect_test_server(client_id=None).model_copy(update={"url": "https://mine.example/mcp"})
|
|
row = LiteLLM_MCPServerTable.model_validate(server.model_dump(exclude_none=True))
|
|
row.credentials = {"client_id": "someone-elses-client", "dcr_server_url": "https://other.example/mcp"}
|
|
mock_update = AsyncMock()
|
|
|
|
with (
|
|
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
|
|
patch("litellm.proxy.mcp_server.db.get_mcp_server", new=AsyncMock(return_value=row)),
|
|
patch("litellm.proxy.mcp_server.db.update_mcp_server", new=mock_update),
|
|
):
|
|
result = await _persist_dcr_client_registration(
|
|
server, {"client_id": "my-client"}, "https://proxy.litellm.example/callback", replace_stale_client=False
|
|
)
|
|
|
|
assert result == "failed"
|
|
mock_update.assert_not_called()
|
|
assert server.client_id is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_client_reuses_existing_client_id_without_re_dcr():
|
|
"""A server that already has a client_id (admin-configured or previously DCR'd) must be
|
|
reused, not re-registered, even without a client_secret. A client_id is one-per-application
|
|
in OAuth and shared across users; re-minting per authorize would orphan other users' refresh
|
|
tokens by overwriting the server's client_id."""
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
register_client,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
oauth2_server = MCPServer(
|
|
server_id="remote_server",
|
|
name="remote_server",
|
|
server_name="remote_server",
|
|
alias="remote_server",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id="existing-shared-client",
|
|
client_secret=None,
|
|
authorization_url="https://provider.example/oauth/authorize",
|
|
token_url="https://provider.example/oauth/token",
|
|
registration_url="https://provider.example/oauth/register",
|
|
)
|
|
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://proxy.litellm.example/"
|
|
mock_request.headers = {}
|
|
|
|
request_payload = {
|
|
"client_name": "Litellm Proxy",
|
|
"grant_types": ["authorization_code", "refresh_token"],
|
|
"response_types": ["code"],
|
|
"token_endpoint_auth_method": "none",
|
|
}
|
|
|
|
mock_async_client = MagicMock()
|
|
mock_async_client.post = AsyncMock()
|
|
|
|
try:
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.read_request_body",
|
|
new=AsyncMock(return_value=request_payload),
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=mock_async_client,
|
|
),
|
|
):
|
|
response = await register_client(request=mock_request, mcp_server_name=oauth2_server.server_name)
|
|
finally:
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
mock_async_client.post.assert_not_called()
|
|
body = response if isinstance(response, dict) else json.loads(response.body.decode("utf-8"))
|
|
assert body["client_secret"] == "dummy"
|
|
|
|
|
|
def _public_register_rig(minted):
|
|
from fastapi import Request
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://proxy.litellm.example/"
|
|
mock_request.headers = {}
|
|
request_payload = {
|
|
"client_name": "attacker",
|
|
"grant_types": ["implicit"],
|
|
"response_types": ["token"],
|
|
"token_endpoint_auth_method": "client_secret_basic",
|
|
}
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = minted
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_async_client = MagicMock()
|
|
mock_async_client.post = AsyncMock(return_value=mock_response)
|
|
return mock_request, request_payload, mock_async_client
|
|
|
|
|
|
GATEWAY_OWNED_DCR_BODY: Final = {
|
|
"client_name": "LiteLLM Proxy",
|
|
"redirect_uris": ["https://proxy.litellm.example/callback"],
|
|
"grant_types": ["authorization_code", "refresh_token"],
|
|
"response_types": ["code"],
|
|
"token_endpoint_auth_method": "none",
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_public_register_route_persists_first_dcr_client_when_origin_is_configured(monkeypatch):
|
|
"""A DB-declared oauth2 server with no client yet gets its first DCR client from whichever
|
|
client connects first, usually a non-admin user's coding tool on the unauthenticated
|
|
/{server}/register route. With PROXY_BASE_URL set the gateway knows its own callback, so that
|
|
client lands on the server row for every later user's refresh. The gateway owns what it
|
|
registers: the caller's grant types, response types, auth method and name are not sent
|
|
upstream, so an anonymous caller cannot shape or phish the shared client."""
|
|
try:
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
register_client,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
monkeypatch.setenv("PROXY_BASE_URL", "https://proxy.litellm.example")
|
|
global_mcp_server_manager.registry.clear()
|
|
oauth2_server = _dcr_redirect_test_server(client_id=None)
|
|
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
|
|
mock_request, request_payload, mock_async_client = _public_register_rig(
|
|
{
|
|
"client_id": "minted-client",
|
|
"client_secret": "minted-secret",
|
|
"registration_access_token": "manage-me",
|
|
"registration_client_uri": "https://provider.example/oauth/register/minted-client",
|
|
}
|
|
)
|
|
persisted_row = LiteLLM_MCPServerTable.model_validate(oauth2_server.model_dump(exclude_none=True))
|
|
mock_update = AsyncMock(return_value=persisted_row)
|
|
mock_update_server = AsyncMock()
|
|
|
|
try:
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.read_request_body",
|
|
new=AsyncMock(return_value=request_payload),
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=mock_async_client,
|
|
),
|
|
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
|
|
patch(
|
|
"litellm.proxy.mcp_server.db.get_mcp_server",
|
|
new=AsyncMock(return_value=persisted_row),
|
|
),
|
|
patch.object(global_mcp_server_manager, "update_server", new=mock_update_server),
|
|
patch("litellm.proxy.mcp_server.db.update_mcp_server", new=mock_update),
|
|
):
|
|
response = await register_client(request=mock_request, mcp_server_name=oauth2_server.server_name)
|
|
finally:
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
assert mock_async_client.post.call_args.kwargs["json"] == GATEWAY_OWNED_DCR_BODY
|
|
mock_update.assert_called_once()
|
|
assert mock_update.call_args.kwargs["data"].credentials["client_id"] == "minted-client"
|
|
mock_update_server.assert_called_once_with(persisted_row)
|
|
body = json.loads(response.body.decode("utf-8"))
|
|
assert body["client_id"] == "minted-client"
|
|
assert not {"client_secret", "registration_access_token", "registration_client_uri", "dcr_server_url"} & set(body)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_public_register_route_persists_first_dcr_client_for_config_server_and_serves_later_callers(
|
|
monkeypatch,
|
|
):
|
|
"""The customer-visible shape of the bug: a config.yaml-declared DCR server (no
|
|
LiteLLM_MCPServerTable row) whose first connect comes from a non-admin user. The minted client
|
|
must reach the server-scoped OAuth client store, so a token refresh has a client_id and a
|
|
second connect reuses the same client instead of minting another."""
|
|
try:
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
register_client,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
monkeypatch.setenv("PROXY_BASE_URL", "https://proxy.litellm.example")
|
|
global_mcp_server_manager.registry.clear()
|
|
config_server = _dcr_redirect_test_server(client_id=None)
|
|
global_mcp_server_manager.registry[config_server.server_id] = config_server
|
|
mock_request, request_payload, mock_async_client = _public_register_rig(
|
|
{"client_id": "minted-client", "token_endpoint_auth_method": "none"}
|
|
)
|
|
store = {}
|
|
|
|
def insert_if_absent(**kwargs):
|
|
if "blob" in store:
|
|
return False
|
|
store["blob"] = kwargs["credentials"]
|
|
return True
|
|
|
|
mock_create = AsyncMock(side_effect=insert_if_absent)
|
|
mock_upsert = AsyncMock()
|
|
|
|
try:
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.read_request_body",
|
|
new=AsyncMock(return_value=request_payload),
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=mock_async_client,
|
|
),
|
|
patch.object(global_mcp_server_manager, "is_config_declared_server", return_value=True),
|
|
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
|
|
patch("litellm.proxy.mcp_server.db.get_mcp_server", new=AsyncMock(return_value=None)),
|
|
patch("litellm.proxy.mcp_server.db.update_mcp_server", new=AsyncMock(return_value=None)),
|
|
patch(
|
|
"litellm.proxy.mcp_server.db.get_mcp_server_oauth_client_credentials",
|
|
new=AsyncMock(side_effect=lambda **kwargs: store.get("blob")),
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.db.create_mcp_server_oauth_client_credentials_if_absent",
|
|
new=mock_create,
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.db.upsert_mcp_server_oauth_client_credentials",
|
|
new=mock_upsert,
|
|
),
|
|
):
|
|
first = await register_client(request=mock_request, mcp_server_name=config_server.server_name)
|
|
second = await register_client(request=mock_request, mcp_server_name=config_server.server_name)
|
|
finally:
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
mock_create.assert_called_once()
|
|
assert mock_create.call_args.kwargs["server_id"] == config_server.server_id
|
|
assert mock_create.call_args.kwargs["credentials"]["client_id"] == "minted-client"
|
|
mock_upsert.assert_not_called()
|
|
mock_async_client.post.assert_called_once()
|
|
assert json.loads(first.body.decode("utf-8"))["client_id"] == "minted-client"
|
|
assert second["client_id"] == config_server.server_name
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_public_register_route_without_configured_origin_does_not_pin_the_shared_client(monkeypatch):
|
|
"""Without PROXY_BASE_URL the callback comes from the request's own Host, which an anonymous
|
|
caller can forge, so the public route keeps today's behavior: register for this caller with
|
|
the body it sent, echo the upstream response, and persist nothing. An admin's Connect (or
|
|
setting PROXY_BASE_URL) is what establishes the shared client."""
|
|
try:
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
register_client,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
monkeypatch.delenv("PROXY_BASE_URL", raising=False)
|
|
global_mcp_server_manager.registry.clear()
|
|
config_server = _dcr_redirect_test_server(client_id=None)
|
|
global_mcp_server_manager.registry[config_server.server_id] = config_server
|
|
mock_request, request_payload, mock_async_client = _public_register_rig({"client_id": "caller-client"})
|
|
mock_create = AsyncMock()
|
|
mock_upsert = AsyncMock()
|
|
mock_update = AsyncMock()
|
|
|
|
try:
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.read_request_body",
|
|
new=AsyncMock(return_value=request_payload),
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=mock_async_client,
|
|
),
|
|
patch.object(global_mcp_server_manager, "is_config_declared_server", return_value=True),
|
|
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
|
|
patch("litellm.proxy.mcp_server.db.get_mcp_server", new=AsyncMock(return_value=None)),
|
|
patch("litellm.proxy.mcp_server.db.update_mcp_server", new=mock_update),
|
|
patch(
|
|
"litellm.proxy.mcp_server.db.get_mcp_server_oauth_client_credentials",
|
|
new=AsyncMock(return_value=None),
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.db.create_mcp_server_oauth_client_credentials_if_absent",
|
|
new=mock_create,
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.db.upsert_mcp_server_oauth_client_credentials",
|
|
new=mock_upsert,
|
|
),
|
|
):
|
|
response = await register_client(request=mock_request, mcp_server_name=config_server.server_name)
|
|
finally:
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
posted = mock_async_client.post.call_args.kwargs["json"]
|
|
assert posted["grant_types"] == request_payload["grant_types"]
|
|
assert posted["client_name"] == request_payload["client_name"]
|
|
mock_create.assert_not_called()
|
|
mock_upsert.assert_not_called()
|
|
mock_update.assert_not_called()
|
|
assert json.loads(response.body.decode("utf-8"))["client_id"] == "caller-client"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.usefixtures("trust_xff")
|
|
async def test_authorize_endpoint_respects_x_forwarded_proto():
|
|
"""Test that authorize endpoint uses X-Forwarded-Proto header to construct correct redirect_uri"""
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
authorize,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
# Clear registry
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
# Create mock OAuth2 server
|
|
oauth2_server = MCPServer(
|
|
server_id="test_oauth_server",
|
|
name="test_oauth",
|
|
server_name="test_oauth",
|
|
alias="test_oauth",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id="test_client_id",
|
|
client_secret="test_client_secret",
|
|
authorization_url="https://provider.com/oauth/authorize",
|
|
token_url="https://provider.com/oauth/token",
|
|
scopes=["read", "write"],
|
|
)
|
|
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
|
|
|
|
# Mock request with http base_url but X-Forwarded-Proto: https
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "http://litellm.example.com/" # HTTP
|
|
mock_request.headers = {"X-Forwarded-Proto": "https"} # Behind HTTPS proxy
|
|
|
|
# Mock the encryption functions
|
|
with patch("litellm.proxy.mcp_server.discoverable_endpoints.encrypt_value_helper") as mock_encrypt:
|
|
mock_encrypt.return_value = "mocked_encrypted_state"
|
|
|
|
# Call authorize endpoint
|
|
response = await authorize(
|
|
request=mock_request,
|
|
client_id="test_client_id",
|
|
mcp_server_name="test_oauth",
|
|
redirect_uri="http://127.0.0.1:60108/callback",
|
|
state="test_state",
|
|
)
|
|
|
|
# Verify redirect URL uses HTTPS in the redirect_uri parameter
|
|
location = response.headers["location"]
|
|
|
|
# The redirect_uri parameter sent to the OAuth provider should use HTTPS
|
|
assert (
|
|
"redirect_uri=https%3A%2F%2Flitellm.example.com%2Fcallback" in location
|
|
or "redirect_uri=https://litellm.example.com/callback" in location
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.usefixtures("trust_xff")
|
|
async def test_token_endpoint_respects_x_forwarded_proto():
|
|
"""Test that token endpoint uses X-Forwarded-Proto header for redirect_uri"""
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
token_endpoint,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
# Clear registry
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
# Create mock OAuth2 server
|
|
oauth2_server = MCPServer(
|
|
server_id="google_mcp",
|
|
name="google_mcp",
|
|
server_name="google_mcp",
|
|
alias="google_mcp",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id="test_client_id",
|
|
client_secret="test_secret",
|
|
authorization_url="https://accounts.google.com/o/oauth2/v2/auth",
|
|
token_url="https://oauth2.googleapis.com/token",
|
|
scopes=["openid", "email"],
|
|
)
|
|
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
|
|
|
|
# Mock request with http base_url but X-Forwarded-Proto: https
|
|
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
|
|
|
|
# Mock httpx client response
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"access_token": "test_token",
|
|
"token_type": "Bearer",
|
|
"expires_in": 3599,
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
# Mock the async httpx client
|
|
mock_async_client = MagicMock()
|
|
mock_async_client.post = AsyncMock(return_value=mock_response)
|
|
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client"
|
|
) as mock_get_client:
|
|
mock_get_client.return_value = mock_async_client
|
|
|
|
await token_endpoint(
|
|
request=mock_request,
|
|
grant_type="authorization_code",
|
|
code="test_code",
|
|
redirect_uri="http://localhost:60108/callback",
|
|
client_id="test_client_id",
|
|
mcp_server_name="google_mcp",
|
|
client_secret="test_secret",
|
|
)
|
|
|
|
# Verify that the redirect_uri sent to the provider uses HTTPS
|
|
call_args = mock_async_client.post.call_args
|
|
assert call_args[1]["data"]["redirect_uri"] == "https://litellm-proxy.example.com/callback"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.usefixtures("trust_xff")
|
|
async def test_oauth_protected_resource_respects_x_forwarded_proto():
|
|
"""Test that oauth_protected_resource_mcp uses X-Forwarded-Proto for URLs"""
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
oauth_protected_resource_mcp,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
# Clear registry
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
# Create mock OAuth2 server
|
|
oauth2_server = MCPServer(
|
|
server_id="test_oauth_server",
|
|
name="test_oauth",
|
|
server_name="test_oauth",
|
|
alias="test_oauth",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id="test_client_id",
|
|
client_secret="test_client_secret",
|
|
authorization_url="https://provider.com/oauth/authorize",
|
|
token_url="https://provider.com/oauth/token",
|
|
scopes=["read", "write"],
|
|
)
|
|
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
|
|
|
|
# Mock request with http base_url but X-Forwarded-Proto: https
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "http://litellm.example.com/" # HTTP
|
|
mock_request.headers = {"X-Forwarded-Proto": "https"} # Behind HTTPS proxy
|
|
|
|
# Call the endpoint
|
|
response = await oauth_protected_resource_mcp(
|
|
request=mock_request,
|
|
mcp_server_name="test_oauth",
|
|
)
|
|
|
|
# Verify response uses HTTPS URLs
|
|
assert response["authorization_servers"][0].startswith("https://litellm.example.com/")
|
|
assert response["scopes_supported"] == oauth2_server.scopes
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.usefixtures("trust_xff")
|
|
async def test_oauth_authorization_server_respects_x_forwarded_proto():
|
|
"""Test that oauth_authorization_server_mcp uses X-Forwarded-Proto for URLs"""
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
oauth_authorization_server_mcp,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
# Clear registry
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
# Create mock OAuth2 server
|
|
oauth2_server = MCPServer(
|
|
server_id="test_oauth_server",
|
|
name="test_oauth",
|
|
server_name="test_oauth",
|
|
alias="test_oauth",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id="test_client_id",
|
|
client_secret="test_client_secret",
|
|
authorization_url="https://provider.com/oauth/authorize",
|
|
token_url="https://provider.com/oauth/token",
|
|
scopes=["read", "write"],
|
|
)
|
|
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
|
|
|
|
# Mock request with http base_url but X-Forwarded-Proto: https
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "http://litellm.example.com/" # HTTP
|
|
mock_request.headers = {"X-Forwarded-Proto": "https"} # Behind HTTPS proxy
|
|
|
|
# Call the endpoint
|
|
response = await oauth_authorization_server_mcp(
|
|
request=mock_request,
|
|
mcp_server_name="test_oauth",
|
|
)
|
|
|
|
# Verify response uses HTTPS URLs
|
|
assert response["authorization_endpoint"].startswith("https://litellm.example.com/")
|
|
assert response["token_endpoint"].startswith("https://litellm.example.com/")
|
|
assert response["registration_endpoint"].startswith("https://litellm.example.com/")
|
|
assert response["grant_types_supported"] == ["authorization_code", "refresh_token"]
|
|
assert response["scopes_supported"] == oauth2_server.scopes
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.usefixtures("trust_xff")
|
|
async def test_register_client_respects_x_forwarded_proto():
|
|
"""Test that register_client uses X-Forwarded-Proto for redirect_uris"""
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
register_client,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
# Clear registry to ensure no OAuth2 servers exist (otherwise resolver would find one)
|
|
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
|
|
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.read_request_body",
|
|
new=AsyncMock(return_value={}),
|
|
):
|
|
result = await register_client(request=mock_request)
|
|
|
|
# Verify the redirect_uris use HTTPS
|
|
assert result == {
|
|
"client_id": "dummy_client",
|
|
"client_secret": "dummy",
|
|
"redirect_uris": ["https://proxy.litellm.example/callback"],
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.usefixtures("trust_xff")
|
|
async def test_authorize_endpoint_respects_x_forwarded_host():
|
|
"""Test that authorize endpoint uses X-Forwarded-Host and X-Forwarded-Proto to construct correct redirect_uri"""
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
authorize,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
# Clear registry
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
# Create mock OAuth2 server
|
|
oauth2_server = MCPServer(
|
|
server_id="test_oauth_server",
|
|
name="test_oauth",
|
|
server_name="test_oauth",
|
|
alias="test_oauth",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id="test_client_id",
|
|
client_secret="test_client_secret",
|
|
authorization_url="https://provider.com/oauth/authorize",
|
|
token_url="https://provider.com/oauth/token",
|
|
scopes=["read", "write"],
|
|
)
|
|
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
|
|
|
|
# Mock request simulating nginx proxy:
|
|
# Internal: http://localhost:8888/github/mcp
|
|
# External: https://proxy.example.com/github/mcp
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "http://localhost:8888/github/mcp"
|
|
mock_request.headers = {
|
|
"X-Forwarded-Proto": "https",
|
|
"X-Forwarded-Host": "proxy.example.com",
|
|
}
|
|
|
|
# Mock the encryption functions
|
|
with patch("litellm.proxy.mcp_server.discoverable_endpoints.encrypt_value_helper") as mock_encrypt:
|
|
mock_encrypt.return_value = "mocked_encrypted_state"
|
|
|
|
# Call authorize endpoint
|
|
response = await authorize(
|
|
request=mock_request,
|
|
client_id="test_client_id",
|
|
mcp_server_name="test_oauth",
|
|
redirect_uri="http://127.0.0.1:60108/callback",
|
|
state="test_state",
|
|
)
|
|
|
|
# Verify redirect URL uses the forwarded host and scheme
|
|
location = response.headers["location"]
|
|
|
|
# The redirect_uri parameter should use the external URL
|
|
assert (
|
|
"redirect_uri=https%3A%2F%2Fproxy.example.com%2Fgithub%2Fmcp%2Fcallback" in location
|
|
or "redirect_uri=https://proxy.example.com/github/mcp/callback" in location
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.usefixtures("trust_xff")
|
|
async def test_token_endpoint_respects_x_forwarded_host():
|
|
"""Test that token endpoint uses X-Forwarded-Host and X-Forwarded-Proto for redirect_uri"""
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
token_endpoint,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
# Clear registry
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
# Create mock OAuth2 server
|
|
oauth2_server = MCPServer(
|
|
server_id="google_mcp",
|
|
name="google_mcp",
|
|
server_name="google_mcp",
|
|
alias="google_mcp",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id="test_client_id",
|
|
client_secret="test_secret",
|
|
authorization_url="https://accounts.google.com/o/oauth2/v2/auth",
|
|
token_url="https://oauth2.googleapis.com/token",
|
|
scopes=["openid", "email"],
|
|
)
|
|
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
|
|
|
|
# Mock request simulating nginx proxy without port in host
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "http://localhost:8888/github/mcp"
|
|
mock_request.headers = {
|
|
"X-Forwarded-Proto": "https",
|
|
"X-Forwarded-Host": "proxy.example.com",
|
|
}
|
|
|
|
# Mock httpx client response
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"access_token": "test_token",
|
|
"token_type": "Bearer",
|
|
"expires_in": 3599,
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
# Mock the async httpx client
|
|
mock_async_client = MagicMock()
|
|
mock_async_client.post = AsyncMock(return_value=mock_response)
|
|
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client"
|
|
) as mock_get_client:
|
|
mock_get_client.return_value = mock_async_client
|
|
|
|
await token_endpoint(
|
|
request=mock_request,
|
|
grant_type="authorization_code",
|
|
code="test_code",
|
|
redirect_uri="http://localhost:60108/callback",
|
|
client_id="test_client_id",
|
|
mcp_server_name="google_mcp",
|
|
client_secret="test_secret",
|
|
)
|
|
|
|
# Verify that the redirect_uri sent to the provider uses the external URL
|
|
call_args = mock_async_client.post.call_args
|
|
assert call_args[1]["data"]["redirect_uri"] == "https://proxy.example.com/github/mcp/callback"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"base_url,x_forwarded_proto,x_forwarded_host,x_forwarded_port,expected_url",
|
|
[
|
|
# Case 1: No forwarded headers - use original URL as-is (no trailing slash)
|
|
(
|
|
"http://localhost:4000/",
|
|
None,
|
|
None,
|
|
None,
|
|
"http://localhost:4000",
|
|
),
|
|
# Case 2: Only X-Forwarded-Proto - change scheme only
|
|
(
|
|
"http://localhost:4000/",
|
|
"https",
|
|
None,
|
|
None,
|
|
"https://localhost:4000",
|
|
),
|
|
# Case 3: X-Forwarded-Proto + X-Forwarded-Host - change scheme and host
|
|
(
|
|
"http://localhost:4000/",
|
|
"https",
|
|
"proxy.example.com",
|
|
None,
|
|
"https://proxy.example.com",
|
|
),
|
|
# Case 4: X-Forwarded-Host with port included in host header
|
|
(
|
|
"http://localhost:4000/",
|
|
"https",
|
|
"proxy.example.com:8080",
|
|
None,
|
|
"https://proxy.example.com:8080",
|
|
),
|
|
# Case 5: X-Forwarded-Host + X-Forwarded-Port as separate headers
|
|
(
|
|
"http://localhost:4000/",
|
|
"https",
|
|
"proxy.example.com",
|
|
"8443",
|
|
"https://proxy.example.com:8443",
|
|
),
|
|
# Case 6: Only X-Forwarded-Host without proto - use original scheme
|
|
(
|
|
"http://localhost:4000/",
|
|
None,
|
|
"proxy.example.com",
|
|
None,
|
|
"http://proxy.example.com",
|
|
),
|
|
# Case 7: Only X-Forwarded-Port without host - preserves original port if present
|
|
# (This is safer behavior - X-Forwarded-Port alone is unusual)
|
|
(
|
|
"http://localhost:4000/",
|
|
None,
|
|
None,
|
|
"8443",
|
|
"http://localhost:4000", # Original port preserved when already present
|
|
),
|
|
# Case 8: Complex internal URL with path (path is preserved)
|
|
(
|
|
"http://localhost:8888/github/mcp",
|
|
"https",
|
|
"proxy.example.com",
|
|
None,
|
|
"https://proxy.example.com/github/mcp",
|
|
),
|
|
# Case 9: IPv6 address in X-Forwarded-Host (should not treat :: as port separator)
|
|
(
|
|
"http://localhost:4000/",
|
|
"https",
|
|
"[2001:db8::1]",
|
|
None,
|
|
"https://[2001:db8::1]",
|
|
),
|
|
# Case 10: IPv6 address with port
|
|
(
|
|
"http://localhost:4000/",
|
|
"https",
|
|
"[2001:db8::1]:8080",
|
|
None,
|
|
"https://[2001:db8::1]:8080",
|
|
),
|
|
# Case 11: X-Forwarded-Host already has port, X-Forwarded-Port also provided (host wins)
|
|
(
|
|
"http://localhost:4000/",
|
|
"https",
|
|
"proxy.example.com:9000",
|
|
"8443",
|
|
"https://proxy.example.com:9000",
|
|
),
|
|
# Case 12: Standard proxy setup (most common case)
|
|
(
|
|
"http://127.0.0.1:8888/",
|
|
"https",
|
|
"chatproxy.company.com",
|
|
None,
|
|
"https://chatproxy.company.com",
|
|
),
|
|
# Case 13: Internal URL already has port, X-Forwarded-Port does NOT override
|
|
# (safer behavior - preserves original port when X-Forwarded-Host not provided)
|
|
(
|
|
"http://localhost:4000/",
|
|
None,
|
|
None,
|
|
"443",
|
|
"http://localhost:4000", # Original port preserved
|
|
),
|
|
# Case 14: Original URL with existing port in netloc, X-Forwarded-Host replaces it
|
|
(
|
|
"http://internal.local:8888/",
|
|
"https",
|
|
"external.com",
|
|
None,
|
|
"https://external.com",
|
|
),
|
|
(
|
|
"http://localhost:4000/",
|
|
"https",
|
|
"proxy.example.com",
|
|
"443",
|
|
"https://proxy.example.com",
|
|
),
|
|
(
|
|
"http://localhost:4000/",
|
|
"http",
|
|
"proxy.example.com",
|
|
"80",
|
|
"http://proxy.example.com",
|
|
),
|
|
(
|
|
"http://internal.local/",
|
|
"https",
|
|
None,
|
|
"443",
|
|
"https://internal.local",
|
|
),
|
|
(
|
|
"http://localhost:4000/",
|
|
"https",
|
|
"proxy.example.com:443",
|
|
None,
|
|
"https://proxy.example.com",
|
|
),
|
|
],
|
|
)
|
|
def test_get_request_base_url_comprehensive(
|
|
base_url, x_forwarded_proto, x_forwarded_host, x_forwarded_port, expected_url
|
|
):
|
|
"""Comprehensive test for get_request_base_url with various header combinations.
|
|
|
|
These cases exercise the X-Forwarded-* parsing logic, so the trust gate
|
|
is patched True; the gate's own behaviour is covered by the
|
|
``test_get_request_base_url_xff_trust_gate`` matrix below.
|
|
"""
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
get_request_base_url,
|
|
)
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = base_url
|
|
|
|
headers = {}
|
|
if x_forwarded_proto:
|
|
headers["X-Forwarded-Proto"] = x_forwarded_proto
|
|
if x_forwarded_host:
|
|
headers["X-Forwarded-Host"] = x_forwarded_host
|
|
if x_forwarded_port:
|
|
headers["X-Forwarded-Port"] = x_forwarded_port
|
|
|
|
def mock_get(header_name, default=None):
|
|
return headers.get(header_name, default)
|
|
|
|
mock_request.headers.get = mock_get
|
|
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.IPAddressUtils.is_request_from_trusted_proxy",
|
|
return_value=True,
|
|
):
|
|
result = get_request_base_url(mock_request)
|
|
|
|
assert result == expected_url, (
|
|
f"Expected '{expected_url}' but got '{result}'\n"
|
|
f"Input: base_url={base_url}, "
|
|
f"X-Forwarded-Proto={x_forwarded_proto}, "
|
|
f"X-Forwarded-Host={x_forwarded_host}, "
|
|
f"X-Forwarded-Port={x_forwarded_port}"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"general_settings,direct_ip,expect_xff_honoured",
|
|
[
|
|
# Default: use_x_forwarded_for not set -> ignore X-Forwarded-* entirely.
|
|
({}, "127.0.0.1", False),
|
|
# XFF enabled, no trusted ranges -> still ignored (no way to tell a trusted
|
|
# reverse proxy from a direct attacker).
|
|
({"use_x_forwarded_for": True}, "127.0.0.1", False),
|
|
# XFF enabled, ranges set, but caller IP outside any range -> ignored.
|
|
(
|
|
{
|
|
"use_x_forwarded_for": True,
|
|
"mcp_trusted_proxy_ranges": ["10.0.0.0/8"],
|
|
},
|
|
"203.0.113.5",
|
|
False,
|
|
),
|
|
# XFF enabled, caller in trusted range -> headers honoured.
|
|
(
|
|
{
|
|
"use_x_forwarded_for": True,
|
|
"mcp_trusted_proxy_ranges": ["10.0.0.0/8"],
|
|
},
|
|
"10.0.0.7",
|
|
True,
|
|
),
|
|
# Loopback example (common dev / single-host deploy).
|
|
(
|
|
{
|
|
"use_x_forwarded_for": True,
|
|
"mcp_trusted_proxy_ranges": ["127.0.0.0/8"],
|
|
},
|
|
"127.0.0.1",
|
|
True,
|
|
),
|
|
],
|
|
)
|
|
def test_get_request_base_url_xff_trust_gate(general_settings, direct_ip, expect_xff_honoured):
|
|
"""Verify the X-Forwarded-* trust gate.
|
|
|
|
With XFF poisoning attempted, the helper must return either the literal
|
|
base_url (gate denies) or the forwarded URL (gate allows), never the
|
|
forwarded URL when the gate denies.
|
|
"""
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
get_request_base_url,
|
|
)
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "http://localhost:4000/"
|
|
mock_request.client = MagicMock()
|
|
mock_request.client.host = direct_ip
|
|
|
|
headers = {
|
|
"X-Forwarded-Proto": "https",
|
|
"X-Forwarded-Host": "attacker.example.com",
|
|
}
|
|
mock_request.headers.get = lambda name, default=None: headers.get(name, default)
|
|
mock_request.headers.__contains__ = lambda self_, name: name in headers
|
|
|
|
with patch(
|
|
"litellm.proxy.proxy_server.general_settings",
|
|
general_settings,
|
|
create=True,
|
|
):
|
|
result = get_request_base_url(mock_request)
|
|
|
|
if expect_xff_honoured:
|
|
assert result == "https://attacker.example.com"
|
|
else:
|
|
assert result == "http://localhost:4000"
|
|
|
|
|
|
def test_xff_misconfig_warning_emitted_once(caplog):
|
|
"""Operators upgrading from the old "always trust X-Forwarded-*" behaviour
|
|
get a one-shot warning when they have ``use_x_forwarded_for`` enabled
|
|
but no ``mcp_trusted_proxy_ranges`` configured. The warning must NOT
|
|
spam every request."""
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy import auth as proxy_auth_pkg # noqa: F401
|
|
from litellm.proxy.auth import ip_address_utils
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
get_request_base_url,
|
|
)
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
# Reset the module-level one-shot flag so the test is deterministic.
|
|
ip_address_utils._warned_xff_without_trusted_ranges = False
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "http://localhost:4000/"
|
|
mock_request.client = MagicMock()
|
|
mock_request.client.host = "203.0.113.5"
|
|
headers = {"X-Forwarded-Host": "attacker.example.com"}
|
|
mock_request.headers.get = lambda name, default=None: headers.get(name, default)
|
|
|
|
misconfig = {"use_x_forwarded_for": True}
|
|
|
|
import logging
|
|
|
|
with (
|
|
caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"),
|
|
patch("litellm.proxy.proxy_server.general_settings", misconfig, create=True),
|
|
):
|
|
for _ in range(3):
|
|
get_request_base_url(mock_request)
|
|
|
|
matching = [rec for rec in caplog.records if "mcp_trusted_proxy_ranges" in rec.getMessage()]
|
|
assert len(matching) == 1, (
|
|
f"expected exactly one warning, got {len(matching)}: {[r.getMessage() for r in matching]}"
|
|
)
|
|
|
|
|
|
def test_get_request_base_url_honors_proxy_base_url_env(monkeypatch):
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server.oauth_utils import (
|
|
get_request_base_url,
|
|
)
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "http://litellm-internal:4000/"
|
|
mock_request.client = MagicMock()
|
|
mock_request.client.host = "10.0.0.7"
|
|
headers = {
|
|
"X-Forwarded-Proto": "https",
|
|
"X-Forwarded-Host": "litellm-internal:4000",
|
|
"X-Forwarded-Port": "9999",
|
|
}
|
|
mock_request.headers.get = lambda name, default=None: headers.get(name, default)
|
|
|
|
monkeypatch.setenv("PROXY_BASE_URL", "https://litellm.example.com")
|
|
assert get_request_base_url(mock_request) == "https://litellm.example.com"
|
|
|
|
monkeypatch.setenv("PROXY_BASE_URL", "https://litellm.example.com/")
|
|
assert get_request_base_url(mock_request) == "https://litellm.example.com"
|
|
|
|
|
|
def test_validate_trusted_redirect_uri_logs_diagnostic_on_rejection(caplog, monkeypatch):
|
|
try:
|
|
from fastapi import HTTPException, Request
|
|
|
|
from litellm.proxy.mcp_server.oauth_utils import (
|
|
validate_trusted_redirect_uri,
|
|
)
|
|
except ImportError:
|
|
pytest.skip("MCP oauth_utils not available")
|
|
|
|
monkeypatch.delenv("PROXY_BASE_URL", raising=False)
|
|
monkeypatch.delenv("MCP_TRUSTED_REDIRECT_ORIGINS", raising=False)
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "http://litellm-internal:4000/"
|
|
mock_request.client = MagicMock()
|
|
mock_request.client.host = "203.0.113.5"
|
|
headers = {
|
|
"X-Forwarded-Proto": "https",
|
|
"X-Forwarded-Host": "litellm.example.com",
|
|
"X-Forwarded-Port": "443",
|
|
"Host": "litellm-internal:4000",
|
|
}
|
|
mock_request.headers.get = lambda name, default=None: headers.get(name, default)
|
|
|
|
import logging
|
|
|
|
with (
|
|
caplog.at_level(logging.WARNING, logger="LiteLLM"),
|
|
patch("litellm.proxy.proxy_server.general_settings", {}, create=True),
|
|
):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
validate_trusted_redirect_uri(
|
|
mock_request,
|
|
"https://litellm.example.com/ui/mcp/oauth/callback",
|
|
)
|
|
assert exc_info.value.status_code == 400
|
|
detail = exc_info.value.detail
|
|
assert isinstance(detail, dict)
|
|
assert detail.get("error") == "invalid_request"
|
|
assert "error_description" in detail
|
|
assert "redirect_uri origin" in detail["error_description"]
|
|
assert "proxy origin" in detail["error_description"]
|
|
assert "hint" in detail
|
|
|
|
matching = [r for r in caplog.records if "rejecting redirect_uri" in r.getMessage()]
|
|
assert len(matching) == 1, (
|
|
f"expected exactly one diagnostic warning, got {[r.getMessage() for r in caplog.records]}"
|
|
)
|
|
msg = matching[0].getMessage()
|
|
assert "https://litellm.example.com/ui/mcp/oauth/callback" in msg
|
|
assert "litellm-internal:4000" in msg
|
|
assert "X-Forwarded-Host" in msg
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"direct_ip,expect_accepted",
|
|
[
|
|
("10.0.0.7", True),
|
|
("203.0.113.5", False),
|
|
],
|
|
)
|
|
def test_validate_trusted_redirect_uri_follows_the_xff_trust_gate(direct_ip, expect_accepted, monkeypatch):
|
|
try:
|
|
from fastapi import HTTPException, Request
|
|
|
|
from litellm.proxy.mcp_server.oauth_utils import (
|
|
validate_trusted_redirect_uri,
|
|
)
|
|
except ImportError:
|
|
pytest.skip("MCP oauth_utils not available")
|
|
|
|
monkeypatch.delenv("PROXY_BASE_URL", raising=False)
|
|
monkeypatch.delenv("MCP_TRUSTED_REDIRECT_ORIGINS", raising=False)
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "http://localhost:4000/"
|
|
mock_request.client = MagicMock()
|
|
mock_request.client.host = direct_ip
|
|
|
|
headers = {
|
|
"X-Forwarded-Proto": "https",
|
|
"X-Forwarded-Host": "proxy.example.com",
|
|
}
|
|
mock_request.headers.get = lambda name, default=None: headers.get(name, default)
|
|
mock_request.headers.__contains__ = lambda self_, name: name in headers
|
|
|
|
redirect_uri = "https://proxy.example.com/callback"
|
|
general_settings = {
|
|
"use_x_forwarded_for": True,
|
|
"mcp_trusted_proxy_ranges": ["10.0.0.0/8"],
|
|
}
|
|
|
|
with patch("litellm.proxy.proxy_server.general_settings", general_settings, create=True):
|
|
if expect_accepted:
|
|
validate_trusted_redirect_uri(mock_request, redirect_uri)
|
|
return
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
validate_trusted_redirect_uri(mock_request, redirect_uri)
|
|
|
|
assert exc_info.value.status_code == 400
|
|
assert "proxy.example.com" in str(exc_info.value.detail)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"bad_value",
|
|
[
|
|
"litellm.example.com",
|
|
"litellm.example.com/",
|
|
"://litellm.example.com",
|
|
"ftp://litellm.example.com",
|
|
"https://",
|
|
"not a url at all",
|
|
],
|
|
)
|
|
def test_get_request_base_url_rejects_malformed_proxy_base_url(bad_value, monkeypatch, caplog):
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server import oauth_utils
|
|
from litellm.proxy.mcp_server.oauth_utils import (
|
|
get_request_base_url,
|
|
)
|
|
except ImportError:
|
|
pytest.skip("MCP oauth_utils not available")
|
|
|
|
oauth_utils._warned_invalid_proxy_base_url = None
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "http://litellm-internal:4000/"
|
|
mock_request.client = MagicMock()
|
|
mock_request.client.host = "127.0.0.1"
|
|
mock_request.headers.get = lambda name, default=None: default
|
|
|
|
monkeypatch.setenv("PROXY_BASE_URL", bad_value)
|
|
|
|
import logging
|
|
|
|
with (
|
|
caplog.at_level(logging.WARNING, logger="LiteLLM"),
|
|
patch("litellm.proxy.proxy_server.general_settings", {}, create=True),
|
|
):
|
|
result = get_request_base_url(mock_request)
|
|
|
|
assert result == "http://litellm-internal:4000", (
|
|
f"malformed PROXY_BASE_URL={bad_value!r} should be ignored, got {result!r}"
|
|
)
|
|
matching = [r for r in caplog.records if "PROXY_BASE_URL" in r.getMessage() and "ignored" in r.getMessage()]
|
|
assert len(matching) == 1, (
|
|
f"expected one diagnostic for malformed PROXY_BASE_URL, got {[r.getMessage() for r in caplog.records]}"
|
|
)
|
|
assert repr(bad_value) in matching[0].getMessage() or bad_value in matching[0].getMessage()
|
|
|
|
|
|
def test_get_request_base_url_malformed_proxy_base_url_warning_is_one_shot(monkeypatch, caplog):
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server import oauth_utils
|
|
from litellm.proxy.mcp_server.oauth_utils import (
|
|
get_request_base_url,
|
|
)
|
|
except ImportError:
|
|
pytest.skip("MCP oauth_utils not available")
|
|
|
|
oauth_utils._warned_invalid_proxy_base_url = None
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "http://litellm-internal:4000/"
|
|
mock_request.client = MagicMock()
|
|
mock_request.client.host = "127.0.0.1"
|
|
mock_request.headers.get = lambda name, default=None: default
|
|
|
|
monkeypatch.setenv("PROXY_BASE_URL", "litellm.example.com")
|
|
|
|
import logging
|
|
|
|
with (
|
|
caplog.at_level(logging.WARNING, logger="LiteLLM"),
|
|
patch("litellm.proxy.proxy_server.general_settings", {}, create=True),
|
|
):
|
|
for _ in range(5):
|
|
get_request_base_url(mock_request)
|
|
|
|
matching = [r for r in caplog.records if "PROXY_BASE_URL" in r.getMessage() and "ignored" in r.getMessage()]
|
|
assert len(matching) == 1, f"expected exactly one warning across 5 calls, got {len(matching)}"
|
|
|
|
|
|
# -------------------------------------------------------------------
|
|
# Tests for scopes_supported when mcp_server.scopes is None
|
|
# -------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_oauth_protected_resource_returns_empty_scopes_when_none():
|
|
"""
|
|
When an MCP server exists but has scopes=None (e.g. Atlassian OAuth),
|
|
scopes_supported should be [] not None.
|
|
"""
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
_build_oauth_protected_resource_response,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
# Create an OAuth2 server with scopes=None (like Atlassian)
|
|
oauth2_server = MCPServer(
|
|
server_id="atlassian_mcp",
|
|
name="atlassian_mcp",
|
|
server_name="atlassian_mcp",
|
|
alias="atlassian_mcp",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id="atlassian_client_id",
|
|
client_secret="atlassian_secret",
|
|
authorization_url="https://auth.atlassian.com/authorize",
|
|
token_url="https://auth.atlassian.com/oauth/token",
|
|
scopes=None, # Atlassian doesn't set scopes
|
|
)
|
|
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://litellm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
try:
|
|
response = await _build_oauth_protected_resource_response(
|
|
request=mock_request,
|
|
mcp_server_name="atlassian_mcp",
|
|
use_standard_pattern=False,
|
|
)
|
|
assert response["scopes_supported"] == []
|
|
finally:
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_oauth_protected_resource_gateway_managed_oauth2_advertises_gateway_as():
|
|
"""LIT-4864: an explicitly named gateway-managed oauth2 server (interactive or M2M)
|
|
advertises the gateway's own authorization server, so a keyless DCR client that
|
|
configured the per-server URL completes the same sign-in flow the aggregate /mcp
|
|
endpoint supports and returns with a gateway session bearer; the resource stays the
|
|
per-server URL in the requested spelling (RFC 9728 resource match). A delegate-auth
|
|
oauth2 server keeps the per-server relay authorization server (its keyless flow is
|
|
upstream PKCE via the relay), and the root-resolved unnamed legacy shape is unchanged."""
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
_build_oauth_protected_resource_response,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
def _oauth2_server(name, **kw):
|
|
return MCPServer(
|
|
server_id=name,
|
|
name=name,
|
|
server_name=name,
|
|
alias=name,
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
authorization_url="https://idp.example.com/authorize",
|
|
token_url="https://idp.example.com/oauth/token",
|
|
scopes=["read"],
|
|
**kw,
|
|
)
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://litellm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
interactive = _oauth2_server("github_mcp")
|
|
relay = _oauth2_server("relay_mcp", per_server_oauth_discovery=True)
|
|
m2m = _oauth2_server("m2m_mcp", oauth2_flow="client_credentials", client_id="cid", client_secret="cs")
|
|
delegated = _oauth2_server("delegated_mcp", delegate_auth_to_upstream=True)
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
try:
|
|
for server in (interactive, relay, m2m, delegated):
|
|
global_mcp_server_manager.registry[server.server_id] = server
|
|
|
|
for name in ("github_mcp", "m2m_mcp"):
|
|
standard = await _build_oauth_protected_resource_response(
|
|
request=mock_request, mcp_server_name=name, use_standard_pattern=True
|
|
)
|
|
assert standard["authorization_servers"] == ["https://litellm.example.com/mcp"], name
|
|
assert standard["resource"] == f"https://litellm.example.com/mcp/{name}"
|
|
assert standard["scopes_supported"] == ["read"]
|
|
legacy = await _build_oauth_protected_resource_response(
|
|
request=mock_request, mcp_server_name=name, use_standard_pattern=False
|
|
)
|
|
assert legacy["authorization_servers"] == ["https://litellm.example.com/mcp"], name
|
|
assert legacy["resource"] == f"https://litellm.example.com/{name}/mcp"
|
|
|
|
relay_response = await _build_oauth_protected_resource_response(
|
|
request=mock_request, mcp_server_name="relay_mcp", use_standard_pattern=True
|
|
)
|
|
assert relay_response["authorization_servers"] == ["https://litellm.example.com/relay_mcp"]
|
|
relay_legacy_response = await _build_oauth_protected_resource_response(
|
|
request=mock_request, mcp_server_name="relay_mcp", use_standard_pattern=False
|
|
)
|
|
assert relay_legacy_response["authorization_servers"] == ["https://litellm.example.com/relay_mcp"]
|
|
|
|
delegated_response = await _build_oauth_protected_resource_response(
|
|
request=mock_request, mcp_server_name="delegated_mcp", use_standard_pattern=True
|
|
)
|
|
assert delegated_response["authorization_servers"] == ["https://litellm.example.com/delegated_mcp"]
|
|
finally:
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
|
|
@pytest.mark.parametrize("server_count", [0, 1, 2])
|
|
@pytest.mark.parametrize("byok_first", [True, False])
|
|
@pytest.mark.parametrize(
|
|
("base_url", "origin"),
|
|
[
|
|
("https://gateway.example.com", "https://gateway.example.com"),
|
|
("https://gateway.example.com/proxy", "https://gateway.example.com"),
|
|
("http://[::1]:4000/proxy", "http://[::1]:4000"),
|
|
],
|
|
)
|
|
def test_root_protected_resource_discovers_gateway(monkeypatch, server_count, byok_first, base_url, origin):
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server import byok_oauth_endpoints, discoverable_endpoints
|
|
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
monkeypatch.setenv("PROXY_BASE_URL", base_url)
|
|
monkeypatch.setattr(
|
|
global_mcp_server_manager,
|
|
"registry",
|
|
{
|
|
f"oauth_{index}": MCPServer(
|
|
server_id=f"oauth_{index}",
|
|
name=f"oauth_{index}",
|
|
server_name=f"oauth_{index}",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
authorization_url="https://idp.example.com/authorize",
|
|
token_url="https://idp.example.com/token",
|
|
)
|
|
for index in range(server_count)
|
|
},
|
|
)
|
|
app = FastAPI()
|
|
routers = (byok_oauth_endpoints.router, discoverable_endpoints.router)
|
|
for router in routers if byok_first else reversed(routers):
|
|
app.include_router(router)
|
|
with TestClient(app) as client:
|
|
response = client.get("/.well-known/oauth-protected-resource", params={"mcp_server_name": "oauth_0"})
|
|
assert response.status_code == 200
|
|
assert response.json() == {
|
|
"resource": origin,
|
|
"authorization_servers": [f"{base_url}/mcp"],
|
|
"scopes_supported": [],
|
|
}
|
|
authorization = client.get("/.well-known/oauth-authorization-server/mcp")
|
|
assert authorization.status_code == 200
|
|
metadata = authorization.json()
|
|
assert metadata["issuer"] == response.json()["authorization_servers"][0]
|
|
assert metadata["authorization_endpoint"] == f"{base_url}/authorize/mcp-session"
|
|
assert metadata["token_endpoint"] == f"{base_url}/token"
|
|
assert metadata["registration_endpoint"] == f"{base_url}/register"
|
|
aggregate = client.get("/.well-known/oauth-protected-resource/mcp")
|
|
assert aggregate.status_code == 200
|
|
assert aggregate.json()["resource"] == f"{base_url}/mcp"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unnamed_protected_resource_builder_uses_gateway_origin(monkeypatch):
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
_build_oauth_protected_resource_response,
|
|
)
|
|
|
|
monkeypatch.delenv("PROXY_BASE_URL", raising=False)
|
|
request = Request(
|
|
{"type": "http", "scheme": "https", "server": ("gateway.example.com", 443), "path": "/", "headers": []}
|
|
)
|
|
response = await _build_oauth_protected_resource_response(request, None, False)
|
|
assert response == {
|
|
"resource": "https://gateway.example.com",
|
|
"authorization_servers": ("https://gateway.example.com/mcp",),
|
|
"scopes_supported": (),
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_oauth_authorization_server_returns_empty_scopes_when_none():
|
|
"""
|
|
When an MCP server exists but has scopes=None (e.g. Atlassian OAuth),
|
|
scopes_supported should be [] not None.
|
|
"""
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
_build_oauth_authorization_server_response,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
# Create an OAuth2 server with scopes=None
|
|
oauth2_server = MCPServer(
|
|
server_id="atlassian_mcp",
|
|
name="atlassian_mcp",
|
|
server_name="atlassian_mcp",
|
|
alias="atlassian_mcp",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id="atlassian_client_id",
|
|
client_secret="atlassian_secret",
|
|
authorization_url="https://auth.atlassian.com/authorize",
|
|
token_url="https://auth.atlassian.com/oauth/token",
|
|
scopes=None,
|
|
)
|
|
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://litellm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
try:
|
|
response = _build_oauth_authorization_server_response(
|
|
request=mock_request,
|
|
mcp_server_name="atlassian_mcp",
|
|
)
|
|
assert response["scopes_supported"] == []
|
|
finally:
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
|
|
# -------------------------------------------------------------------
|
|
# Tests for root-level OAuth endpoint resolution (no server name)
|
|
# -------------------------------------------------------------------
|
|
|
|
|
|
def _create_oauth2_server(
|
|
server_id="test_oauth_server",
|
|
name="test_oauth",
|
|
server_name="test_oauth",
|
|
alias="test_oauth",
|
|
client_id="test_client_id",
|
|
client_secret="test_client_secret",
|
|
available_on_public_internet=True,
|
|
delegate_auth_to_upstream: bool = False,
|
|
):
|
|
"""Helper to create a mock OAuth2 MCPServer."""
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
return MCPServer(
|
|
server_id=server_id,
|
|
name=name,
|
|
server_name=server_name,
|
|
alias=alias,
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id=client_id,
|
|
client_secret=client_secret,
|
|
authorization_url="https://provider.com/oauth/authorize",
|
|
token_url="https://provider.com/oauth/token",
|
|
scopes=["read", "write"],
|
|
available_on_public_internet=available_on_public_internet,
|
|
delegate_auth_to_upstream=delegate_auth_to_upstream,
|
|
)
|
|
|
|
|
|
def _create_id_lookup_oauth2_server():
|
|
return _create_oauth2_server(
|
|
server_id="oauth-server-id",
|
|
name="oauth-server-name",
|
|
server_name="oauth-server-name",
|
|
alias="oauth-server-alias",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authorize_resolves_server_by_id_when_name_lookup_fails():
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server import discoverable_endpoints
|
|
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
|
|
|
|
server = _create_id_lookup_oauth2_server()
|
|
request = MagicMock(spec=Request)
|
|
request.base_url = "https://llm.example.com/"
|
|
request.headers = {}
|
|
|
|
with (
|
|
patch.object(
|
|
global_mcp_server_manager, "get_mcp_server_by_name", return_value=None
|
|
) as by_name, # test-quality-ok: resolver seam
|
|
patch.object(
|
|
global_mcp_server_manager, "get_mcp_server_by_id", return_value=server
|
|
) as by_id, # test-quality-ok: resolver seam
|
|
patch.object(
|
|
discoverable_endpoints, "encrypt_value_helper", return_value="encrypted-state"
|
|
), # test-quality-ok: flow seam
|
|
):
|
|
response = await discoverable_endpoints.authorize(
|
|
request=request,
|
|
client_id=server.client_id,
|
|
mcp_server_name=server.server_id,
|
|
redirect_uri="http://localhost:62646/callback",
|
|
state="test_state",
|
|
)
|
|
|
|
assert response.status_code == 307
|
|
assert "https://provider.com/oauth/authorize" in response.headers["location"]
|
|
by_name.assert_called_once_with(server.server_id, client_ip=None)
|
|
by_id.assert_called_once_with(server.server_id, client_ip=None)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_endpoint_resolves_server_by_id_when_name_lookup_fails():
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server import discoverable_endpoints
|
|
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
|
|
|
|
server = _create_id_lookup_oauth2_server()
|
|
request = MagicMock(spec=Request)
|
|
request.base_url = "https://llm.example.com/"
|
|
request.headers = {}
|
|
response = MagicMock()
|
|
response.json.return_value = {"access_token": "token", "token_type": "Bearer"}
|
|
response.raise_for_status = MagicMock()
|
|
client = MagicMock()
|
|
client.post = AsyncMock(return_value=response)
|
|
|
|
with (
|
|
patch.object(
|
|
global_mcp_server_manager, "get_mcp_server_by_name", return_value=None
|
|
) as by_name, # test-quality-ok: resolver seam
|
|
patch.object(
|
|
global_mcp_server_manager, "get_mcp_server_by_id", return_value=server
|
|
) as by_id, # test-quality-ok: resolver seam
|
|
patch.object(
|
|
discoverable_endpoints, "get_async_httpx_client", return_value=client
|
|
), # test-quality-ok: HTTP seam
|
|
):
|
|
result = await discoverable_endpoints.token_endpoint(
|
|
request=request,
|
|
grant_type="authorization_code",
|
|
code="test_code",
|
|
redirect_uri="http://localhost:62646/callback",
|
|
client_id=server.client_id,
|
|
mcp_server_name=server.server_id,
|
|
client_secret=server.client_secret,
|
|
)
|
|
|
|
assert json.loads(result.body)["access_token"] == "token"
|
|
by_name.assert_called_once_with(server.server_id, client_ip=None)
|
|
by_id.assert_called_once_with(server.server_id, client_ip=None)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_client_resolves_server_by_id_when_name_lookup_fails():
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server import discoverable_endpoints
|
|
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
|
|
|
|
server = _create_id_lookup_oauth2_server().model_copy(
|
|
update={"client_id": None, "client_secret": None, "registration_url": "https://provider.com/oauth/register"}
|
|
)
|
|
request = MagicMock(spec=Request)
|
|
request.base_url = "https://llm.example.com/"
|
|
request.headers = {}
|
|
response = MagicMock()
|
|
response.json.return_value = {"client_id": "registered-client", "client_secret": "registered-secret"}
|
|
response.raise_for_status = MagicMock()
|
|
client = MagicMock()
|
|
client.post = AsyncMock(return_value=response)
|
|
|
|
with (
|
|
patch.object(
|
|
global_mcp_server_manager, "get_mcp_server_by_name", return_value=None
|
|
) as by_name, # test-quality-ok: resolver seam
|
|
patch.object(
|
|
global_mcp_server_manager, "get_mcp_server_by_id", return_value=server
|
|
) as by_id, # test-quality-ok: resolver seam
|
|
patch.object(
|
|
discoverable_endpoints, "read_request_body", new=AsyncMock(return_value={})
|
|
), # test-quality-ok: request seam
|
|
patch.object(
|
|
discoverable_endpoints, "get_async_httpx_client", return_value=client
|
|
), # test-quality-ok: HTTP seam
|
|
):
|
|
result = await discoverable_endpoints.register_client(request=request, mcp_server_name=server.server_id)
|
|
|
|
assert json.loads(result.body)["client_id"] == "registered-client"
|
|
by_name.assert_called_once_with(server.server_id, client_ip=None)
|
|
by_id.assert_called_once_with(server.server_id, client_ip=None)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_protected_resource_metadata_resolves_server_by_id_when_name_lookup_fails():
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server import discoverable_endpoints
|
|
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
|
|
|
|
server = _create_id_lookup_oauth2_server()
|
|
request = MagicMock(spec=Request)
|
|
request.base_url = "https://llm.example.com/"
|
|
request.headers = {}
|
|
|
|
with (
|
|
patch.object(
|
|
global_mcp_server_manager, "get_mcp_server_by_name", return_value=None
|
|
) as by_name, # test-quality-ok: resolver seam
|
|
patch.object(
|
|
global_mcp_server_manager, "get_mcp_server_by_id", return_value=server
|
|
) as by_id, # test-quality-ok: resolver seam
|
|
):
|
|
result = await discoverable_endpoints._build_oauth_protected_resource_response(
|
|
request=request,
|
|
mcp_server_name=server.server_id,
|
|
use_standard_pattern=True,
|
|
)
|
|
|
|
assert result["authorization_servers"] == ["https://llm.example.com/mcp"]
|
|
assert result["resource"] == f"https://llm.example.com/mcp/{server.server_id}"
|
|
by_name.assert_called_once_with(server.server_id, client_ip=None)
|
|
by_id.assert_called_once_with(server.server_id, client_ip=None)
|
|
|
|
|
|
def test_authorization_server_metadata_resolves_server_by_id_when_name_lookup_fails():
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server import discoverable_endpoints
|
|
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
|
|
|
|
server = _create_id_lookup_oauth2_server()
|
|
request = MagicMock(spec=Request)
|
|
request.base_url = "https://llm.example.com/"
|
|
request.headers = {}
|
|
|
|
with (
|
|
patch.object(
|
|
global_mcp_server_manager, "get_mcp_server_by_name", return_value=None
|
|
) as by_name, # test-quality-ok: resolver seam
|
|
patch.object(
|
|
global_mcp_server_manager, "get_mcp_server_by_id", return_value=server
|
|
) as by_id, # test-quality-ok: resolver seam
|
|
):
|
|
result = discoverable_endpoints._build_oauth_authorization_server_response(
|
|
request=request,
|
|
mcp_server_name=server.server_id,
|
|
)
|
|
|
|
assert result["scopes_supported"] == server.scopes
|
|
assert result["issuer"] == f"https://llm.example.com/{server.server_id}"
|
|
by_name.assert_called_once_with(server.server_id, client_ip=None)
|
|
by_id.assert_called_once_with(server.server_id, client_ip=None)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authorize_root_resolves_single_oauth2_server():
|
|
"""When /authorize is hit without server name and exactly 1 OAuth2 server exists, resolve it."""
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
authorize,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
oauth2_server = _create_oauth2_server()
|
|
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://llm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
try:
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.encrypt_value_helper"
|
|
) as mock_encrypt:
|
|
mock_encrypt.return_value = "mocked_encrypted_state"
|
|
|
|
# Call /authorize WITHOUT mcp_server_name, with dummy_client as client_id
|
|
response = await authorize(
|
|
request=mock_request,
|
|
client_id="dummy_client",
|
|
mcp_server_name=None,
|
|
redirect_uri="http://localhost:62646/callback",
|
|
state="test_state",
|
|
)
|
|
|
|
# Should resolve to the single OAuth2 server and redirect
|
|
assert response.status_code == 307
|
|
location = response.headers["location"]
|
|
assert "https://provider.com/oauth/authorize" in location
|
|
assert "client_id=test_client_id" in location
|
|
finally:
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authorize_root_fails_with_multiple_oauth2_servers():
|
|
"""When /authorize is hit without server name and multiple OAuth2 servers exist, return 404."""
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
authorize,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
server1 = _create_oauth2_server(server_id="server1", name="server1", server_name="server1", alias="server1")
|
|
server2 = _create_oauth2_server(server_id="server2", name="server2", server_name="server2", alias="server2")
|
|
global_mcp_server_manager.registry[server1.server_id] = server1
|
|
global_mcp_server_manager.registry[server2.server_id] = server2
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://llm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
try:
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await authorize(
|
|
request=mock_request,
|
|
client_id="dummy_client",
|
|
mcp_server_name=None,
|
|
redirect_uri="http://localhost:62646/callback",
|
|
state="test_state",
|
|
)
|
|
assert exc_info.value.status_code == 404
|
|
assert "MCP server not found" in str(exc_info.value.detail)
|
|
finally:
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authorize_root_does_not_resolve_private_server_for_external_client():
|
|
"""Root /authorize must not auto-select an MCP server hidden from the caller IP."""
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
authorize,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
oauth2_server = _create_oauth2_server(available_on_public_internet=False)
|
|
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://llm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
try:
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.IPAddressUtils.get_mcp_client_ip",
|
|
return_value="198.51.100.10",
|
|
):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await authorize(
|
|
request=mock_request,
|
|
client_id="dummy_client",
|
|
mcp_server_name=None,
|
|
redirect_uri="http://localhost:62646/callback",
|
|
state="test_state",
|
|
)
|
|
assert exc_info.value.status_code == 404
|
|
finally:
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_root_resolves_single_oauth2_server():
|
|
"""When /token is hit without server name and exactly 1 OAuth2 server exists, resolve it."""
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
token_endpoint,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
oauth2_server = _create_oauth2_server()
|
|
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://llm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"access_token": "ya29.test_token",
|
|
"token_type": "Bearer",
|
|
"expires_in": 3599,
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
mock_async_client = MagicMock()
|
|
mock_async_client.post = AsyncMock(return_value=mock_response)
|
|
|
|
try:
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client"
|
|
) as mock_get_client:
|
|
mock_get_client.return_value = mock_async_client
|
|
|
|
# Call /token WITHOUT mcp_server_name
|
|
response = await token_endpoint(
|
|
request=mock_request,
|
|
grant_type="authorization_code",
|
|
code="test_auth_code",
|
|
redirect_uri="http://localhost:62646/callback",
|
|
client_id="dummy_client",
|
|
mcp_server_name=None,
|
|
client_secret=None,
|
|
code_verifier="test_verifier",
|
|
)
|
|
|
|
# Should resolve and exchange token with the upstream server
|
|
import json
|
|
|
|
token_data = json.loads(response.body)
|
|
assert token_data["access_token"] == "ya29.test_token"
|
|
|
|
# Verify it called the correct upstream token URL
|
|
call_args = mock_async_client.post.call_args
|
|
assert call_args.args[0] == "https://provider.com/oauth/token"
|
|
finally:
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_root_does_not_resolve_private_server_for_external_client():
|
|
"""Root /token must not exchange codes for a hidden MCP server."""
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
token_endpoint,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
oauth2_server = _create_oauth2_server(available_on_public_internet=False)
|
|
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://llm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
try:
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.IPAddressUtils.get_mcp_client_ip",
|
|
return_value="198.51.100.10",
|
|
):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await token_endpoint(
|
|
request=mock_request,
|
|
grant_type="authorization_code",
|
|
code="test_auth_code",
|
|
redirect_uri="http://localhost:62646/callback",
|
|
client_id="dummy_client",
|
|
mcp_server_name=None,
|
|
client_secret=None,
|
|
code_verifier="test_verifier",
|
|
)
|
|
assert exc_info.value.status_code == 404
|
|
finally:
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_root_does_aggregate_dcr_not_single_server_resolution():
|
|
"""Root /register is the aggregate DCR endpoint: it mints a stateless llm_dcrc_ client
|
|
from the request's redirect_uris and does NOT resolve a single configured oauth2 server
|
|
(a single-server deployment registers at /{server}/register instead)."""
|
|
import json
|
|
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
register_client,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
oauth2_server = _create_oauth2_server()
|
|
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://llm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
try:
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.read_request_body",
|
|
new=AsyncMock(return_value={"redirect_uris": ["https://claude.ai/cb"]}),
|
|
),
|
|
patch("litellm.proxy.proxy_server.master_key", "sk-test-salt-for-lit3637"),
|
|
):
|
|
response = await register_client(request=mock_request, mcp_server_name=None)
|
|
|
|
body = json.loads(response.body)
|
|
assert body["client_id"].startswith("llm_dcrc_")
|
|
assert body["client_id"] != "test_oauth"
|
|
assert body["token_endpoint_auth_method"] == "none"
|
|
finally:
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_root_does_not_leak_a_private_server():
|
|
"""Root /register never resolves or reveals a configured server, so a private one cannot
|
|
leak to an external caller: it always mints the aggregate DCR client instead."""
|
|
import json
|
|
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
register_client,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
oauth2_server = _create_oauth2_server(available_on_public_internet=False)
|
|
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://llm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
try:
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.read_request_body",
|
|
new=AsyncMock(return_value={"redirect_uris": ["https://claude.ai/cb"]}),
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.IPAddressUtils.get_mcp_client_ip",
|
|
return_value="198.51.100.10",
|
|
),
|
|
patch("litellm.proxy.proxy_server.master_key", "sk-test-salt-for-lit3637"),
|
|
):
|
|
response = await register_client(request=mock_request, mcp_server_name=None)
|
|
|
|
body = json.loads(response.body)
|
|
assert body["client_id"].startswith("llm_dcrc_")
|
|
assert "test_oauth" not in body["client_id"]
|
|
finally:
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_discovery_root_includes_server_name_prefix():
|
|
"""When root discovery is hit and exactly 1 OAuth2 server exists, include server name in URLs."""
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
_build_oauth_authorization_server_response,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
oauth2_server = _create_oauth2_server()
|
|
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://llm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
try:
|
|
# Call with mcp_server_name=None (root discovery)
|
|
response = _build_oauth_authorization_server_response(
|
|
request=mock_request,
|
|
mcp_server_name=None,
|
|
)
|
|
|
|
# Should resolve to the single server and include its name in endpoint URLs
|
|
assert "/test_oauth/authorize" in response["authorization_endpoint"]
|
|
assert "/test_oauth/token" in response["token_endpoint"]
|
|
assert "/test_oauth/register" in response["registration_endpoint"]
|
|
assert response["scopes_supported"] == ["read", "write"]
|
|
finally:
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_discovery_root_does_not_expose_private_server_for_external_client():
|
|
"""Root discovery must use caller visibility before adding server-specific metadata."""
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
_build_oauth_authorization_server_response,
|
|
_build_oauth_protected_resource_response,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
oauth2_server = _create_oauth2_server(available_on_public_internet=False)
|
|
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://llm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
try:
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.IPAddressUtils.get_mcp_client_ip",
|
|
return_value="198.51.100.10",
|
|
):
|
|
authorization_response = _build_oauth_authorization_server_response(
|
|
request=mock_request,
|
|
mcp_server_name=None,
|
|
)
|
|
resource_response = await _build_oauth_protected_resource_response(
|
|
request=mock_request,
|
|
mcp_server_name=None,
|
|
use_standard_pattern=False,
|
|
)
|
|
|
|
assert "/test_oauth/" not in authorization_response["authorization_endpoint"]
|
|
assert "/test_oauth/" not in authorization_response["token_endpoint"]
|
|
assert authorization_response["scopes_supported"] == []
|
|
assert tuple(resource_response["authorization_servers"]) == ("https://llm.example.com/mcp",)
|
|
assert resource_response["resource"] == "https://llm.example.com"
|
|
assert not resource_response["scopes_supported"]
|
|
finally:
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_oauth_callback_redirects_with_state():
|
|
"""Test OAuth callback endpoint properly decodes state and redirects to client callback URL."""
|
|
try:
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
callback,
|
|
)
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
# Mock the state decoding
|
|
mock_state_data = {
|
|
"base_url": "http://localhost:3000/ui/mcp/oauth/callback",
|
|
"original_state": "test-uuid-state-123",
|
|
"code_challenge": "test_challenge",
|
|
"code_challenge_method": "S256",
|
|
"client_redirect_uri": "http://localhost:3000/ui/mcp/oauth/callback",
|
|
}
|
|
|
|
with patch("litellm.proxy.mcp_server.discoverable_endpoints.decode_state_hash") as mock_decode:
|
|
mock_decode.return_value = mock_state_data
|
|
|
|
# Call callback endpoint with code and state
|
|
response = await callback(
|
|
request=_mock_callback_request(),
|
|
code="test_authorization_code_12345",
|
|
state="encrypted_state_value",
|
|
)
|
|
|
|
# Should redirect to the client callback URL with code and original state
|
|
assert response.status_code == 302
|
|
assert "http://localhost:3000/ui/mcp/oauth/callback" in response.headers["location"]
|
|
assert "code=test_authorization_code_12345" in response.headers["location"]
|
|
assert "state=test-uuid-state-123" in response.headers["location"]
|
|
|
|
# Verify state was decoded
|
|
mock_decode.assert_called_once_with("encrypted_state_value")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_oauth_callback_preserves_client_redirect_uri_query():
|
|
"""The callback should append code/state without dropping a client's existing query."""
|
|
try:
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
callback,
|
|
)
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
with patch("litellm.proxy.mcp_server.discoverable_endpoints.decode_state_hash") as mock_decode:
|
|
mock_decode.return_value = {
|
|
"base_url": "http://localhost:3000/ui/mcp/oauth/callback",
|
|
"original_state": "test-uuid-state-123",
|
|
"code_challenge": "test_challenge",
|
|
"code_challenge_method": "S256",
|
|
"client_redirect_uri": ("http://localhost:3000/ui/mcp/oauth/callback?session=abc"),
|
|
}
|
|
|
|
response = await callback(
|
|
request=_mock_callback_request(),
|
|
code="test_authorization_code_12345",
|
|
state="encrypted_state_value",
|
|
)
|
|
|
|
assert response.status_code == 302
|
|
parsed_location = urlparse(response.headers["location"])
|
|
query_params = parse_qs(parsed_location.query)
|
|
assert query_params["session"] == ["abc"]
|
|
assert query_params["code"] == ["test_authorization_code_12345"]
|
|
assert query_params["state"] == ["test-uuid-state-123"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_oauth_callback_handles_invalid_state():
|
|
"""Test OAuth callback returns error page when state decryption fails."""
|
|
try:
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
callback,
|
|
)
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
# Mock state decoding to raise an exception
|
|
with patch("litellm.proxy.mcp_server.discoverable_endpoints.decode_state_hash") as mock_decode:
|
|
mock_decode.side_effect = Exception("Failed to decrypt state")
|
|
|
|
# Call callback endpoint with invalid state
|
|
response = await callback(
|
|
request=_mock_callback_request(),
|
|
code="test_code",
|
|
state="invalid_encrypted_state",
|
|
)
|
|
|
|
# Should return HTML error page
|
|
assert response.status_code == 200
|
|
assert "Authentication incomplete" in response.body.decode()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_oauth_callback_accepts_same_origin_ui_redirect():
|
|
"""UI OAuth flow: the callback should redirect to the proxy's own UI
|
|
origin when the encrypted state carries a same-origin client_redirect_uri."""
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
callback,
|
|
)
|
|
|
|
with patch("litellm.proxy.mcp_server.discoverable_endpoints.decode_state_hash") as mock_decode:
|
|
mock_decode.return_value = {
|
|
"base_url": "https://proxy.example.com/ui/mcp/oauth/callback",
|
|
"original_state": "state-123",
|
|
"code_challenge": None,
|
|
"code_challenge_method": None,
|
|
"client_redirect_uri": "https://proxy.example.com/ui/mcp/oauth/callback",
|
|
}
|
|
|
|
response = await callback(
|
|
request=_mock_callback_request(base_url="https://proxy.example.com/"),
|
|
code="auth-code-123",
|
|
state="encrypted_state",
|
|
)
|
|
|
|
assert response.status_code == 302
|
|
assert "https://proxy.example.com/ui/mcp/oauth/callback" in response.headers["location"]
|
|
assert "code=auth-code-123" in response.headers["location"]
|
|
assert "state=state-123" in response.headers["location"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authorize_forwards_short_state_and_round_trips_via_cookie(monkeypatch):
|
|
"""LIT-4197: the ``state`` sent to the upstream authorization server must be
|
|
a short opaque handle, not the long encrypted OAuth session (some IdPs
|
|
reject an over-long state). The session must instead ride in a per-flow
|
|
HttpOnly cookie so ``/callback`` still recovers the client's original state
|
|
and redirects back to the client's redirect_uri."""
|
|
from http.cookies import SimpleCookie
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
_oauth_state_cookie_name,
|
|
authorize_with_server,
|
|
callback,
|
|
decode_state_hash,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
# Real encryption so the cookie value is a genuine encrypted session.
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-test-salt-for-LIT-4197")
|
|
|
|
client_state = "ee230e3dfd4f19c7441941684f39c8a4e0e2c3c61a088e33403df5662b4047b8"
|
|
client_redirect_uri = "http://127.0.0.1:6274/oauth/callback/debug"
|
|
|
|
server = MCPServer(
|
|
server_id="leanix_server",
|
|
name="leanix",
|
|
server_name="leanix",
|
|
alias="leanix",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id="upstream-client-id",
|
|
authorization_url="https://idp.example.com/oauth/authorize",
|
|
token_url="https://idp.example.com/oauth/token",
|
|
)
|
|
|
|
authorize_request = MagicMock(spec=Request)
|
|
authorize_request.base_url = "https://proxy.example.com/"
|
|
authorize_request.headers = {}
|
|
|
|
authorize_response = await authorize_with_server(
|
|
request=authorize_request,
|
|
mcp_server=server,
|
|
client_id="upstream-client-id",
|
|
redirect_uri=client_redirect_uri,
|
|
state=client_state,
|
|
code_challenge="challenge",
|
|
code_challenge_method="S256",
|
|
)
|
|
|
|
location = authorize_response.headers["location"]
|
|
upstream_state = parse_qs(urlparse(location).query)["state"][0]
|
|
|
|
# The upstream must receive a short handle, not the encrypted session blob.
|
|
assert len(upstream_state) <= 64
|
|
assert upstream_state != client_state
|
|
|
|
# The encrypted session rides in a per-flow HttpOnly cookie bound to it.
|
|
jar = SimpleCookie()
|
|
jar.load(authorize_response.headers["set-cookie"])
|
|
cookie_name = _oauth_state_cookie_name(upstream_state)
|
|
assert cookie_name in jar
|
|
morsel = jar[cookie_name]
|
|
assert morsel["httponly"]
|
|
assert morsel["samesite"].lower() == "lax"
|
|
assert len(morsel.value) > len(upstream_state)
|
|
session = decode_state_hash(morsel.value)
|
|
assert session["original_state"] == client_state
|
|
assert session["client_redirect_uri"] == client_redirect_uri
|
|
|
|
# /callback recovers the original state from the cookie (not the handle) and
|
|
# redirects back to the client with the client's own state.
|
|
callback_request = MagicMock(spec=Request)
|
|
callback_request.base_url = "https://proxy.example.com/"
|
|
callback_request.headers = {}
|
|
callback_request.cookies = {cookie_name: morsel.value}
|
|
|
|
callback_response = await callback(
|
|
request=callback_request,
|
|
code="upstream-auth-code",
|
|
state=upstream_state,
|
|
)
|
|
|
|
assert callback_response.status_code == 302
|
|
cb_query = parse_qs(urlparse(callback_response.headers["location"]).query)
|
|
assert callback_response.headers["location"].startswith(client_redirect_uri)
|
|
assert cb_query["code"] == ["upstream-auth-code"]
|
|
assert cb_query["state"] == [client_state]
|
|
|
|
# The one-time cookie is expired on the callback response so it cannot be replayed.
|
|
cleared = SimpleCookie()
|
|
cleared.load(callback_response.headers["set-cookie"])
|
|
assert cookie_name in cleared
|
|
assert cleared[cookie_name].value == ""
|
|
assert cleared[cookie_name]["max-age"] == "0"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_callback_error_path_reads_cookie_and_clears_it(monkeypatch):
|
|
"""LIT-4197: an IdP error routed through /callback must recover the client's
|
|
original state from the cookie (not the short handle), propagate the error to
|
|
the client's redirect_uri, and expire the one-time cookie."""
|
|
from http.cookies import SimpleCookie
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
_oauth_state_cookie_name,
|
|
callback,
|
|
encode_state_with_base_url,
|
|
)
|
|
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-test-salt-for-LIT-4197")
|
|
|
|
client_state = "client-original-state-abc"
|
|
client_redirect_uri = "http://127.0.0.1:6274/oauth/callback/debug"
|
|
handle = "shortRelayHandle123"
|
|
encoded_state = encode_state_with_base_url(
|
|
base_url=client_redirect_uri,
|
|
original_state=client_state,
|
|
client_redirect_uri=client_redirect_uri,
|
|
)
|
|
cookie_name = _oauth_state_cookie_name(handle)
|
|
|
|
request = MagicMock(spec=Request)
|
|
request.base_url = "https://proxy.example.com/"
|
|
request.headers = {}
|
|
request.cookies = {cookie_name: encoded_state}
|
|
|
|
response = await callback(
|
|
request=request,
|
|
error="access_denied",
|
|
error_description="User declined access",
|
|
state=handle,
|
|
)
|
|
|
|
assert response.status_code == 302
|
|
location = response.headers["location"]
|
|
assert location.startswith(client_redirect_uri)
|
|
query = parse_qs(urlparse(location).query)
|
|
assert query["error"] == ["access_denied"]
|
|
# The client's own state is echoed back, recovered from the cookie.
|
|
assert query["state"] == [client_state]
|
|
|
|
cleared = SimpleCookie()
|
|
cleared.load(response.headers["set-cookie"])
|
|
assert cookie_name in cleared
|
|
assert cleared[cookie_name].value == ""
|
|
assert cleared[cookie_name]["max-age"] == "0"
|
|
|
|
|
|
def _issuer_anchored_oauth_server(issuer: str | None = "https://idp.example.com"):
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
return MCPServer(
|
|
server_id="rfc9207_server",
|
|
name="rfc9207",
|
|
server_name="rfc9207",
|
|
alias="rfc9207",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id="upstream-client-id",
|
|
issuer=issuer,
|
|
authorization_url="https://idp.example.com/oauth/authorize",
|
|
token_url="https://idp.example.com/oauth/token",
|
|
)
|
|
|
|
|
|
async def _authorize_then_callback(server, iss, monkeypatch, expected_issuer_override=..., error=None):
|
|
"""Run /authorize for ``server``, then feed the resulting flow back through /callback with the
|
|
RFC 9207 ``iss`` the authorization server supposedly returned. Returns (callback_response,
|
|
sealed_state_data)."""
|
|
from http.cookies import SimpleCookie
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
_oauth_state_cookie_name,
|
|
authorize_with_server,
|
|
callback,
|
|
decode_state_hash,
|
|
encode_state_with_base_url,
|
|
)
|
|
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-test-salt-for-LIT-5940")
|
|
client_redirect_uri = "http://127.0.0.1:6274/oauth/callback/debug"
|
|
|
|
authorize_request = MagicMock(spec=Request)
|
|
authorize_request.base_url = "https://proxy.example.com/"
|
|
authorize_request.headers = {}
|
|
|
|
authorize_response = await authorize_with_server(
|
|
request=authorize_request,
|
|
mcp_server=server,
|
|
client_id="upstream-client-id",
|
|
redirect_uri=client_redirect_uri,
|
|
state="client-original-state-9207",
|
|
code_challenge="challenge",
|
|
code_challenge_method="S256",
|
|
)
|
|
handle = parse_qs(urlparse(authorize_response.headers["location"]).query)["state"][0]
|
|
jar = SimpleCookie()
|
|
jar.load(authorize_response.headers["set-cookie"])
|
|
cookie_name = _oauth_state_cookie_name(handle)
|
|
sealed_state = jar[cookie_name].value
|
|
if expected_issuer_override is not ...:
|
|
# A state minted before the issuer was sealed into it: same shape, key absent.
|
|
sealed_state = encode_state_with_base_url(
|
|
base_url=client_redirect_uri,
|
|
original_state="client-original-state-9207",
|
|
client_redirect_uri=client_redirect_uri,
|
|
expected_issuer=expected_issuer_override,
|
|
)
|
|
|
|
callback_request = MagicMock(spec=Request)
|
|
callback_request.base_url = "https://proxy.example.com/"
|
|
callback_request.headers = {}
|
|
callback_request.cookies = {cookie_name: sealed_state}
|
|
|
|
response = await callback(
|
|
request=callback_request,
|
|
code="upstream-auth-code" if error is None else None,
|
|
error=error,
|
|
state=handle,
|
|
iss=iss,
|
|
)
|
|
return response, decode_state_hash(sealed_state)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authorize_seals_the_issuer_and_callback_accepts_a_matching_rfc9207_iss(monkeypatch):
|
|
"""The callback accepts the exact issuer sealed during authorization."""
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
response, state_data = await _authorize_then_callback(
|
|
_issuer_anchored_oauth_server(),
|
|
iss="https://idp.example.com",
|
|
monkeypatch=monkeypatch,
|
|
)
|
|
|
|
assert state_data["expected_issuer"] == "https://idp.example.com"
|
|
assert response.status_code == 302
|
|
query = parse_qs(urlparse(response.headers["location"]).query)
|
|
assert query["code"] == ["upstream-auth-code"]
|
|
assert query["state"] == ["client-original-state-9207"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_callback_rejects_authorization_response_from_a_different_issuer(monkeypatch):
|
|
"""LIT-5940 / RFC 9207 §2.4: an ``iss`` naming an authorization server we never sent the user to
|
|
is a mix-up attack, so the code must not reach the client's redirect_uri."""
|
|
response, _ = await _authorize_then_callback(
|
|
_issuer_anchored_oauth_server(),
|
|
iss="https://attacker-idp.example.com",
|
|
monkeypatch=monkeypatch,
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert "location" not in response.headers
|
|
assert b"upstream-auth-code" not in response.body
|
|
assert b"invalid_issuer" in response.body
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("error", [None, "access_denied"])
|
|
async def test_callback_rejects_supplied_issuer_without_expected_identity(monkeypatch, error):
|
|
response, state_data = await _authorize_then_callback(
|
|
_issuer_anchored_oauth_server(issuer=None),
|
|
iss="https://unknown-idp.example.com",
|
|
monkeypatch=monkeypatch,
|
|
error=error,
|
|
)
|
|
assert state_data["expected_issuer"] is None
|
|
assert response.status_code == 400
|
|
assert "location" not in response.headers
|
|
assert b"upstream-auth-code" not in response.body
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("issuer", [None, "https://idp.example.com"])
|
|
async def test_callback_accepts_missing_unadvertised_issuer(monkeypatch, issuer):
|
|
response, _ = await _authorize_then_callback(
|
|
_issuer_anchored_oauth_server(issuer),
|
|
iss=None,
|
|
monkeypatch=monkeypatch,
|
|
)
|
|
assert response.status_code == 302
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_callback_rejects_an_issuer_differing_only_outside_the_path(monkeypatch):
|
|
"""A tenant a deployment encoded in a query string is part of that issuer's identity, so the
|
|
comparison must not canonicalize it away and let another tenant's response through."""
|
|
response, state_data = await _authorize_then_callback(
|
|
_issuer_anchored_oauth_server(issuer="https://idp.example.com/?tenant=a"),
|
|
iss="https://idp.example.com/?tenant=b",
|
|
monkeypatch=monkeypatch,
|
|
)
|
|
|
|
assert state_data["expected_issuer"] == "https://idp.example.com/?tenant=a"
|
|
assert response.status_code == 400
|
|
assert "location" not in response.headers
|
|
assert b"upstream-auth-code" not in response.body
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("iss,expected_status", [(None, 302), ("https://some-idp.example.com", 400)])
|
|
async def test_callback_legacy_state_requires_absent_issuer(monkeypatch, iss, expected_status):
|
|
response, _ = await _authorize_then_callback(
|
|
_issuer_anchored_oauth_server(),
|
|
iss=iss,
|
|
monkeypatch=monkeypatch,
|
|
expected_issuer_override=None,
|
|
)
|
|
assert response.status_code == expected_status
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_oauth_authorize_includes_scopes_from_server_config():
|
|
"""Test that authorize endpoint includes scopes from server configuration."""
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
authorize_with_server,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
# Create server with specific scopes (e.g., GitLab requires 'ai_workflows')
|
|
oauth_server = MCPServer(
|
|
server_id="gitlab_server",
|
|
name="gitlab",
|
|
server_name="gitlab",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
authorization_url="https://gitlab.com/oauth/authorize",
|
|
token_url="https://gitlab.com/oauth/token",
|
|
client_id="test_client",
|
|
scopes=["api", "read_user", "ai_workflows"], # GitLab-specific scopes
|
|
)
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://litellm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
with patch("litellm.proxy.mcp_server.discoverable_endpoints.encrypt_value_helper") as mock_encrypt:
|
|
mock_encrypt.return_value = "encrypted_state"
|
|
|
|
# Call authorize without explicit scope parameter
|
|
response = await authorize_with_server(
|
|
request=mock_request,
|
|
mcp_server=oauth_server,
|
|
client_id="test_client",
|
|
redirect_uri="http://localhost:3000/callback",
|
|
state="test_state",
|
|
code_challenge="test_challenge",
|
|
code_challenge_method="S256",
|
|
response_type="code",
|
|
scope=None, # No scope in request, should use server's scopes
|
|
)
|
|
|
|
# Should redirect with scopes from server config
|
|
assert response.status_code in (307, 302)
|
|
redirect_url = response.headers["location"]
|
|
assert (
|
|
"scope=api+read_user+ai_workflows" in redirect_url or "scope=api%20read_user%20ai_workflows" in redirect_url
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_oauth_authorize_prefers_request_scope_over_server_config():
|
|
"""Test that explicit scope parameter takes precedence over server configuration."""
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
authorize_with_server,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
oauth_server = MCPServer(
|
|
server_id="test_server",
|
|
name="test",
|
|
server_name="test",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
authorization_url="https://provider.com/oauth/authorize",
|
|
token_url="https://provider.com/oauth/token",
|
|
client_id="test_client",
|
|
scopes=["default_scope1", "default_scope2"],
|
|
)
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://litellm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
with patch("litellm.proxy.mcp_server.discoverable_endpoints.encrypt_value_helper") as mock_encrypt:
|
|
mock_encrypt.return_value = "encrypted_state"
|
|
|
|
# Call authorize WITH explicit scope parameter
|
|
response = await authorize_with_server(
|
|
request=mock_request,
|
|
mcp_server=oauth_server,
|
|
client_id="test_client",
|
|
redirect_uri="http://localhost:3000/callback",
|
|
state="test_state",
|
|
code_challenge="test_challenge",
|
|
code_challenge_method="S256",
|
|
response_type="code",
|
|
scope="custom_scope1 custom_scope2", # Explicit scope should take precedence
|
|
)
|
|
|
|
# Should use the explicit scope, not server config
|
|
assert response.status_code in (307, 302)
|
|
redirect_url = response.headers["location"]
|
|
assert (
|
|
"scope=custom_scope1+custom_scope2" in redirect_url or "scope=custom_scope1%20custom_scope2" in redirect_url
|
|
)
|
|
assert "default_scope" not in redirect_url
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_endpoint_refresh_token_grant():
|
|
"""Test that token endpoint supports refresh_token grant type."""
|
|
try:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
token_endpoint,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
# Clear registry
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
# Create mock OAuth2 server
|
|
oauth2_server = MCPServer(
|
|
server_id="google_mcp",
|
|
name="google_mcp",
|
|
server_name="google_mcp",
|
|
alias="google_mcp",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id="test_client_id",
|
|
client_secret="test_secret",
|
|
authorization_url="https://accounts.google.com/o/oauth2/v2/auth",
|
|
token_url="https://oauth2.googleapis.com/token",
|
|
scopes=["openid", "email"],
|
|
)
|
|
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://proxy.litellm.example/"
|
|
mock_request.headers = {}
|
|
|
|
# Mock httpx client response with new tokens
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"access_token": "new_access_token",
|
|
"token_type": "Bearer",
|
|
"expires_in": 3599,
|
|
"refresh_token": "new_refresh_token",
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
mock_async_client = MagicMock()
|
|
mock_async_client.post = AsyncMock(return_value=mock_response)
|
|
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client"
|
|
) as mock_get_client:
|
|
mock_get_client.return_value = mock_async_client
|
|
|
|
response = await token_endpoint(
|
|
request=mock_request,
|
|
grant_type="refresh_token",
|
|
code=None,
|
|
redirect_uri=None,
|
|
client_id="test_client_id",
|
|
mcp_server_name="google_mcp",
|
|
client_secret="test_secret",
|
|
refresh_token="rt-test",
|
|
scope="openid email",
|
|
)
|
|
|
|
# Verify the POST was called with refresh_token grant data
|
|
mock_async_client.post.assert_called_once()
|
|
call_args = mock_async_client.post.call_args
|
|
|
|
assert call_args[1]["data"]["grant_type"] == "refresh_token"
|
|
assert call_args[1]["data"]["refresh_token"] == "rt-test"
|
|
assert call_args[1]["data"]["client_id"] == "test_client_id"
|
|
assert call_args[1]["data"]["client_secret"] == "test_secret"
|
|
assert call_args[1]["data"]["scope"] == "openid email"
|
|
|
|
# Verify response contains the new tokens
|
|
import json
|
|
|
|
token_data = json.loads(response.body)
|
|
assert token_data["access_token"] == "new_access_token"
|
|
assert token_data["refresh_token"] == "new_refresh_token"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_endpoint_authorization_code_missing_code():
|
|
"""Test that authorization_code grant rejects missing code param."""
|
|
try:
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
exchange_token_with_server,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
server = MCPServer(
|
|
server_id="test_server",
|
|
name="test_server",
|
|
server_name="test_server",
|
|
alias="test_server",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id="cid",
|
|
token_url="https://example.com/token",
|
|
)
|
|
global_mcp_server_manager.registry[server.server_id] = server
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://proxy.example/"
|
|
mock_request.headers = {}
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await exchange_token_with_server(
|
|
request=mock_request,
|
|
mcp_server=server,
|
|
grant_type="authorization_code",
|
|
code=None,
|
|
redirect_uri="https://example.com/cb",
|
|
client_id="cid",
|
|
client_secret=None,
|
|
code_verifier=None,
|
|
)
|
|
assert exc_info.value.status_code == 400
|
|
assert "code is required" in str(exc_info.value.detail)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authorize_endpoint_rejects_non_loopback_redirect_uri():
|
|
"""VERIA-57 root cause B regression. The client-supplied redirect_uri
|
|
is encrypted into the OAuth state and decoded on /callback to 302 the
|
|
user back. A non-loopback value is an open-redirect + code-theft
|
|
primitive — reject with 400 before encoding anything into state."""
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
authorize,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
oauth2_server = MCPServer(
|
|
server_id="test_oauth_server",
|
|
name="test_oauth",
|
|
server_name="test_oauth",
|
|
alias="test_oauth",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id="cid",
|
|
client_secret="cs",
|
|
authorization_url="https://provider.com/oauth/authorize",
|
|
token_url="https://provider.com/oauth/token",
|
|
)
|
|
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://litellm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await authorize(
|
|
request=mock_request,
|
|
client_id="cid",
|
|
mcp_server_name="test_oauth",
|
|
redirect_uri="https://attacker.example.com/cb",
|
|
state="s",
|
|
)
|
|
assert exc_info.value.status_code == 400
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authorize_endpoint_accepts_ipv4_loopback_range_and_ipv6_full_form():
|
|
"""RFC 8252 §7.3 + RFC 4291: full 127.0.0.0/8 and full-form IPv6
|
|
loopback must be accepted — string match on ``127.0.0.1`` alone
|
|
would miss ``127.0.0.2`` and ``0:0:0:0:0:0:0:1``."""
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
authorize,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
oauth2_server = MCPServer(
|
|
server_id="test_oauth_server",
|
|
name="test_oauth",
|
|
server_name="test_oauth",
|
|
alias="test_oauth",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id="cid",
|
|
client_secret="cs",
|
|
authorization_url="https://provider.com/oauth/authorize",
|
|
token_url="https://provider.com/oauth/token",
|
|
)
|
|
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://litellm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
for uri in (
|
|
"http://127.0.0.2:3000/cb",
|
|
"http://[0:0:0:0:0:0:0:1]:3000/cb",
|
|
"http://localhost:3000/cb",
|
|
):
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.encrypt_value_helper"
|
|
) as mock_encrypt:
|
|
mock_encrypt.return_value = "mocked_encrypted_state"
|
|
response = await authorize(
|
|
request=mock_request,
|
|
client_id="cid",
|
|
mcp_server_name="test_oauth",
|
|
redirect_uri=uri,
|
|
state="s",
|
|
)
|
|
assert response.status_code == 307, f"{uri} should be accepted"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_callback_revalidates_loopback_on_decoded_base_url():
|
|
"""VERIA-57 root cause B defense-in-depth: an encrypted state minted
|
|
before the /authorize validation was added has no expiry and stays
|
|
valid. /callback must re-validate the decoded base_url so those
|
|
stale states can't be used as an open-redirect + code-theft
|
|
primitive."""
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
callback,
|
|
)
|
|
|
|
with patch("litellm.proxy.mcp_server.discoverable_endpoints.decode_state_hash") as mock_decode:
|
|
mock_decode.return_value = {
|
|
"base_url": "https://attacker.example.com/cb",
|
|
"original_state": "s",
|
|
"code_challenge": None,
|
|
"code_challenge_method": None,
|
|
"client_redirect_uri": "https://attacker.example.com/cb",
|
|
}
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await callback(
|
|
request=_mock_callback_request(),
|
|
code="stolen_code",
|
|
state="encrypted_stale_state",
|
|
)
|
|
assert exc_info.value.status_code == 400
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_callback_revalidates_loopback_on_decoded_client_redirect_uri():
|
|
"""If a state contains a full client_redirect_uri, validate that exact sink."""
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
callback,
|
|
)
|
|
|
|
with patch("litellm.proxy.mcp_server.discoverable_endpoints.decode_state_hash") as mock_decode:
|
|
mock_decode.return_value = {
|
|
"base_url": "http://localhost:3000/cb",
|
|
"original_state": "s",
|
|
"code_challenge": None,
|
|
"code_challenge_method": None,
|
|
"client_redirect_uri": "https://attacker.example.com/cb",
|
|
}
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await callback(
|
|
request=_mock_callback_request(),
|
|
code="stolen_code",
|
|
state="encrypted_stale_state",
|
|
)
|
|
assert exc_info.value.status_code == 400
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_callback_rejects_state_missing_redirect_uri():
|
|
"""Malformed state without a redirect target should fail with a structured 400."""
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
callback,
|
|
)
|
|
|
|
with patch("litellm.proxy.mcp_server.discoverable_endpoints.decode_state_hash") as mock_decode:
|
|
mock_decode.return_value = {
|
|
"original_state": "s",
|
|
"code_challenge": None,
|
|
"code_challenge_method": None,
|
|
}
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await callback(
|
|
request=_mock_callback_request(),
|
|
code="code",
|
|
state="encrypted_malformed_state",
|
|
)
|
|
assert exc_info.value.status_code == 400
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_endpoint_sets_no_store_cache_control():
|
|
"""RFC 6749 §5.1 / OAuth 2.1 draft-15 §4.1.3: the token response
|
|
contains an access token (and possibly a refresh token) — it MUST
|
|
NOT be cached by intermediaries or the client."""
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
exchange_token_with_server,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
server = MCPServer(
|
|
server_id="t",
|
|
name="t",
|
|
server_name="t",
|
|
alias="t",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id="cid",
|
|
client_secret="cs",
|
|
authorization_url="https://provider.com/oauth/authorize",
|
|
token_url="https://provider.com/oauth/token",
|
|
)
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://litellm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
fake_http_response = MagicMock()
|
|
fake_http_response.json.return_value = {
|
|
"access_token": "tok",
|
|
"token_type": "Bearer",
|
|
"expires_in": 3600,
|
|
}
|
|
fake_http_response.raise_for_status = MagicMock()
|
|
fake_http_client = MagicMock()
|
|
fake_http_client.post = AsyncMock(return_value=fake_http_response)
|
|
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=fake_http_client,
|
|
):
|
|
response = await exchange_token_with_server(
|
|
request=mock_request,
|
|
mcp_server=server,
|
|
grant_type="authorization_code",
|
|
code="c",
|
|
redirect_uri="http://127.0.0.1:3000/cb",
|
|
client_id="cid",
|
|
client_secret=None,
|
|
code_verifier=None,
|
|
)
|
|
|
|
assert response.headers["cache-control"] == "no-store"
|
|
assert response.headers["pragma"] == "no-cache"
|
|
|
|
|
|
async def _exchange_with_upstream_token_response(upstream_body):
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
exchange_token_with_server,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
server = MCPServer(
|
|
server_id="t",
|
|
name="t",
|
|
server_name="t",
|
|
alias="t",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id="cid",
|
|
client_secret="cs",
|
|
authorization_url="https://provider.com/oauth/authorize",
|
|
token_url="https://provider.com/oauth/token",
|
|
)
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://litellm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
fake_http_response = MagicMock()
|
|
fake_http_response.json.return_value = upstream_body
|
|
fake_http_response.raise_for_status = MagicMock()
|
|
fake_http_client = MagicMock()
|
|
fake_http_client.post = AsyncMock(return_value=fake_http_response)
|
|
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=fake_http_client,
|
|
):
|
|
response = await exchange_token_with_server(
|
|
request=mock_request,
|
|
mcp_server=server,
|
|
grant_type="authorization_code",
|
|
code="c",
|
|
redirect_uri="http://127.0.0.1:3000/cb",
|
|
client_id="cid",
|
|
client_secret=None,
|
|
code_verifier=None,
|
|
)
|
|
return json.loads(response.body)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_exchange_omits_expires_in_when_upstream_omits_it():
|
|
"""A provider that issues a non-expiring token (e.g. Slack without token
|
|
rotation) returns no ``expires_in``. The exchange must mirror that and omit
|
|
``expires_in`` rather than fabricate a 1-hour TTL, so the stored credential
|
|
is treated as non-expiring instead of dying after an hour."""
|
|
body = await _exchange_with_upstream_token_response({"access_token": "tok", "token_type": "Bearer"})
|
|
assert "expires_in" not in body
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_exchange_passes_through_upstream_expires_in():
|
|
"""When the provider does send ``expires_in`` (e.g. Slack with token
|
|
rotation), the exchange forwards the real value unchanged."""
|
|
body = await _exchange_with_upstream_token_response(
|
|
{"access_token": "tok", "token_type": "Bearer", "expires_in": 43200}
|
|
)
|
|
assert body["expires_in"] == 43200
|
|
|
|
|
|
_BRIDGE_CLIENT_REDIRECT = "https://claude.ai/api/mcp/auth_callback"
|
|
|
|
|
|
def _bridge_server(**overrides):
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
fields = {
|
|
"server_id": "bridge_srv",
|
|
"name": "bridge_srv",
|
|
"server_name": "bridge_srv",
|
|
"alias": "bridge_srv",
|
|
"transport": MCPTransport.http,
|
|
"auth_type": MCPAuth.true_passthrough,
|
|
"dcr_bridge": True,
|
|
"authorization_url": "https://provider.com/oauth/authorize",
|
|
"token_url": "https://provider.com/oauth/token",
|
|
"registration_url": "https://provider.com/oauth/register",
|
|
**overrides,
|
|
}
|
|
return MCPServer(**fields)
|
|
|
|
|
|
def _bridge_mock_request(cookie: str | None = None) -> MagicMock:
|
|
from fastapi import Request
|
|
|
|
mock_request: Final = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://litellm.example.com/"
|
|
mock_request.headers = {}
|
|
mock_request.cookies = {"token": cookie} if cookie else {}
|
|
return mock_request
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("auth_type_value", ["true_passthrough", "oauth_delegate"])
|
|
async def test_authorize_bridge_relay_passes_client_params_verbatim(auth_type_value):
|
|
"""The bridge relay arm (registration relayed upstream, no admin-configured client) passes the
|
|
client's client_id, redirect_uri, state, and PKCE through verbatim: the code returns straight
|
|
to the client's own redirect URI, so the gateway sets no state cookie, injects no /callback,
|
|
and applies no gateway-side redirect trust (the upstream enforces its registered binding)."""
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
authorize_with_server,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
response = await authorize_with_server(
|
|
request=_bridge_mock_request(),
|
|
mcp_server=_bridge_server(auth_type=MCPAuth(auth_type_value)),
|
|
client_id="dcr-client-123",
|
|
redirect_uri=_BRIDGE_CLIENT_REDIRECT,
|
|
state="client-state",
|
|
code_challenge="chal",
|
|
code_challenge_method="S256",
|
|
)
|
|
|
|
assert response.status_code == 307
|
|
location = response.headers["location"]
|
|
assert location.startswith("https://provider.com/oauth/authorize")
|
|
query = parse_qs(urlparse(location).query)
|
|
assert query["client_id"] == ["dcr-client-123"]
|
|
assert query["redirect_uri"] == [_BRIDGE_CLIENT_REDIRECT]
|
|
assert query["state"] == ["client-state"]
|
|
assert query["code_challenge"] == ["chal"]
|
|
assert query["code_challenge_method"] == ["S256"]
|
|
assert "litellm.example.com" not in location
|
|
assert "set-cookie" not in {key.lower() for key in response.headers.keys()}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"code_challenge,code_challenge_method",
|
|
[(None, None), ("chal", None), ("chal", "plain"), (None, "S256")],
|
|
)
|
|
async def test_authorize_bridge_requires_s256_pkce(code_challenge, code_challenge_method):
|
|
"""Bridge servers serve unauthenticated public clients, so the PKCE downgrade paths (missing
|
|
challenge, or a method that is not S256; RFC 7636 defaults a missing method to plain) are
|
|
rejected at the gateway on both bridge arms."""
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
authorize_with_server,
|
|
)
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
await authorize_with_server(
|
|
request=_bridge_mock_request(),
|
|
mcp_server=_bridge_server(),
|
|
client_id="dcr-client-123",
|
|
redirect_uri=_BRIDGE_CLIENT_REDIRECT,
|
|
state="s",
|
|
code_challenge=code_challenge,
|
|
code_challenge_method=code_challenge_method,
|
|
)
|
|
|
|
assert exc.value.status_code == 400
|
|
assert "S256" in str(exc.value.detail)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authorize_bridge_short_circuit_keeps_callback_and_redirect_trust():
|
|
"""The bridge short-circuit arm (admin-configured OAuth client, upstream only knows the
|
|
gateway callback) keeps the /callback state relay and the gateway redirect trust: a public
|
|
client redirect target is rejected unless ops allowlist it, and a trusted target still routes
|
|
through the gateway callback with the state cookie."""
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
authorize_with_server,
|
|
)
|
|
|
|
short_circuit_server = _bridge_server(client_id="admin-client", registration_url=None)
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
await authorize_with_server(
|
|
request=_bridge_mock_request(),
|
|
mcp_server=short_circuit_server,
|
|
client_id="ignored",
|
|
redirect_uri=_BRIDGE_CLIENT_REDIRECT,
|
|
state="s",
|
|
code_challenge="chal",
|
|
code_challenge_method="S256",
|
|
)
|
|
assert exc.value.status_code in (400, 403)
|
|
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.encrypt_value_helper",
|
|
return_value="mocked_encrypted_state",
|
|
):
|
|
response = await authorize_with_server(
|
|
request=_bridge_mock_request(),
|
|
mcp_server=short_circuit_server,
|
|
client_id="ignored",
|
|
redirect_uri="http://127.0.0.1:60108/callback",
|
|
state="s",
|
|
code_challenge="chal",
|
|
code_challenge_method="S256",
|
|
)
|
|
|
|
query = parse_qs(urlparse(response.headers["location"]).query)
|
|
assert query["redirect_uri"] == ["https://litellm.example.com/callback"]
|
|
assert query["client_id"] == ["admin-client"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authorize_non_bridge_client_forwarded_keeps_pre_bridge_contract():
|
|
"""A client-forwarded server without dcr_bridge keeps the pre-bridge behavior: no PKCE
|
|
requirement and the gateway /callback relay (this is the browser-only Authorize path)."""
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
authorize_with_server,
|
|
)
|
|
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.encrypt_value_helper",
|
|
return_value="mocked_encrypted_state",
|
|
):
|
|
response = await authorize_with_server(
|
|
request=_bridge_mock_request(),
|
|
mcp_server=_bridge_server(dcr_bridge=None),
|
|
client_id="cid",
|
|
redirect_uri="http://127.0.0.1:60108/callback",
|
|
state="s",
|
|
)
|
|
|
|
assert response.status_code == 307
|
|
query = parse_qs(urlparse(response.headers["location"]).query)
|
|
assert query["redirect_uri"] == ["https://litellm.example.com/callback"]
|
|
|
|
|
|
async def _bridge_token_post_data(server, redirect_uri):
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
exchange_token_with_server,
|
|
)
|
|
|
|
fake_http_response = MagicMock()
|
|
fake_http_response.json.return_value = {"access_token": "tok", "token_type": "Bearer"}
|
|
fake_http_response.raise_for_status = MagicMock()
|
|
fake_http_client = MagicMock()
|
|
fake_http_client.post = AsyncMock(return_value=fake_http_response)
|
|
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=fake_http_client,
|
|
):
|
|
await exchange_token_with_server(
|
|
request=_bridge_mock_request(),
|
|
mcp_server=server,
|
|
grant_type="authorization_code",
|
|
code="auth-code",
|
|
redirect_uri=redirect_uri,
|
|
client_id="dcr-client-123",
|
|
client_secret=None,
|
|
code_verifier="verifier",
|
|
)
|
|
return fake_http_client.post.call_args.kwargs["data"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_bridge_relay_posts_client_redirect_uri():
|
|
"""The bridge relay arm's token exchange sends the client's own redirect_uri upstream (it must
|
|
match the authorize leg) with the caller's public client_id and PKCE verifier."""
|
|
data = await _bridge_token_post_data(_bridge_server(), redirect_uri=_BRIDGE_CLIENT_REDIRECT)
|
|
|
|
assert data["redirect_uri"] == _BRIDGE_CLIENT_REDIRECT
|
|
assert data["client_id"] == "dcr-client-123"
|
|
assert data["code_verifier"] == "verifier"
|
|
assert "client_secret" not in data
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_bridge_relay_requires_redirect_uri():
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
exchange_token_with_server,
|
|
)
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
await exchange_token_with_server(
|
|
request=_bridge_mock_request(),
|
|
mcp_server=_bridge_server(),
|
|
grant_type="authorization_code",
|
|
code="auth-code",
|
|
redirect_uri=None,
|
|
client_id="dcr-client-123",
|
|
client_secret=None,
|
|
code_verifier="verifier",
|
|
)
|
|
|
|
assert exc.value.status_code == 400
|
|
assert "redirect_uri" in str(exc.value.detail)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_non_bridge_keeps_gateway_callback():
|
|
"""Without dcr_bridge the token exchange keeps posting the gateway callback as redirect_uri,
|
|
pinning the pre-bridge contract for the browser-only Authorize path."""
|
|
data = await _bridge_token_post_data(_bridge_server(dcr_bridge=None), redirect_uri=_BRIDGE_CLIENT_REDIRECT)
|
|
|
|
assert data["redirect_uri"] == "https://litellm.example.com/callback"
|
|
|
|
|
|
def _named_as_metadata_response(server):
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
_build_oauth_authorization_server_response,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
global_mcp_server_manager.registry[server.server_id] = server
|
|
try:
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.IPAddressUtils.get_mcp_client_ip",
|
|
return_value=None,
|
|
):
|
|
return _build_oauth_authorization_server_response(
|
|
request=_bridge_mock_request(),
|
|
mcp_server_name=server.server_name,
|
|
)
|
|
finally:
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
|
|
def test_oauth_authorization_server_metadata_served_for_bridge_server():
|
|
"""Bridge servers get the gateway's AS metadata (the register, authorize, and token relays),
|
|
which is what makes the DCR front door discoverable to standard MCP clients."""
|
|
result = _named_as_metadata_response(_bridge_server())
|
|
|
|
assert result["authorization_endpoint"] == "https://litellm.example.com/bridge_srv/authorize"
|
|
assert result["token_endpoint"] == "https://litellm.example.com/bridge_srv/token"
|
|
assert result["registration_endpoint"] == "https://litellm.example.com/bridge_srv/register"
|
|
|
|
|
|
def test_oauth_authorization_server_404_for_non_bridge_client_forwarded_server():
|
|
"""Without dcr_bridge a client-forwarded server keeps 404ing AS-metadata discovery: verbatim
|
|
upstream discovery is the contract and the gateway must not advertise itself as its AS."""
|
|
with pytest.raises(HTTPException) as exc:
|
|
_named_as_metadata_response(_bridge_server(dcr_bridge=None))
|
|
|
|
assert exc.value.status_code == 404
|
|
|
|
|
|
async def _bridge_register_response(
|
|
server: "MCPServer",
|
|
request_payload: _BridgeRegistrationPayload,
|
|
replace_stale_client: bool = False,
|
|
database_connected: bool = True,
|
|
) -> tuple[Response, MagicMock, AsyncMock]:
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
register_client_with_server,
|
|
)
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 201
|
|
mock_response.json.return_value = {
|
|
"client_id": "upstream-issued-client",
|
|
"redirect_uris": request_payload.get("redirect_uris", []),
|
|
"token_endpoint_auth_method": "none",
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_async_client = MagicMock()
|
|
mock_async_client.post = AsyncMock(return_value=mock_response)
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=mock_async_client,
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints._persist_dcr_client_registration",
|
|
new_callable=AsyncMock,
|
|
) as mock_persist,
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints._reuse_persisted_dcr_client_if_available",
|
|
new_callable=AsyncMock,
|
|
return_value=False,
|
|
),
|
|
patch(
|
|
"litellm.proxy.utils.get_prisma_client_or_throw",
|
|
**({"return_value": MagicMock()} if database_connected else {"side_effect": HTTPException(status_code=500)}),
|
|
),
|
|
):
|
|
response = await register_client_with_server(
|
|
request=_bridge_mock_request(),
|
|
mcp_server=server,
|
|
client_name=request_payload.get("client_name", ""),
|
|
grant_types=request_payload.get("grant_types"),
|
|
response_types=request_payload.get("response_types"),
|
|
token_endpoint_auth_method=request_payload.get("token_endpoint_auth_method"),
|
|
replace_stale_client=replace_stale_client,
|
|
client_redirect_uris=request_payload.get("redirect_uris"),
|
|
)
|
|
return response, mock_async_client, mock_persist
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_bridge_relay_forwards_client_redirect_uris():
|
|
"""The bridge relay arm registers the client's own redirect_uris upstream with public-client
|
|
defaults and relays the upstream response verbatim, so the upstream AS enforces the redirect
|
|
binding for that client and the auth code never transits the gateway."""
|
|
import json
|
|
|
|
response, mock_async_client, _ = await _bridge_register_response(
|
|
_bridge_server(),
|
|
{"client_name": "Claude", "redirect_uris": [_BRIDGE_CLIENT_REDIRECT]},
|
|
)
|
|
|
|
posted = mock_async_client.post.call_args.kwargs["json"]
|
|
assert posted["redirect_uris"] == [_BRIDGE_CLIENT_REDIRECT]
|
|
assert posted["grant_types"] == ["authorization_code", "refresh_token"]
|
|
assert posted["response_types"] == ["code"]
|
|
assert posted["token_endpoint_auth_method"] == "none"
|
|
|
|
payload = json.loads(response.body.decode("utf-8"))
|
|
assert payload["client_id"] == "upstream-issued-client"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_bridge_relay_requires_redirect_uris():
|
|
with pytest.raises(HTTPException) as exc:
|
|
await _bridge_register_response(_bridge_server(), {"client_name": "Claude"})
|
|
|
|
assert exc.value.status_code == 400
|
|
assert "redirect_uris" in str(exc.value.detail)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_bridge_relay_surfaces_upstream_error_not_500():
|
|
"""A bridge relay registration the upstream rejects must surface the upstream status and its
|
|
RFC 7591 error body to the client, not a bare 500 that hides the real reason."""
|
|
import httpx
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
register_client_with_server,
|
|
)
|
|
|
|
error_response = MagicMock()
|
|
error_response.status_code = 400
|
|
error_response.text = '{"error":"invalid_redirect_uri","error_description":"redirect_uri not allowed"}'
|
|
error_response.json = MagicMock(
|
|
return_value={"error": "invalid_redirect_uri", "error_description": "redirect_uri not allowed"}
|
|
)
|
|
error_response.raise_for_status = MagicMock(
|
|
side_effect=httpx.HTTPStatusError("bad", request=MagicMock(), response=error_response)
|
|
)
|
|
mock_async_client = MagicMock()
|
|
mock_async_client.post = AsyncMock(return_value=error_response)
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=mock_async_client,
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints._reuse_persisted_dcr_client_if_available",
|
|
new_callable=AsyncMock,
|
|
return_value=False,
|
|
),
|
|
):
|
|
with pytest.raises(HTTPException) as exc:
|
|
await register_client_with_server(
|
|
request=_bridge_mock_request(),
|
|
mcp_server=_bridge_server(),
|
|
client_name="Claude",
|
|
grant_types=None,
|
|
response_types=None,
|
|
token_endpoint_auth_method=None,
|
|
client_redirect_uris=[_BRIDGE_CLIENT_REDIRECT],
|
|
)
|
|
|
|
assert exc.value.status_code == 400
|
|
assert "invalid_redirect_uri" in str(exc.value.detail)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_non_bridge_upstream_error_relays_status_not_500():
|
|
"""A non-bridge DCR rejection must relay the upstream status and RFC 7591 error body just like
|
|
the bridge relay arm; a raw HTTPStatusError would escape to the global handler and surface as an
|
|
opaque 500 that hides the real reason from the create-flow UI."""
|
|
import httpx
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
register_client_with_server,
|
|
)
|
|
|
|
error_response = MagicMock()
|
|
error_response.status_code = 400
|
|
error_response.text = '{"error":"invalid_client_metadata"}'
|
|
error_response.json = MagicMock(return_value={"error": "invalid_client_metadata"})
|
|
error_response.raise_for_status = MagicMock(
|
|
side_effect=httpx.HTTPStatusError("bad", request=MagicMock(), response=error_response)
|
|
)
|
|
mock_async_client = MagicMock()
|
|
mock_async_client.post = AsyncMock(return_value=error_response)
|
|
|
|
oauth2_server = _bridge_server(auth_type=MCPAuth.oauth2, dcr_bridge=None)
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=mock_async_client,
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints._reuse_persisted_dcr_client_if_available",
|
|
new_callable=AsyncMock,
|
|
return_value=False,
|
|
),
|
|
):
|
|
with pytest.raises(HTTPException) as exc:
|
|
await register_client_with_server(
|
|
request=_bridge_mock_request(),
|
|
mcp_server=oauth2_server,
|
|
client_name="Claude",
|
|
grant_types=None,
|
|
response_types=None,
|
|
token_endpoint_auth_method=None,
|
|
)
|
|
|
|
assert exc.value.status_code == 400
|
|
assert "invalid_client_metadata" in str(exc.value.detail)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_bridge_relay_never_persists():
|
|
"""Relayed registrations belong to individual clients; persisting one as the server's own DCR
|
|
client would hand every future caller the first client's identity."""
|
|
_, _, mock_persist = await _bridge_register_response(
|
|
_bridge_server(),
|
|
{"client_name": "Claude", "redirect_uris": [_BRIDGE_CLIENT_REDIRECT]},
|
|
replace_stale_client=True,
|
|
)
|
|
|
|
mock_persist.assert_not_called()
|
|
|
|
|
|
_LAPTOP_LOOPBACK_REDIRECT: Final = "http://127.0.0.1:33418/"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("auth_type_value", ["oauth_delegate", "true_passthrough"])
|
|
async def test_register_bridge_with_proxy_base_url_registers_gateway_client_not_laptop_loopback(
|
|
monkeypatch: pytest.MonkeyPatch, auth_type_value: Literal["oauth_delegate", "true_passthrough"]
|
|
) -> None:
|
|
"""A laptop MCP client (VS Code) registers with its own loopback redirect. With PROXY_BASE_URL set
|
|
the gateway must not relay that registration upstream: it registers its one shared client bound
|
|
to the gateway /callback, keeps it, and hands the caller a client bound to the caller's loopback.
|
|
Relaying the loopback is the customer failure on upstreams that restrict redirect domains."""
|
|
import json
|
|
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
monkeypatch.setenv("PROXY_BASE_URL", "https://litellm.example.com")
|
|
response, mock_async_client, mock_persist = await _bridge_register_response(
|
|
_bridge_server(auth_type=MCPAuth(auth_type_value)),
|
|
{"client_name": "Visual Studio Code", "redirect_uris": [_LAPTOP_LOOPBACK_REDIRECT]},
|
|
)
|
|
|
|
posted: Final = mock_async_client.post.call_args.kwargs["json"]
|
|
assert posted["redirect_uris"] == ["https://litellm.example.com/callback"], posted
|
|
assert posted["client_name"] == "LiteLLM Proxy", posted
|
|
mock_persist.assert_awaited_once()
|
|
|
|
payload: Final = json.loads(response.body.decode("utf-8"))
|
|
assert payload["redirect_uris"] == [_LAPTOP_LOOPBACK_REDIRECT], payload
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_bridge_without_database_still_relays_client_registration(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A database-free gateway cannot keep the shared client past this process, so even with
|
|
PROXY_BASE_URL set it relays the caller's own registration instead of stranding the next authorize."""
|
|
monkeypatch.setenv("PROXY_BASE_URL", "https://litellm.example.com")
|
|
_, mock_async_client, mock_persist = await _bridge_register_response(
|
|
_bridge_server(),
|
|
{"client_name": "Visual Studio Code", "redirect_uris": [_LAPTOP_LOOPBACK_REDIRECT]},
|
|
database_connected=False,
|
|
)
|
|
|
|
assert mock_async_client.post.call_args.kwargs["json"]["redirect_uris"] == [_LAPTOP_LOOPBACK_REDIRECT]
|
|
mock_persist.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_bridge_without_proxy_base_url_still_relays_client_registration(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Without PROXY_BASE_URL the gateway cannot keep a shared client, so the relay arm remains the
|
|
fallback and the caller's own redirect goes upstream as before."""
|
|
monkeypatch.delenv("PROXY_BASE_URL", raising=False)
|
|
_, mock_async_client, mock_persist = await _bridge_register_response(
|
|
_bridge_server(),
|
|
{"client_name": "Visual Studio Code", "redirect_uris": [_LAPTOP_LOOPBACK_REDIRECT]},
|
|
)
|
|
|
|
assert mock_async_client.post.call_args.kwargs["json"]["redirect_uris"] == [_LAPTOP_LOOPBACK_REDIRECT]
|
|
mock_persist.assert_not_called()
|
|
|
|
|
|
def _persisted_shared_gateway_client(
|
|
server: "MCPServer", client_id: str = "shared-gateway-client"
|
|
) -> LiteLLM_MCPServerTable:
|
|
request: Final = _bridge_mock_request()
|
|
credentials: Final = {
|
|
"client_id": client_id,
|
|
"client_secret": None,
|
|
"token_endpoint_auth_method": None,
|
|
"redirect_uris": [f"{request.base_url.rstrip('/')}/callback"],
|
|
"dcr_issuer": server.issuer,
|
|
"dcr_server_url": server.url,
|
|
}
|
|
return LiteLLM_MCPServerTable.model_validate({**server.model_dump(exclude_none=True), "credentials": credentials})
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("shared_client_persisted", "expected_redirect_uri", "expected_client_id"),
|
|
[
|
|
(True, "https://litellm.example.com/callback", "shared-gateway-client"),
|
|
(False, _LAPTOP_LOOPBACK_REDIRECT, "dcr-client-123"),
|
|
],
|
|
)
|
|
async def test_authorize_clientless_bridge_loads_shared_client_before_choosing_the_arm(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
shared_client_persisted: bool,
|
|
expected_redirect_uri: str,
|
|
expected_client_id: str,
|
|
) -> None:
|
|
"""This worker's catalog snapshot has no client_id. When another worker already persisted the shared
|
|
client, authorize must load it and send it with the gateway /callback upstream, never the caller's
|
|
own client_id or loopback. When no shared client exists yet (a client registered before the gateway
|
|
kept one), the caller keeps its own upstream binding through the relay."""
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import authorize_with_server
|
|
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
|
|
|
|
monkeypatch.setenv("PROXY_BASE_URL", "https://litellm.example.com")
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", "test-registration-binding")
|
|
server: Final = _bridge_server(url="https://mcp.example.com/mcp")
|
|
persisted: Final = _persisted_shared_gateway_client(server) if shared_client_persisted else None
|
|
prisma_client: Final = MagicMock()
|
|
mock_get_mcp_server: Final = AsyncMock(return_value=persisted)
|
|
mock_update_server: Final = AsyncMock()
|
|
with (
|
|
patch(
|
|
"litellm.proxy.utils.get_prisma_client_or_throw",
|
|
return_value=prisma_client,
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.db.get_mcp_server",
|
|
new=mock_get_mcp_server,
|
|
),
|
|
patch.object(
|
|
global_mcp_server_manager,
|
|
"update_server",
|
|
new=mock_update_server,
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.encrypt_value_helper",
|
|
return_value="mocked_encrypted_state",
|
|
),
|
|
):
|
|
response: Final = await authorize_with_server(
|
|
request=_bridge_mock_request(),
|
|
mcp_server=server,
|
|
client_id="dcr-client-123",
|
|
redirect_uri=_LAPTOP_LOOPBACK_REDIRECT,
|
|
state="s",
|
|
code_challenge="chal",
|
|
code_challenge_method="S256",
|
|
)
|
|
|
|
location: Final = response.headers["location"]
|
|
assert location.startswith("https://provider.com/oauth/authorize"), location
|
|
query: Final = parse_qs(urlparse(location).query)
|
|
assert query["redirect_uri"] == [expected_redirect_uri], query
|
|
assert query["client_id"] == [expected_client_id], query
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("shared_client_persisted", "expected_redirect_uri", "expected_client_id"),
|
|
[
|
|
(True, "https://litellm.example.com/callback", "shared-gateway-client"),
|
|
(False, _LAPTOP_LOOPBACK_REDIRECT, "dcr-client-123"),
|
|
],
|
|
)
|
|
async def test_token_clientless_bridge_loads_shared_client_before_choosing_the_arm(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
shared_client_persisted: bool,
|
|
expected_redirect_uri: str,
|
|
expected_client_id: str,
|
|
) -> None:
|
|
"""The token leg mirrors authorize: with the shared client persisted it redeems the code with that
|
|
client and the gateway /callback, and with none yet it relays the caller's own client and redirect."""
|
|
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
|
|
|
|
monkeypatch.setenv("PROXY_BASE_URL", "https://litellm.example.com")
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", "test-registration-binding")
|
|
server: Final = _bridge_server(url="https://mcp.example.com/mcp")
|
|
persisted: Final = _persisted_shared_gateway_client(server) if shared_client_persisted else None
|
|
prisma_client: Final = MagicMock()
|
|
mock_get_mcp_server: Final = AsyncMock(return_value=persisted)
|
|
mock_update_server: Final = AsyncMock()
|
|
with (
|
|
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=prisma_client),
|
|
patch(
|
|
"litellm.proxy.mcp_server.db.get_mcp_server",
|
|
new=mock_get_mcp_server,
|
|
),
|
|
patch.object(
|
|
global_mcp_server_manager,
|
|
"update_server",
|
|
new=mock_update_server,
|
|
),
|
|
):
|
|
data: Final = await _bridge_token_post_data(server, redirect_uri=_LAPTOP_LOOPBACK_REDIRECT)
|
|
|
|
assert data["redirect_uri"] == expected_redirect_uri, data
|
|
assert data["client_id"] == expected_client_id, data
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("dcr_bridge", "expected"),
|
|
[(True, "persisted"), (False, "skipped")],
|
|
)
|
|
async def test_persist_dcr_client_for_client_forwarded_server_only_when_it_is_a_bridge(
|
|
dcr_bridge: bool,
|
|
expected: Literal["persisted", "skipped"],
|
|
) -> None:
|
|
"""A DCR bridge keeps the shared gateway client on the server row (without stamping oauth2_flow on
|
|
a client-forwarded row), while a client-forwarded server without the bridge still stores nothing."""
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
_persist_dcr_client_registration,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server: Final = _bridge_server(auth_type=MCPAuth.oauth_delegate, dcr_bridge=dcr_bridge)
|
|
prisma: Final = MagicMock()
|
|
prisma.db.litellm_mcpservertable.find_unique = AsyncMock(return_value=None)
|
|
mock_update: Final = AsyncMock(return_value=MagicMock())
|
|
with (
|
|
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=prisma),
|
|
patch("litellm.proxy.mcp_server.db.update_mcp_server", new=mock_update),
|
|
patch.object(global_mcp_server_manager, "update_server", new=AsyncMock()),
|
|
patch.object(global_mcp_server_manager, "is_config_declared_server", return_value=False),
|
|
):
|
|
result: Final = await _persist_dcr_client_registration(
|
|
server,
|
|
{"client_id": "shared-gateway-client", "token_endpoint_auth_method": "none"},
|
|
"https://litellm.example.com/callback",
|
|
replace_stale_client=False,
|
|
)
|
|
|
|
assert result == expected
|
|
if expected == "skipped":
|
|
mock_update.assert_not_awaited()
|
|
return
|
|
written: Final = mock_update.await_args.kwargs["data"]
|
|
assert written.oauth2_flow is None, written
|
|
assert written.credentials["client_id"] == "shared-gateway-client", written
|
|
assert server.client_id == "shared-gateway-client"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("server_has_shared_client", [False, True])
|
|
async def test_token_sealed_ephemeral_client_is_not_replaced_by_persisted_gateway_client(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
server_has_shared_client: bool,
|
|
) -> None:
|
|
"""The token leg must use the sealed ephemeral client even if this worker's catalog has a shared client."""
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import exchange_token_with_server
|
|
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
|
|
|
|
monkeypatch.setenv("PROXY_BASE_URL", "https://litellm.example.com")
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", "test-registration-binding")
|
|
server: Final = _bridge_server(
|
|
url="https://mcp.example.com/mcp",
|
|
client_id="shared-gateway-client" if server_has_shared_client else None,
|
|
client_secret="shared-secret" if server_has_shared_client else None,
|
|
token_endpoint_auth_method="client_secret_basic" if server_has_shared_client else None,
|
|
)
|
|
fake_http_response: Final = MagicMock()
|
|
fake_http_response.json.return_value = {"access_token": "tok", "token_type": "Bearer"}
|
|
fake_http_response.raise_for_status = MagicMock()
|
|
fake_http_client: Final = MagicMock()
|
|
fake_http_client.post = AsyncMock(return_value=fake_http_response)
|
|
prisma_client: Final = MagicMock()
|
|
mock_get_mcp_server: Final = AsyncMock(
|
|
return_value=_persisted_shared_gateway_client(server, client_id="persisted-gateway-client")
|
|
)
|
|
mock_update_server: Final = AsyncMock()
|
|
with (
|
|
patch(
|
|
"litellm.proxy.utils.get_prisma_client_or_throw",
|
|
return_value=prisma_client,
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.db.get_mcp_server",
|
|
new=mock_get_mcp_server,
|
|
),
|
|
patch.object(
|
|
global_mcp_server_manager,
|
|
"update_server",
|
|
new=mock_update_server,
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=fake_http_client,
|
|
),
|
|
):
|
|
await exchange_token_with_server(
|
|
request=_bridge_mock_request(),
|
|
mcp_server=server,
|
|
grant_type="authorization_code",
|
|
code="upstream-code",
|
|
redirect_uri="https://litellm.example.com/callback",
|
|
client_id="ephemeral-client",
|
|
client_secret="ephemeral-secret" if server_has_shared_client else None,
|
|
code_verifier="verifier",
|
|
client_token_endpoint_auth_method="client_secret_post" if server_has_shared_client else None,
|
|
sealed_client=True,
|
|
)
|
|
|
|
data: Final = fake_http_client.post.call_args.kwargs["data"]
|
|
assert data["client_id"] == "ephemeral-client", data
|
|
assert data["redirect_uri"] == "https://litellm.example.com/callback", data
|
|
if server_has_shared_client:
|
|
headers: Final = fake_http_client.post.call_args.kwargs["headers"]
|
|
assert data["client_secret"] == "ephemeral-secret", data
|
|
assert "Authorization" not in headers, headers
|
|
|
|
|
|
_BRIDGE_MASTER_KEY = "sk-bridge-producer-master-key-0123456789abcdef"
|
|
|
|
|
|
async def _exchange_for_bridge_server(server, upstream_body, key_hash, code="auth-code", fake_client_out=None):
|
|
from litellm.proxy.mcp_server.bridge_token_flow import _ResolvedKey
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import exchange_token_with_server
|
|
|
|
fake_http_response = MagicMock()
|
|
fake_http_response.json.return_value = upstream_body
|
|
fake_http_response.raise_for_status = MagicMock()
|
|
fake_http_client = MagicMock()
|
|
fake_http_client.post = AsyncMock(return_value=fake_http_response)
|
|
# The mint consumes _resolve_active_litellm_key's tagged result: an active key resolves to a
|
|
# _ResolvedKey carrying its hash; a request with no usable credential resolves to "no_active_key".
|
|
resolution = _ResolvedKey(key_hash=key_hash, key=MagicMock()) if key_hash is not None else "no_active_key"
|
|
key_resolver = AsyncMock(return_value=resolution)
|
|
if fake_client_out is not None:
|
|
fake_client_out["client"] = fake_http_client
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=fake_http_client,
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.bridge_token_flow._resolve_active_litellm_key",
|
|
new=key_resolver,
|
|
),
|
|
patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY),
|
|
):
|
|
response = await exchange_token_with_server(
|
|
request=_bridge_mock_request(),
|
|
mcp_server=server,
|
|
grant_type="authorization_code",
|
|
code=code,
|
|
redirect_uri="https://claude.ai/api/mcp/auth_callback",
|
|
client_id="dcr-client-123",
|
|
client_secret=None,
|
|
code_verifier="verifier",
|
|
)
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import is_bridge_authorization_code
|
|
|
|
# The key_hash path resolves the presented litellm key; the interactive SSO path recovers identity
|
|
# from the gateway authorization code instead, so it never awaits the resolver.
|
|
if server.is_oauth_delegate and server.is_dcr_bridge and not is_bridge_authorization_code(code):
|
|
key_resolver.assert_awaited_once()
|
|
else:
|
|
key_resolver.assert_not_awaited()
|
|
return response
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_oauth_delegate_bridge_token_exchange_mints_envelope_not_raw_token():
|
|
"""A dcr_bridge oauth_delegate token exchange returns a gateway-bound envelope, not the raw
|
|
upstream token: the response access_token opens (under the same master-key-derived keys and the
|
|
server_id) to the caller's identity and the upstream Authorization, and the raw upstream token
|
|
never appears in the bearer the client receives."""
|
|
from datetime import datetime, timezone
|
|
|
|
from litellm.proxy.mcp_server.outbound_credentials.bridge_credentials import (
|
|
BridgeEnvelopeAdmitted,
|
|
envelope_keys_from_master_key,
|
|
resolve_bridge_envelope,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
|
upstream = {"access_token": "UPSTREAM-SECRET-TOKEN", "token_type": "Bearer", "expires_in": 3600}
|
|
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
|
|
|
|
body = json.loads(response.body)
|
|
token = body["access_token"]
|
|
assert body["token_type"] == "Bearer"
|
|
assert body["expires_in"] > 0
|
|
assert token.startswith("llm_env_")
|
|
assert "UPSTREAM-SECRET-TOKEN" not in token
|
|
assert "refresh_token" not in body
|
|
|
|
keys = envelope_keys_from_master_key(_BRIDGE_MASTER_KEY)
|
|
opened = resolve_bridge_envelope(token, keys, datetime.now(timezone.utc), server.server_id)
|
|
assert isinstance(opened, BridgeEnvelopeAdmitted)
|
|
assert opened.identity.subject_type == "key_hash"
|
|
assert opened.identity.subject == "hashed-litellm-key-77"
|
|
assert opened.upstream_authorization.get_secret_value() == "Bearer UPSTREAM-SECRET-TOKEN"
|
|
|
|
|
|
def test_bridge_authorization_code_round_trips_and_rejects_hostile_input():
|
|
"""The gateway authorization code seals and recovers the upstream code and the SSO user, and is
|
|
total over hostile input: a raw upstream code (scripted path) opens to None, and a tampered or
|
|
non-gateway value opens to None rather than raising."""
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
is_bridge_authorization_code,
|
|
open_bridge_authorization_code,
|
|
seal_bridge_authorization_code,
|
|
)
|
|
|
|
with patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY):
|
|
sealed = seal_bridge_authorization_code(
|
|
upstream_code="up-code", litellm_user_id="sso-user-9", mcp_server_id="srv-1"
|
|
)
|
|
assert is_bridge_authorization_code(sealed)
|
|
opened = open_bridge_authorization_code(sealed)
|
|
assert opened is not None
|
|
assert opened.upstream_code == "up-code"
|
|
assert opened.litellm_user_id == "sso-user-9"
|
|
assert opened.mcp_server_id == "srv-1"
|
|
assert open_bridge_authorization_code("raw-upstream-code") is None
|
|
assert open_bridge_authorization_code(sealed[:-4] + "aaaa") is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_interactive_bridge_token_exchange_mints_user_subject_envelope():
|
|
"""An interactive dcr_bridge oauth_delegate exchange (the client presents the gateway code the
|
|
callback sealed, and NO litellm key) mints an envelope bound to the SSO-captured user: it opens
|
|
to a user_id subject, and the upstream exchange used the real upstream code recovered from the
|
|
gateway code, not the sealed wrapper."""
|
|
from datetime import datetime, timezone
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
seal_bridge_authorization_code,
|
|
)
|
|
from litellm.proxy.mcp_server.outbound_credentials.bridge_credentials import (
|
|
BridgeEnvelopeAdmitted,
|
|
envelope_keys_from_master_key,
|
|
resolve_bridge_envelope,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
|
with patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY):
|
|
gateway_code = seal_bridge_authorization_code(
|
|
upstream_code="REAL-UPSTREAM-CODE", litellm_user_id="sso-user-42", mcp_server_id=server.server_id
|
|
)
|
|
upstream = {"access_token": "UPSTREAM-SECRET-TOKEN", "token_type": "Bearer", "expires_in": 3600}
|
|
captured: dict = {}
|
|
response = await _exchange_for_bridge_server(
|
|
server, upstream, key_hash=None, code=gateway_code, fake_client_out=captured
|
|
)
|
|
|
|
token = json.loads(response.body)["access_token"]
|
|
keys = envelope_keys_from_master_key(_BRIDGE_MASTER_KEY)
|
|
opened = resolve_bridge_envelope(token, keys, datetime.now(timezone.utc), server.server_id)
|
|
assert isinstance(opened, BridgeEnvelopeAdmitted)
|
|
assert opened.identity.subject_type == "user_id"
|
|
assert opened.identity.subject == "sso-user-42"
|
|
assert opened.upstream_authorization.get_secret_value() == "Bearer UPSTREAM-SECRET-TOKEN"
|
|
assert captured["client"].post.call_args.kwargs["data"]["code"] == "REAL-UPSTREAM-CODE"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_interactive_bridge_gateway_code_for_another_server_is_rejected_400():
|
|
"""A gateway authorization code is bound to the server it was minted for: presenting it at another
|
|
server's token endpoint is a 400, so a code cannot be replayed across a server boundary."""
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
seal_bridge_authorization_code,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
|
with patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY):
|
|
gateway_code = seal_bridge_authorization_code(
|
|
upstream_code="up-code", litellm_user_id="sso-user-42", mcp_server_id="a-different-server-id"
|
|
)
|
|
upstream = {"access_token": "UPSTREAM-SECRET-TOKEN", "token_type": "Bearer", "expires_in": 3600}
|
|
with pytest.raises(HTTPException) as exc:
|
|
await _exchange_for_bridge_server(server, upstream, key_hash=None, code=gateway_code)
|
|
assert exc.value.status_code == 400
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_interactive_bridge_authorize_seals_sso_user_into_state(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""On the short-circuit bridge oauth_delegate arm, authorize captures the SSO user from the UI
|
|
session cookie and seals it (and the target server) into the encrypted OAuth state, so the
|
|
callback can later mint a user-bound gateway code; it still proceeds to the upstream redirect.
|
|
The access gate runs for real against a granted resolver, so its interface stays exercised."""
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import authorize_with_server
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(auth_type=MCPAuth.oauth_delegate, client_id="admin-client", registration_url=None)
|
|
admitted = UserAPIKeyAuth(user_id="sso-user-42")
|
|
admitted.mcp_admitted_user_subject = True
|
|
captured: dict = {}
|
|
cookie: Final = _browser_session_cookie(monkeypatch, "sso-user-42")
|
|
|
|
def _capture(**kwargs):
|
|
captured.update(kwargs)
|
|
return "mocked_encrypted_state"
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler.reload_admitted_user",
|
|
new=AsyncMock(return_value=admitted),
|
|
),
|
|
patch.object(
|
|
global_mcp_server_manager,
|
|
"get_allowed_mcp_servers",
|
|
new=AsyncMock(return_value=[server.server_id]),
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.encode_state_with_base_url",
|
|
side_effect=_capture,
|
|
),
|
|
):
|
|
response = await authorize_with_server(
|
|
request=_bridge_mock_request(cookie),
|
|
mcp_server=server,
|
|
client_id="ignored",
|
|
redirect_uri="http://127.0.0.1:60108/callback",
|
|
state="s",
|
|
code_challenge="chal",
|
|
code_challenge_method="S256",
|
|
)
|
|
|
|
assert captured["litellm_user_id"] == "sso-user-42"
|
|
assert captured["mcp_server_id"] == server.server_id
|
|
assert "/sso/key/generate" not in response.headers["location"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("user_can_reach_server", [True, False])
|
|
async def test_bridge_authorize_gates_on_the_egress_server_access_resolver(
|
|
monkeypatch: pytest.MonkeyPatch, user_can_reach_server: bool
|
|
) -> None:
|
|
"""The interactive dcr_bridge oauth_delegate authorize admits the signed-in user the way MCP
|
|
egress will and refuses with an RFC 6749 access_denied redirect when that admitted subject
|
|
cannot reach the target server, instead of minting an envelope whose every tool request would
|
|
fail-closed to an empty list (#36358). A user the resolver grants proceeds upstream unchanged."""
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import authorize
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
|
|
server = _bridge_server(auth_type=MCPAuth.oauth_delegate, client_id="upstream-app", registration_url=None)
|
|
global_mcp_server_manager.registry.clear()
|
|
global_mcp_server_manager.registry[server.server_id] = server
|
|
|
|
admitted = UserAPIKeyAuth(user_id="bridge-user-1")
|
|
admitted.mcp_admitted_user_subject = True
|
|
allowed = [server.server_id] if user_can_reach_server else []
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://litellm.example.com/"
|
|
mock_request.headers = {}
|
|
mock_request.cookies = {"token": _browser_session_cookie(monkeypatch, "bridge-user-1")}
|
|
|
|
client_redirect = "http://127.0.0.1:60108/callback"
|
|
try:
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler.reload_admitted_user",
|
|
new=AsyncMock(return_value=admitted),
|
|
) as mock_reload,
|
|
patch.object(
|
|
global_mcp_server_manager,
|
|
"get_allowed_mcp_servers",
|
|
new=AsyncMock(return_value=allowed),
|
|
) as mock_allowed,
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.encrypt_value_helper",
|
|
return_value="mocked_encrypted_state",
|
|
),
|
|
):
|
|
response = await authorize(
|
|
request=mock_request,
|
|
client_id="dcr_client_id",
|
|
mcp_server_name="bridge_srv",
|
|
redirect_uri=client_redirect,
|
|
state="client-state-1",
|
|
code_challenge="a" * 43,
|
|
code_challenge_method="S256",
|
|
)
|
|
finally:
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
mock_reload.assert_awaited_once_with("bridge-user-1")
|
|
mock_allowed.assert_awaited_once_with(admitted)
|
|
location = response.headers["location"]
|
|
if user_can_reach_server:
|
|
assert response.status_code == 307
|
|
assert location.startswith("https://provider.com/oauth/authorize")
|
|
else:
|
|
assert response.status_code == 302
|
|
assert location.startswith(client_redirect)
|
|
query = parse_qs(urlparse(location).query)
|
|
assert query["error"] == ["access_denied"]
|
|
assert query["state"] == ["client-state-1"]
|
|
assert "bridge_srv" in query["error_description"][0]
|
|
assert "provider.com" not in location
|
|
assert "set-cookie" not in {k.lower() for k in response.headers}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("reload_status,expect_denial", [(401, True), (500, False), (503, False)])
|
|
async def test_bridge_authorize_reload_failure_denies_or_stays_retryable(
|
|
monkeypatch: pytest.MonkeyPatch, reload_status: int, expect_denial: bool
|
|
) -> None:
|
|
"""An unknown or deactivated signed-in user denies like a missing grant (fail closed); a DB
|
|
outage keeps its retryable 503 instead of masquerading as an access denial."""
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import authorize
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
|
|
server = _bridge_server(auth_type=MCPAuth.oauth_delegate, client_id="upstream-app", registration_url=None)
|
|
global_mcp_server_manager.registry.clear()
|
|
global_mcp_server_manager.registry[server.server_id] = server
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://litellm.example.com/"
|
|
mock_request.headers = {}
|
|
mock_request.cookies = {"token": _browser_session_cookie(monkeypatch, "bridge-user-1")}
|
|
|
|
try:
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler.reload_admitted_user",
|
|
new=AsyncMock(side_effect=HTTPException(status_code=reload_status, detail="x")),
|
|
),
|
|
):
|
|
if expect_denial:
|
|
response = await authorize(
|
|
request=mock_request,
|
|
client_id="dcr_client_id",
|
|
mcp_server_name="bridge_srv",
|
|
redirect_uri="http://127.0.0.1:60108/callback",
|
|
state="client-state-1",
|
|
code_challenge="a" * 43,
|
|
code_challenge_method="S256",
|
|
)
|
|
assert response.status_code == 302
|
|
query = parse_qs(urlparse(response.headers["location"]).query)
|
|
assert query["error"] == ["access_denied"]
|
|
else:
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await authorize(
|
|
request=mock_request,
|
|
client_id="dcr_client_id",
|
|
mcp_server_name="bridge_srv",
|
|
redirect_uri="http://127.0.0.1:60108/callback",
|
|
state="client-state-1",
|
|
code_challenge="a" * 43,
|
|
code_challenge_method="S256",
|
|
)
|
|
assert exc_info.value.status_code == reload_status
|
|
finally:
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_interactive_bridge_authorize_without_session_redirects_to_login():
|
|
"""Without a UI session there is no identity to bind, so the short-circuit bridge oauth_delegate
|
|
authorize sends the browser through litellm login instead of proceeding to the upstream."""
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import authorize_with_server
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(auth_type=MCPAuth.oauth_delegate, client_id="admin-client", registration_url=None)
|
|
response = await authorize_with_server(
|
|
request=_bridge_mock_request(),
|
|
mcp_server=server,
|
|
client_id="ignored",
|
|
redirect_uri="http://127.0.0.1:60108/callback",
|
|
state="s",
|
|
code_challenge="chal",
|
|
code_challenge_method="S256",
|
|
)
|
|
assert "/sso/key/generate" in response.headers["location"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_interactive_bridge_callback_seals_user_into_gateway_code():
|
|
"""When the OAuth state carries the captured SSO user, the callback forwards a gateway
|
|
authorization code (sealing the user and upstream code) to the client instead of the raw upstream
|
|
code, so the client's later token call can prove who signed in."""
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
callback,
|
|
is_bridge_authorization_code,
|
|
)
|
|
|
|
state_data = {
|
|
"original_state": "client-state",
|
|
"client_redirect_uri": "http://127.0.0.1:60108/cb",
|
|
"base_url": "http://127.0.0.1:60108/cb",
|
|
"litellm_user_id": "sso-user-42",
|
|
"mcp_server_id": "bridge_srv",
|
|
}
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints._resolve_encoded_oauth_state",
|
|
return_value="enc",
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.decode_state_hash",
|
|
return_value=state_data,
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints._get_validated_client_redirect_uri",
|
|
return_value="http://127.0.0.1:60108/cb",
|
|
),
|
|
patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY),
|
|
):
|
|
response = await callback(request=_bridge_mock_request(), code="REAL-UPSTREAM-CODE", state="relay")
|
|
|
|
forwarded_code = parse_qs(urlparse(response.headers["location"]).query)["code"][0]
|
|
assert is_bridge_authorization_code(forwarded_code)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_oauth_delegate_bridge_token_exchange_fails_closed_without_litellm_identity():
|
|
"""Without a resolvable litellm identity on the token request, the exchange must not mint an
|
|
identity-less envelope. It returns an RFC 6749 §5.2-shaped invalid_request (error at the top
|
|
level, not wrapped in detail) BEFORE exchanging the upstream code, so the single-use code is not
|
|
burned and the client can retry."""
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
|
upstream = {"access_token": "UPSTREAM-SECRET-TOKEN", "token_type": "Bearer", "expires_in": 3600}
|
|
captured: dict = {}
|
|
response = await _exchange_for_bridge_server(server, upstream, key_hash=None, fake_client_out=captured)
|
|
|
|
assert response.status_code == 400
|
|
assert json.loads(response.body)["error"] == "invalid_request"
|
|
# identity resolution failed first, so the upstream single-use code was never exchanged (not burned)
|
|
captured["client"].post.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bridge_envelope_too_large_upstream_token_is_502():
|
|
"""An upstream token too large to seal into the envelope is an upstream-payload condition, so the
|
|
mint surfaces a 502 (as an RFC 6749 §5.2 error body, not a raised HTTPException) rather than a 500:
|
|
build_bridge_token_response returns EnvelopeTooLarge as a value, _finish_bridge_mint returns the
|
|
"too_large" failure, and _bridge_mint_error_response maps it to a truthful status."""
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
|
upstream = {"access_token": "x" * 40000, "token_type": "Bearer", "expires_in": 3600}
|
|
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
|
|
assert response.status_code == 502
|
|
assert json.loads(response.body)["error"] == "server_error"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bridge_envelope_unrepresentable_upstream_lifetime_is_502():
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
|
upstream = {
|
|
"access_token": "UPSTREAM-SECRET-TOKEN",
|
|
"token_type": "Bearer",
|
|
"expires_in": 10**30,
|
|
}
|
|
|
|
response = await _exchange_for_bridge_server(
|
|
server,
|
|
upstream,
|
|
key_hash="hashed-litellm-key-77",
|
|
)
|
|
|
|
assert response.status_code == 502
|
|
assert json.loads(response.body) == {
|
|
"error": "server_error",
|
|
"error_description": "the upstream token response reports an unrepresentable lifetime",
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bridge_access_envelope_never_carries_upstream_refresh_token():
|
|
"""The upstream refresh token is never sealed into the ACCESS envelope, the bearer forwarded upstream
|
|
on every tool call: the opened access grant carries no refresh token even when the upstream returned
|
|
one, and the raw refresh token never appears in the access envelope. It rides only in the separate
|
|
refresh envelope returned as the response's refresh_token, encrypted, never in plaintext."""
|
|
from datetime import datetime, timezone
|
|
|
|
from litellm.proxy.mcp_server.outbound_credentials.bridge_credentials import (
|
|
envelope_keys_from_master_key,
|
|
)
|
|
from litellm.proxy.mcp_server.outbound_credentials.envelope import (
|
|
OpenedEnvelope,
|
|
open_envelope,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
|
upstream = {
|
|
"access_token": "UP",
|
|
"token_type": "Bearer",
|
|
"expires_in": 3600,
|
|
"refresh_token": "UPSTREAM-REFRESH",
|
|
}
|
|
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
|
|
|
|
body = json.loads(response.body)
|
|
assert "UPSTREAM-REFRESH" not in body["access_token"]
|
|
keys = envelope_keys_from_master_key(_BRIDGE_MASTER_KEY)
|
|
opened = open_envelope(body["access_token"], keys, datetime.now(timezone.utc))
|
|
assert isinstance(opened, OpenedEnvelope)
|
|
assert opened.grant.refresh_token is None
|
|
# the refresh token rides only in the separate, encrypted refresh envelope, never in plaintext
|
|
assert body["refresh_token"].startswith("llm_refresh_")
|
|
assert "UPSTREAM-REFRESH" not in body["refresh_token"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bridge_refresh_grant_with_non_envelope_is_invalid_grant_before_upstream():
|
|
"""A bridge oauth_delegate client only ever holds a refresh envelope, never a raw upstream refresh
|
|
token, so a refresh_token grant carrying a bare (non-envelope) value is invalid_grant, rejected in
|
|
_prepare_bridge_refresh BEFORE any upstream exchange. Rejecting before the exchange means a bad
|
|
refresh request can never consume or rotate an upstream refresh token."""
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import exchange_token_with_server
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
|
fake_http_client = MagicMock()
|
|
fake_http_client.post = AsyncMock()
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=fake_http_client,
|
|
),
|
|
patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY),
|
|
):
|
|
response = await exchange_token_with_server(
|
|
request=_bridge_mock_request(),
|
|
mcp_server=server,
|
|
grant_type="refresh_token",
|
|
code=None,
|
|
redirect_uri=None,
|
|
client_id="dcr-client-123",
|
|
client_secret=None,
|
|
code_verifier=None,
|
|
refresh_token="client-refresh-token",
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert json.loads(response.body)["error"] == "invalid_grant"
|
|
fake_http_client.post.assert_not_called()
|
|
|
|
|
|
def _mint_test_refresh_envelope(
|
|
server_id="bridge_srv",
|
|
key_hash="hashed-litellm-key-77",
|
|
upstream_refresh="UPSTREAM-REFRESH",
|
|
identity=None,
|
|
scope=None,
|
|
):
|
|
"""Mint a refresh envelope the way the producer does, for driving the refresh_token grant in tests.
|
|
Defaults to a key_hash subject; pass ``identity`` to seal a specific subject (e.g. a user_id), and
|
|
``scope`` to seal the scope to re-request on refresh."""
|
|
from datetime import datetime, timezone
|
|
|
|
from pydantic import SecretStr
|
|
|
|
from litellm.proxy.mcp_server.outbound_credentials.bridge_credentials import (
|
|
build_bridge_refresh_token_response,
|
|
envelope_keys_from_master_key,
|
|
)
|
|
from litellm.proxy.mcp_server.outbound_credentials.envelope import (
|
|
RefreshCredential,
|
|
SealedEnvelope,
|
|
key_hash_identity,
|
|
)
|
|
|
|
keys = envelope_keys_from_master_key(_BRIDGE_MASTER_KEY)
|
|
identity = identity if identity is not None else key_hash_identity(server_id=server_id, key_hash=key_hash)
|
|
sealed = build_bridge_refresh_token_response(
|
|
identity,
|
|
RefreshCredential(refresh_token=SecretStr(upstream_refresh), scope=scope),
|
|
keys,
|
|
datetime.now(timezone.utc),
|
|
)
|
|
assert isinstance(sealed, SealedEnvelope)
|
|
return sealed.token.get_secret_value()
|
|
|
|
|
|
async def _refresh_for_bridge_server(
|
|
server, refresh_envelope_value, upstream_body, revalidate_result=None, fake_client_out=None
|
|
):
|
|
"""Drive a refresh_token grant for a bridge server: the client presents ``refresh_envelope_value``,
|
|
the sealed subject re-validates to ``revalidate_result`` (``None`` when the key or user is still
|
|
active, or a failure literal like "no_active_key" when revoked/deactivated), and the upstream returns
|
|
``upstream_body``. Patching the single subject-revalidation dispatch covers both a key_hash and a
|
|
user_id refresh envelope. Returns the response; the captured client exposes the POST call so a test
|
|
can assert what refresh token was actually sent upstream."""
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import exchange_token_with_server
|
|
|
|
fake_http_response = MagicMock()
|
|
fake_http_response.json.return_value = upstream_body
|
|
fake_http_response.raise_for_status = MagicMock()
|
|
fake_http_client = MagicMock()
|
|
fake_http_client.post = AsyncMock(return_value=fake_http_response)
|
|
if fake_client_out is not None:
|
|
fake_client_out["client"] = fake_http_client
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=fake_http_client,
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.bridge_token_flow._revalidate_active_subject",
|
|
new=AsyncMock(return_value=revalidate_result),
|
|
),
|
|
patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY),
|
|
):
|
|
return await exchange_token_with_server(
|
|
request=_bridge_mock_request(),
|
|
mcp_server=server,
|
|
grant_type="refresh_token",
|
|
code=None,
|
|
redirect_uri=None,
|
|
client_id="dcr-client-123",
|
|
client_secret=None,
|
|
code_verifier=None,
|
|
refresh_token=refresh_envelope_value,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bridge_mint_returns_refresh_envelope_that_opens_to_upstream_refresh():
|
|
"""When the upstream returns a refresh token, the authorization_code mint returns a refresh envelope
|
|
alongside the access envelope. The refresh envelope is a distinct llm_refresh_ credential that opens
|
|
(under the same keys and server_id) to the upstream refresh token, so the client can renew later."""
|
|
from datetime import datetime, timezone
|
|
|
|
from litellm.proxy.mcp_server.outbound_credentials.bridge_credentials import (
|
|
envelope_keys_from_master_key,
|
|
)
|
|
from litellm.proxy.mcp_server.outbound_credentials.envelope import (
|
|
OpenedRefreshEnvelope,
|
|
open_refresh_envelope,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
|
upstream = {"access_token": "UP", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "R-UP"}
|
|
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
|
|
|
|
body = json.loads(response.body)
|
|
refresh_env = body["refresh_token"]
|
|
assert refresh_env.startswith("llm_refresh_")
|
|
keys = envelope_keys_from_master_key(_BRIDGE_MASTER_KEY)
|
|
opened = open_refresh_envelope(refresh_env, keys, datetime.now(timezone.utc))
|
|
assert isinstance(opened, OpenedRefreshEnvelope)
|
|
assert opened.identity.server_id == server.server_id
|
|
assert opened.refresh.refresh_token.get_secret_value() == "R-UP"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bridge_mint_omits_refresh_envelope_when_upstream_has_no_refresh():
|
|
"""No refresh envelope is issued when the upstream returns no refresh token, so the response carries
|
|
only the access envelope; the client re-authenticates at access expiry (nothing to renew with)."""
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
|
upstream = {"access_token": "UP", "token_type": "Bearer", "expires_in": 3600}
|
|
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
|
|
|
|
body = json.loads(response.body)
|
|
assert body["access_token"].startswith("llm_env_")
|
|
assert "refresh_token" not in body
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bridge_refresh_grant_sends_unwrapped_upstream_token_and_renews():
|
|
"""A refresh_token grant carrying a valid refresh envelope renews: the exchange unwraps the envelope
|
|
and sends the REAL upstream refresh token upstream (never the envelope), then returns a fresh access
|
|
envelope. This is the flow that lets the client renew without re-authenticating."""
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
|
refresh_env = _mint_test_refresh_envelope(server_id=server.server_id, upstream_refresh="UPSTREAM-REFRESH")
|
|
upstream = {"access_token": "NEW-ACCESS", "token_type": "Bearer", "expires_in": 3600}
|
|
captured: dict = {}
|
|
response = await _refresh_for_bridge_server(server, refresh_env, upstream, None, fake_client_out=captured)
|
|
|
|
assert response.status_code == 200
|
|
body = json.loads(response.body)
|
|
assert body["access_token"].startswith("llm_env_")
|
|
# the upstream exchange received the unwrapped upstream refresh token, never the client's envelope
|
|
sent = captured["client"].post.call_args.kwargs["data"]
|
|
assert sent["grant_type"] == "refresh_token"
|
|
assert sent["refresh_token"] == "UPSTREAM-REFRESH"
|
|
assert not sent["refresh_token"].startswith("llm_refresh_")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bridge_refresh_grant_rotates_refresh_envelope_wrapping_new_upstream_token():
|
|
"""When the upstream rotates the refresh token on renewal, the client receives a new refresh envelope
|
|
that wraps the NEW upstream refresh token, so the rotation is carried through faithfully."""
|
|
from datetime import datetime, timezone
|
|
|
|
from litellm.proxy.mcp_server.outbound_credentials.bridge_credentials import (
|
|
envelope_keys_from_master_key,
|
|
)
|
|
from litellm.proxy.mcp_server.outbound_credentials.envelope import (
|
|
OpenedRefreshEnvelope,
|
|
open_refresh_envelope,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
|
refresh_env = _mint_test_refresh_envelope(server_id=server.server_id, upstream_refresh="OLD-UP-REFRESH")
|
|
upstream = {
|
|
"access_token": "NEW-ACCESS",
|
|
"token_type": "Bearer",
|
|
"expires_in": 3600,
|
|
"refresh_token": "NEW-UP-REFRESH",
|
|
}
|
|
response = await _refresh_for_bridge_server(server, refresh_env, upstream, None)
|
|
|
|
body = json.loads(response.body)
|
|
keys = envelope_keys_from_master_key(_BRIDGE_MASTER_KEY)
|
|
opened = open_refresh_envelope(body["refresh_token"], keys, datetime.now(timezone.utc))
|
|
assert isinstance(opened, OpenedRefreshEnvelope)
|
|
assert opened.refresh.refresh_token.get_secret_value() == "NEW-UP-REFRESH"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bridge_refresh_grant_with_revoked_key_is_invalid_grant_before_upstream():
|
|
"""A valid refresh envelope whose sealed litellm key has since been revoked cannot keep refreshing:
|
|
the reload gate reports no_active_key and the refresh is invalid_grant, returned BEFORE the upstream
|
|
exchange so the upstream refresh token is never consumed. Revocation kills renewal."""
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
|
refresh_env = _mint_test_refresh_envelope(server_id=server.server_id)
|
|
captured: dict = {}
|
|
response = await _refresh_for_bridge_server(
|
|
server, refresh_env, {"access_token": "NEW"}, "no_active_key", fake_client_out=captured
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert json.loads(response.body)["error"] == "invalid_grant"
|
|
captured["client"].post.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bridge_refresh_envelope_for_another_server_is_invalid_grant():
|
|
"""A refresh envelope minted for one server cannot renew against another: the sealed server_id must
|
|
match the server the refresh targets, so a cross-server refresh envelope is invalid_grant and never
|
|
reaches the upstream exchange."""
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
|
foreign_env = _mint_test_refresh_envelope(server_id="some-other-server")
|
|
captured: dict = {}
|
|
response = await _refresh_for_bridge_server(
|
|
server, foreign_env, {"access_token": "NEW"}, None, fake_client_out=captured
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert json.loads(response.body)["error"] == "invalid_grant"
|
|
captured["client"].post.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bridge_refresh_grant_renews_a_user_subject_envelope():
|
|
"""The interactive SSO client mints a user_id-subject envelope, so its refresh envelope carries a
|
|
user subject too. Renewing it re-validates the user (still active here), unwraps the upstream refresh
|
|
token, and returns a fresh access envelope that opens back to the same user_id subject; the upstream
|
|
exchange received the real upstream refresh token, not the client's envelope."""
|
|
from datetime import datetime, timezone
|
|
|
|
from litellm.proxy.mcp_server.outbound_credentials.bridge_credentials import (
|
|
BridgeEnvelopeAdmitted,
|
|
envelope_keys_from_master_key,
|
|
resolve_bridge_envelope,
|
|
)
|
|
from litellm.proxy.mcp_server.outbound_credentials.envelope import user_identity
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
|
user_env = _mint_test_refresh_envelope(
|
|
identity=user_identity(server_id=server.server_id, user_id="sso-user-42"), upstream_refresh="UP-REFRESH-USER"
|
|
)
|
|
upstream = {"access_token": "NEW-ACCESS", "token_type": "Bearer", "expires_in": 3600}
|
|
captured: dict = {}
|
|
response = await _refresh_for_bridge_server(server, user_env, upstream, None, fake_client_out=captured)
|
|
|
|
assert response.status_code == 200
|
|
body = json.loads(response.body)
|
|
keys = envelope_keys_from_master_key(_BRIDGE_MASTER_KEY)
|
|
opened = resolve_bridge_envelope(body["access_token"], keys, datetime.now(timezone.utc), server.server_id)
|
|
assert isinstance(opened, BridgeEnvelopeAdmitted)
|
|
assert opened.identity.subject_type == "user_id"
|
|
assert opened.identity.subject == "sso-user-42"
|
|
assert captured["client"].post.call_args.kwargs["data"]["refresh_token"] == "UP-REFRESH-USER"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bridge_refresh_re_requests_the_sealed_scope_when_client_omits_it():
|
|
"""A DCR/MCP client omits scope on the refresh request, so the gateway must re-request the scope sealed
|
|
at mint; dropping it lets a stricter upstream narrow the renewed token. The upstream POST must carry
|
|
the sealed scope even though the client sent none. Regression for the dropped sealed refresh scope."""
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
|
refresh_env = _mint_test_refresh_envelope(
|
|
server_id=server.server_id, upstream_refresh="UP-REFRESH", scope="read:tools write:tools"
|
|
)
|
|
captured: dict = {}
|
|
response = await _refresh_for_bridge_server(
|
|
server,
|
|
refresh_env,
|
|
{"access_token": "NEW-ACCESS", "token_type": "Bearer", "expires_in": 3600},
|
|
None,
|
|
fake_client_out=captured,
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert captured["client"].post.call_args.kwargs["data"]["scope"] == "read:tools write:tools"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bridge_refresh_re_seals_scope_when_upstream_omits_it_so_the_chain_keeps_it():
|
|
"""RFC 6749 5.1 lets an upstream omit scope in a refresh response when it is unchanged. The re-minted
|
|
refresh envelope must still seal the scope that was requested, otherwise the NEXT refresh loses it and
|
|
a stricter upstream could narrow the token. The returned refresh envelope carries the scope even though
|
|
the upstream response had none, and a second refresh off it still re-requests the scope."""
|
|
from datetime import datetime, timezone
|
|
|
|
from litellm.proxy.mcp_server.outbound_credentials.bridge_credentials import (
|
|
envelope_keys_from_master_key,
|
|
)
|
|
from litellm.proxy.mcp_server.outbound_credentials.envelope import (
|
|
OpenedRefreshEnvelope,
|
|
open_refresh_envelope,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
|
refresh_env = _mint_test_refresh_envelope(
|
|
server_id=server.server_id, upstream_refresh="UP-1", scope="mcp:read mcp:write"
|
|
)
|
|
# the upstream rotates the refresh token but OMITS scope (valid when unchanged)
|
|
upstream_no_scope = {"access_token": "NEW", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "UP-2"}
|
|
|
|
captured: dict = {}
|
|
r1 = await _refresh_for_bridge_server(server, refresh_env, upstream_no_scope, None, fake_client_out=captured)
|
|
assert r1.status_code == 200
|
|
assert captured["client"].post.call_args.kwargs["data"]["scope"] == "mcp:read mcp:write"
|
|
|
|
keys = envelope_keys_from_master_key(_BRIDGE_MASTER_KEY)
|
|
new_env = json.loads(r1.body)["refresh_token"]
|
|
opened = open_refresh_envelope(new_env, keys, datetime.now(timezone.utc))
|
|
assert isinstance(opened, OpenedRefreshEnvelope)
|
|
assert opened.refresh.scope == "mcp:read mcp:write"
|
|
|
|
captured2: dict = {}
|
|
r2 = await _refresh_for_bridge_server(server, new_env, upstream_no_scope, None, fake_client_out=captured2)
|
|
assert r2.status_code == 200
|
|
assert captured2["client"].post.call_args.kwargs["data"]["scope"] == "mcp:read mcp:write"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bridge_refresh_grant_with_deactivated_user_is_invalid_grant_before_upstream():
|
|
"""A user_id-subject refresh envelope whose user has since been deactivated (SCIM offboarding, or
|
|
the user no longer exists) cannot keep refreshing: subject re-validation reports no_active_key and
|
|
the refresh is invalid_grant, returned BEFORE the upstream exchange. Revocation kills renewal for the
|
|
user subject exactly as it does for the key subject."""
|
|
from litellm.proxy.mcp_server.outbound_credentials.envelope import user_identity
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
|
user_env = _mint_test_refresh_envelope(identity=user_identity(server_id=server.server_id, user_id="gone-user"))
|
|
captured: dict = {}
|
|
response = await _refresh_for_bridge_server(
|
|
server, user_env, {"access_token": "NEW"}, "no_active_key", fake_client_out=captured
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert json.loads(response.body)["error"] == "invalid_grant"
|
|
captured["client"].post.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_revalidate_active_subject_dispatches_on_subject_type():
|
|
"""Subject re-validation routes a key_hash envelope to the key reload and a user_id envelope to the
|
|
user reload, so revocation gates renewal for either identity source through one dispatch point."""
|
|
from litellm.proxy.mcp_server.bridge_token_flow import (
|
|
_ResolvedKey,
|
|
_revalidate_active_subject,
|
|
)
|
|
from litellm.proxy.mcp_server.outbound_credentials.envelope import key_hash_identity, user_identity
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.bridge_token_flow._reload_active_key_by_hash",
|
|
new=AsyncMock(return_value=_ResolvedKey(key_hash="kh", key=MagicMock())),
|
|
) as key_reload,
|
|
patch(
|
|
"litellm.proxy.mcp_server.bridge_token_flow.reload_active_user_by_id",
|
|
new=AsyncMock(return_value=None),
|
|
) as user_reload,
|
|
):
|
|
assert await _revalidate_active_subject(key_hash_identity(server_id="s", key_hash="kh")) is None
|
|
key_reload.assert_awaited_once_with("kh")
|
|
user_reload.assert_not_awaited()
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.bridge_token_flow._reload_active_key_by_hash",
|
|
new=AsyncMock(),
|
|
) as key_reload2,
|
|
patch(
|
|
"litellm.proxy.mcp_server.bridge_token_flow.reload_active_user_by_id",
|
|
new=AsyncMock(return_value="no_active_key"),
|
|
) as user_reload2,
|
|
):
|
|
assert await _revalidate_active_subject(user_identity(server_id="s", user_id="u42")) == "no_active_key"
|
|
user_reload2.assert_awaited_once_with("u42")
|
|
key_reload2.assert_not_awaited()
|
|
|
|
|
|
def test_upstream_refresh_credential_expired_refresh_token_is_not_sealed():
|
|
"""An upstream that reports its refresh token already elapsed (refresh_expires_in non-positive) must
|
|
not be sealed: _upstream_refresh_credential returns None so the exchange degrades to an access-only
|
|
response, mirroring how the access grant refuses an already-elapsed access token rather than capping a
|
|
dead token to the full refresh TTL. A live or unspecified lifetime still yields a credential."""
|
|
from litellm.proxy.mcp_server.bridge_token_flow import _upstream_refresh_credential
|
|
|
|
assert _upstream_refresh_credential({"access_token": "A", "refresh_token": "R", "refresh_expires_in": 0}) is None
|
|
assert _upstream_refresh_credential({"refresh_token": "R", "refresh_expires_in": -5}) is None
|
|
live = _upstream_refresh_credential({"refresh_token": "R", "refresh_expires_in": 1800})
|
|
assert live is not None and live.expires_in == 1800
|
|
unspecified = _upstream_refresh_credential({"refresh_token": "R"})
|
|
assert unspecified is not None and unspecified.expires_in is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bridge_refresh_upstream_invalid_grant_maps_to_invalid_grant():
|
|
"""When the sealed upstream refresh token has been revoked or expired at the IdP, the upstream returns
|
|
400 invalid_grant. The bridge refresh path maps that to an RFC 6749 invalid_grant response so the OAuth
|
|
client re-runs authorization_code, rather than surfacing the opaque upstream error it cannot act on."""
|
|
import httpx
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import exchange_token_with_server
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
|
refresh_env = _mint_test_refresh_envelope(server_id=server.server_id, upstream_refresh="LIVE-ENVELOPE-REFRESH")
|
|
|
|
error_response = MagicMock()
|
|
error_response.status_code = 400
|
|
error_response.text = '{"error": "invalid_grant", "error_description": "refresh token expired"}'
|
|
error_response.json = MagicMock(
|
|
return_value={"error": "invalid_grant", "error_description": "refresh token expired"}
|
|
)
|
|
error_response.raise_for_status = MagicMock(
|
|
side_effect=httpx.HTTPStatusError("bad", request=MagicMock(), response=error_response)
|
|
)
|
|
fake_http_client = MagicMock()
|
|
fake_http_client.post = AsyncMock(return_value=error_response)
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=fake_http_client,
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.bridge_token_flow._revalidate_active_subject",
|
|
new=AsyncMock(return_value=None),
|
|
),
|
|
patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY),
|
|
):
|
|
response = await exchange_token_with_server(
|
|
request=_bridge_mock_request(),
|
|
mcp_server=server,
|
|
grant_type="refresh_token",
|
|
code=None,
|
|
redirect_uri=None,
|
|
client_id="dcr-client-123",
|
|
client_secret=None,
|
|
code_verifier=None,
|
|
refresh_token=refresh_env,
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert json.loads(response.body)["error"] == "invalid_grant"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bridge_refresh_upstream_error_detection_parses_json_not_substring():
|
|
"""The upstream invalid_grant detection reads the classified RFC 6749 5.2 error code, not a substring
|
|
of the body. An upstream error whose code is not invalid_grant (here invalid_client, with the string
|
|
invalid_grant only inside error_description) must NOT be mistaken for a dead refresh token: it renders
|
|
as the classified upstream rejection rather than triggering a spurious authorization_code re-run."""
|
|
import httpx
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import exchange_token_with_server
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
|
refresh_env = _mint_test_refresh_envelope(server_id=server.server_id, upstream_refresh="UP")
|
|
|
|
error_response = MagicMock()
|
|
error_response.status_code = 400
|
|
error_response.text = '{"error": "invalid_client", "error_description": "this is not an invalid_grant problem"}'
|
|
error_response.json = MagicMock(
|
|
return_value={"error": "invalid_client", "error_description": "this is not an invalid_grant problem"}
|
|
)
|
|
error_response.raise_for_status = MagicMock(
|
|
side_effect=httpx.HTTPStatusError("bad", request=MagicMock(), response=error_response)
|
|
)
|
|
fake_http_client = MagicMock()
|
|
fake_http_client.post = AsyncMock(return_value=error_response)
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=fake_http_client,
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.bridge_token_flow._revalidate_active_subject",
|
|
new=AsyncMock(return_value=None),
|
|
),
|
|
patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY),
|
|
):
|
|
response = await exchange_token_with_server(
|
|
request=_bridge_mock_request(),
|
|
mcp_server=server,
|
|
grant_type="refresh_token",
|
|
code=None,
|
|
redirect_uri=None,
|
|
client_id="dcr-client-123",
|
|
client_secret=None,
|
|
code_verifier=None,
|
|
refresh_token=refresh_env,
|
|
)
|
|
|
|
assert response.status_code == 401
|
|
body = json.loads(response.body)
|
|
assert body["error"] == "invalid_client"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_revalidate_key_subject_revoked_when_owner_scim_deactivated(proxy_globals):
|
|
"""A key_hash refresh envelope whose key is still active but whose OWNING user was SCIM-deactivated must
|
|
fail closed to no_active_key, mirroring how admission's _reject_if_admitted_owner_scim_deactivated
|
|
revokes an offboarded owner's key. Without this, an offboarded user keeps renewing a live key."""
|
|
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
|
from litellm.proxy.mcp_server.bridge_token_flow import _ResolvedKey, _revalidate_active_subject
|
|
from litellm.proxy.mcp_server.outbound_credentials.envelope import key_hash_identity
|
|
|
|
proxy_globals.user_api_key_cache = UserApiKeyCache()
|
|
proxy_globals.prisma_client = object()
|
|
|
|
resolved = _ResolvedKey(key_hash="kh", key=MagicMock(user_id="offboarded-owner"))
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.bridge_token_flow._reload_active_key_by_hash",
|
|
new=AsyncMock(return_value=resolved),
|
|
),
|
|
patch(
|
|
"litellm.proxy.auth.auth_checks.get_user_object",
|
|
new=AsyncMock(return_value=MagicMock(metadata={"scim_active": False})),
|
|
),
|
|
):
|
|
result = await _revalidate_active_subject(key_hash_identity(server_id="s", key_hash="kh"))
|
|
|
|
assert result == "no_active_key"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_revalidate_key_subject_active_owner_renews_and_missing_owner_fails_open(proxy_globals):
|
|
"""The key-owner SCIM gate blocks only an explicit scim_active False: an active owner renews (None), and
|
|
a missing owner (get_user_object's wrapped ValueError) fails OPEN, since a key may outlive its owner
|
|
record and a transient blip must not revoke a live key."""
|
|
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
|
from litellm.proxy.mcp_server.bridge_token_flow import _ResolvedKey, _revalidate_active_subject
|
|
from litellm.proxy.mcp_server.outbound_credentials.envelope import key_hash_identity
|
|
|
|
proxy_globals.user_api_key_cache = UserApiKeyCache()
|
|
proxy_globals.prisma_client = object()
|
|
resolved = _ResolvedKey(key_hash="kh", key=MagicMock(user_id="live-owner"))
|
|
identity = key_hash_identity(server_id="s", key_hash="kh")
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.bridge_token_flow._reload_active_key_by_hash",
|
|
new=AsyncMock(return_value=resolved),
|
|
),
|
|
patch(
|
|
"litellm.proxy.auth.auth_checks.get_user_object",
|
|
new=AsyncMock(return_value=MagicMock(metadata={"scim_active": True})),
|
|
),
|
|
):
|
|
assert await _revalidate_active_subject(identity) is None
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.bridge_token_flow._reload_active_key_by_hash",
|
|
new=AsyncMock(return_value=resolved),
|
|
),
|
|
patch(
|
|
"litellm.proxy.auth.auth_checks.get_user_object",
|
|
new=AsyncMock(side_effect=_wrapped_user_lookup_error(Exception())),
|
|
),
|
|
):
|
|
assert await _revalidate_active_subject(identity) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bridge_mint_fails_closed_before_upstream_when_master_key_unset():
|
|
"""master_key is validated BEFORE the upstream exchange (in _prepare_bridge_mint), so a
|
|
misconfigured gateway returns a 500 server_error without consuming the single-use code, avoiding
|
|
the burn-then-fail the pre-exchange phase exists to prevent. The failure is returned as an RFC 6749
|
|
error body, not raised."""
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import exchange_token_with_server
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
|
fake_http_client = MagicMock()
|
|
fake_http_client.post = AsyncMock()
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=fake_http_client,
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.bridge_token_flow._resolve_active_litellm_key",
|
|
new=AsyncMock(return_value="no_active_key"),
|
|
),
|
|
patch("litellm.proxy.proxy_server.master_key", None),
|
|
):
|
|
response = await exchange_token_with_server(
|
|
request=_bridge_mock_request(),
|
|
mcp_server=server,
|
|
grant_type="authorization_code",
|
|
code="auth-code",
|
|
redirect_uri="https://claude.ai/api/mcp/auth_callback",
|
|
client_id="dcr-client-123",
|
|
client_secret=None,
|
|
code_verifier="verifier",
|
|
)
|
|
|
|
assert response.status_code == 500
|
|
assert json.loads(response.body)["error"] == "server_error"
|
|
fake_http_client.post.assert_not_called()
|
|
|
|
|
|
async def _prepare_only_bridge_exchange(resolver_result):
|
|
"""Drive exchange_token_with_server for a bridge oauth_delegate authorization_code request with the
|
|
identity resolver stubbed to a given tagged result, returning (response, post_mock) so a test can
|
|
assert the mapped status and that the single-use code was never exchanged."""
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import exchange_token_with_server
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
|
fake_http_client = MagicMock()
|
|
fake_http_client.post = AsyncMock()
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=fake_http_client,
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.bridge_token_flow._resolve_active_litellm_key",
|
|
new=AsyncMock(return_value=resolver_result),
|
|
),
|
|
patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY),
|
|
):
|
|
response = await exchange_token_with_server(
|
|
request=_bridge_mock_request(),
|
|
mcp_server=server,
|
|
grant_type="authorization_code",
|
|
code="auth-code",
|
|
redirect_uri="https://claude.ai/api/mcp/auth_callback",
|
|
client_id="dcr-client-123",
|
|
client_secret=None,
|
|
code_verifier="verifier",
|
|
)
|
|
return response, fake_http_client.post
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bridge_mint_db_outage_is_503_before_upstream():
|
|
"""A DB outage while resolving identity is a retryable gateway failure, so the mint returns 503
|
|
temporarily_unavailable WITHOUT consuming the single-use code, matching how admission statuses the
|
|
same outage on the egress side. Collapsing every resolution failure to None used to blame the
|
|
client with 400 invalid_request for an infrastructure problem."""
|
|
response, post = await _prepare_only_bridge_exchange("unavailable")
|
|
assert response.status_code == 503
|
|
assert json.loads(response.body)["error"] == "temporarily_unavailable"
|
|
assert "retry shortly" in json.loads(response.body)["error_description"]
|
|
post.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bridge_mint_permanent_db_fault_is_503_without_retry_advice():
|
|
"""A query engine fault that never heals is still a 503 (the gateway is at fault, not the client), but
|
|
the description must not tell the client the database is temporarily unreachable and to retry: that
|
|
sends an operator to wait out an outage that is not one. The code stays temporarily_unavailable, the
|
|
only RFC 6749 error a client treats as a server-side 503."""
|
|
response, post = await _prepare_only_bridge_exchange("faulted")
|
|
assert response.status_code == 503
|
|
body = json.loads(response.body)
|
|
assert body["error"] == "temporarily_unavailable"
|
|
assert "temporarily unreachable" not in body["error_description"]
|
|
assert "retry shortly" not in body["error_description"]
|
|
assert "not a transient outage" in body["error_description"]
|
|
post.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bridge_mint_unresolvable_identity_is_500_before_upstream():
|
|
"""An unresolvable identity (no DB connection, or an unexpected resolution error) is a gateway
|
|
fault, so the mint returns 500 server_error before the exchange, a status distinct from both the
|
|
caller's 400 and the transient 503, matching admission's 500-vs-503 split for the same conditions."""
|
|
response, post = await _prepare_only_bridge_exchange("unresolvable")
|
|
assert response.status_code == 500
|
|
assert json.loads(response.body)["error"] == "server_error"
|
|
post.assert_not_called()
|
|
|
|
|
|
async def _exchange_for_bridge_server_with_jwt(jwt_auth_result, upstream_body=None):
|
|
"""Drive exchange_token_with_server for a bridge oauth_delegate authorization_code request whose
|
|
presented credential is JWT-shaped, with _resolve_jwt_auth stubbed to a given result. Returns
|
|
(response, post_mock) so a test can assert the minted envelope's sealed identity or the mapped
|
|
error status."""
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import exchange_token_with_server
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
request = _bridge_mock_request()
|
|
request.headers = {"x-litellm-api-key": "aaa.bbb.ccc"}
|
|
fake_http_response = MagicMock()
|
|
fake_http_response.json.return_value = upstream_body or {
|
|
"access_token": "UP",
|
|
"token_type": "Bearer",
|
|
"expires_in": 3600,
|
|
}
|
|
fake_http_response.raise_for_status = MagicMock()
|
|
fake_http_client = MagicMock()
|
|
fake_http_client.post = AsyncMock(return_value=fake_http_response)
|
|
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=fake_http_client,
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.bridge_token_flow._resolve_jwt_auth",
|
|
new=AsyncMock(return_value=jwt_auth_result),
|
|
),
|
|
patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY),
|
|
):
|
|
response = await exchange_token_with_server(
|
|
request=request,
|
|
mcp_server=server,
|
|
grant_type="authorization_code",
|
|
code="auth-code",
|
|
redirect_uri="https://claude.ai/api/mcp/auth_callback",
|
|
client_id="dcr-client-123",
|
|
client_secret=None,
|
|
code_verifier="verifier",
|
|
)
|
|
return response, fake_http_client.post
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bridge_mint_unmapped_jwt_is_rejected_before_upstream():
|
|
from litellm.proxy.auth.handle_jwt import JWTIdentity
|
|
|
|
response, post = await _exchange_for_bridge_server_with_jwt(
|
|
JWTIdentity(user_id="jwt-user-5", user_object=None, agent_id=None)
|
|
)
|
|
assert response.status_code == 400
|
|
assert json.loads(response.body)["error"] == "invalid_request"
|
|
post.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bridge_mint_jwt_mapped_to_virtual_key_seals_key_hash_subject():
|
|
from datetime import datetime, timezone
|
|
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.mcp_server.outbound_credentials.bridge_credentials import (
|
|
BridgeEnvelopeAdmitted,
|
|
envelope_keys_from_master_key,
|
|
resolve_bridge_envelope,
|
|
)
|
|
|
|
response, _post = await _exchange_for_bridge_server_with_jwt(
|
|
UserAPIKeyAuth(token="mapped-key-hash-99", user_id="mapped-user")
|
|
)
|
|
assert response.status_code == 200
|
|
token = json.loads(response.body)["access_token"]
|
|
keys = envelope_keys_from_master_key(_BRIDGE_MASTER_KEY)
|
|
opened = resolve_bridge_envelope(token, keys, datetime.now(timezone.utc), "bridge_srv")
|
|
assert isinstance(opened, BridgeEnvelopeAdmitted)
|
|
assert opened.identity.subject_type == "key_hash"
|
|
assert opened.identity.subject == "mapped-key-hash-99"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("jwt_claims", [{"client_id": "allowed"}, {"client_id": "denied"}, {}])
|
|
async def test_bridge_mint_jwt_cannot_drop_signed_client_policy(jwt_claims):
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
|
|
settings = {
|
|
"mcp_allowed_clients": [{"alias": "Allowed", "value": "allowed"}],
|
|
"mcp_client_id_header": "x-client-id",
|
|
"litellm_jwtauth": {"mcp_client_id_jwt_field": "client_id"},
|
|
}
|
|
with patch("litellm.proxy.proxy_server.general_settings", settings):
|
|
response, post = await _exchange_for_bridge_server_with_jwt(
|
|
UserAPIKeyAuth(token="mapped-key-hash-99", jwt_claims=jwt_claims)
|
|
)
|
|
assert response.status_code == 400
|
|
assert json.loads(response.body)["error"] == "invalid_request"
|
|
assert "signed client identity" in json.loads(response.body)["error_description"]
|
|
post.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"settings",
|
|
[
|
|
{},
|
|
{"litellm_jwtauth": {"mcp_client_id_jwt_field": "client_id"}},
|
|
{
|
|
"mcp_allowed_clients": [{"alias": "Allowed", "value": "allowed"}],
|
|
"mcp_client_id_header": "x-client-id",
|
|
},
|
|
],
|
|
)
|
|
async def test_bridge_mint_mapped_jwt_without_signed_client_policy(settings):
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
|
|
with patch("litellm.proxy.proxy_server.general_settings", settings):
|
|
response, post = await _exchange_for_bridge_server_with_jwt(
|
|
UserAPIKeyAuth(token="mapped-key-hash-99", jwt_claims={"client_id": "allowed"})
|
|
)
|
|
assert response.status_code == 200
|
|
assert json.loads(response.body)["access_token"].startswith("llm_env_")
|
|
post.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bridge_mint_jwt_with_no_resolved_identity_is_400_before_upstream():
|
|
"""A JWT that resolves to nothing (or to an identity with no user_id) cannot back an envelope:
|
|
the mint returns 400 invalid_request WITHOUT consuming the single-use code upstream, matching
|
|
the no-credential path rather than hashing the raw JWT string."""
|
|
response, post = await _exchange_for_bridge_server_with_jwt(None)
|
|
assert response.status_code == 400
|
|
assert json.loads(response.body)["error"] == "invalid_request"
|
|
post.assert_not_called()
|
|
|
|
from litellm.proxy.auth.handle_jwt import JWTIdentity
|
|
|
|
response, post = await _exchange_for_bridge_server_with_jwt(
|
|
JWTIdentity(user_id=None, user_object=None, agent_id=None)
|
|
)
|
|
assert response.status_code == 400
|
|
assert json.loads(response.body)["error"] == "invalid_request"
|
|
post.assert_not_called()
|
|
|
|
|
|
def _jwt_auth_patches(mapped_key):
|
|
from contextlib import ExitStack
|
|
|
|
from litellm.proxy._types import LiteLLM_JWTAuth
|
|
from litellm.proxy.auth.auth_checks import jwt_key_mapping_cache_key
|
|
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
|
|
|
cache = UserApiKeyCache()
|
|
cache.set_cache(key=jwt_key_mapping_cache_key("sub", "mapped-client"), value=mapped_key.token)
|
|
cache.set_cache(key=mapped_key.token, value=mapped_key)
|
|
handler = MagicMock()
|
|
handler.litellm_jwtauth = LiteLLM_JWTAuth(virtual_key_claim_field="sub")
|
|
handler.auth_jwt = AsyncMock(return_value={"sub": "mapped-client"})
|
|
stack = ExitStack()
|
|
stack.enter_context(patch("litellm.proxy.proxy_server.general_settings", {"enable_jwt_auth": True}))
|
|
stack.enter_context(patch("litellm.proxy.proxy_server.premium_user", True))
|
|
stack.enter_context(patch("litellm.proxy.proxy_server.prisma_client", object()))
|
|
stack.enter_context(patch("litellm.proxy.proxy_server.jwt_handler", handler))
|
|
stack.enter_context(patch("litellm.proxy.proxy_server.user_api_key_cache", cache))
|
|
stack.enter_context(
|
|
patch(
|
|
"litellm.proxy.mcp_server.bridge_token_flow._key_owner_scim_deactivated",
|
|
new=AsyncMock(return_value=False),
|
|
)
|
|
)
|
|
return stack
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_jwt_mapped_to_service_account_key_without_user_id_resolves():
|
|
"""A JWT mapped to a team or service-account virtual key (no user_id) is still an active
|
|
credential: _resolve_jwt_auth returns the mapped key, and the mint seals a key_hash-subject
|
|
envelope rather than 400ing with no_identity."""
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.mcp_server import bridge_token_flow
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
mapped_key = UserAPIKeyAuth(user_id=None, token="svc-key-hash-1")
|
|
request = _bridge_mock_request()
|
|
request.headers = {"x-litellm-api-key": "aaa.bbb.ccc"}
|
|
with (
|
|
_jwt_auth_patches(mapped_key),
|
|
patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY),
|
|
):
|
|
resolved = await bridge_token_flow._resolve_jwt_auth(request, "aaa.bbb.ccc", None)
|
|
assert isinstance(resolved, UserAPIKeyAuth)
|
|
assert resolved.token == mapped_key.token
|
|
assert resolved.api_key is None
|
|
assert resolved.user_id is None
|
|
|
|
mint = await bridge_token_flow._prepare_bridge_mint(
|
|
request=request,
|
|
mcp_server=_bridge_server(auth_type=MCPAuth.oauth_delegate),
|
|
)
|
|
assert isinstance(mint, bridge_token_flow._BridgeMintReady)
|
|
assert mint.identity.subject_type == "key_hash"
|
|
assert mint.identity.subject == "svc-key-hash-1"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_jwt_mapped_to_blocked_key_is_rejected():
|
|
"""The relaxed gate is still active-state gated: a JWT mapped to a blocked virtual key resolves
|
|
to None, so the mint cannot seal an envelope under it."""
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.mcp_server import bridge_token_flow
|
|
|
|
mapped_key = UserAPIKeyAuth(user_id=None, token="blocked-key-hash-1", blocked=True)
|
|
request = _bridge_mock_request()
|
|
request.headers = {"x-litellm-api-key": "aaa.bbb.ccc"}
|
|
with (
|
|
_jwt_auth_patches(mapped_key),
|
|
patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY),
|
|
):
|
|
resolved = await bridge_token_flow._resolve_jwt_auth(request, "aaa.bbb.ccc", None)
|
|
assert resolved is None
|
|
|
|
mint = await bridge_token_flow._prepare_bridge_mint(
|
|
request=request,
|
|
mcp_server=_bridge_server(auth_type=MCPAuth.oauth_delegate),
|
|
)
|
|
assert mint == "no_identity"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_master_key_at_token_endpoint_mints_key_hash_envelope():
|
|
"""The master key has no row in LiteLLM_VerificationTokenTable, but it is the proxy's root
|
|
credential: presented at the bridge /token endpoint it must mint a key_hash-subject envelope
|
|
(sealed under hash_token(master_key)) even with no database connection at all. A presented key
|
|
that is NOT the master key still hits the unresolvable gate when prisma is down, unchanged."""
|
|
from litellm.proxy._types import hash_token
|
|
from litellm.proxy.mcp_server import bridge_token_flow
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
master = "sk-test-master-key-mint-0000"
|
|
request = _bridge_mock_request()
|
|
request.headers = {"x-litellm-api-key": master}
|
|
with (
|
|
patch("litellm.proxy.proxy_server.master_key", master),
|
|
patch("litellm.proxy.proxy_server.prisma_client", None),
|
|
):
|
|
mint = await bridge_token_flow._prepare_bridge_mint(
|
|
request=request,
|
|
mcp_server=_bridge_server(auth_type=MCPAuth.oauth_delegate),
|
|
)
|
|
assert isinstance(mint, bridge_token_flow._BridgeMintReady)
|
|
assert mint.identity.subject_type == "key_hash"
|
|
assert mint.identity.subject == hash_token(master)
|
|
|
|
other = _bridge_mock_request()
|
|
other.headers = {"x-litellm-api-key": "sk-not-the-master-key"}
|
|
with (
|
|
patch("litellm.proxy.proxy_server.master_key", master),
|
|
patch("litellm.proxy.proxy_server.prisma_client", None),
|
|
):
|
|
mint = await bridge_token_flow._prepare_bridge_mint(
|
|
request=other,
|
|
mcp_server=_bridge_server(auth_type=MCPAuth.oauth_delegate),
|
|
)
|
|
assert mint == "identity_unresolvable"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bridge_mint_upstream_expired_lifetime_is_502():
|
|
"""An upstream token response reporting an already-elapsed lifetime (a parseable non-positive
|
|
expires_in) is rejected with 502 rather than sealed into an hour-long envelope around a dead
|
|
bearer. Regression for expires_in<=0 silently falling through to the 1h cap."""
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
|
upstream = {"access_token": "UP", "token_type": "Bearer", "expires_in": 0}
|
|
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
|
|
assert response.status_code == 502
|
|
assert json.loads(response.body)["error"] == "server_error"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bridge_mint_positive_sub_second_lifetime_mints_not_502():
|
|
"""A positive fractional expires_in in (0, 1) is a live token, not an elapsed one, so it mints a
|
|
(1s-floored) envelope rather than being truncated to 0 and rejected with 502 after the single-use
|
|
code was already consumed. Regression for classifying a sub-second remaining lifetime as expired."""
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
|
upstream = {"access_token": "UP", "token_type": "Bearer", "expires_in": 0.5}
|
|
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
|
|
assert response.status_code == 200
|
|
body = json.loads(response.body)
|
|
assert body["access_token"].startswith("llm_env_")
|
|
assert body["expires_in"] >= 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bridge_mint_unknown_lifetime_is_capped_not_rejected():
|
|
"""An absent or unparseable expires_in leaves the lifetime unknown, which the envelope caps (never
|
|
inventing a longer life than the upstream stated); it is NOT rejected. Only an explicitly-dead
|
|
lifetime fails, so a metadata glitch on an otherwise-valid token still mints a bounded envelope."""
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
|
upstream = {"access_token": "UP", "token_type": "Bearer", "expires_in": "not-a-number"}
|
|
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
|
|
assert response.status_code == 200
|
|
body = json.loads(response.body)
|
|
assert body["access_token"].startswith("llm_env_")
|
|
assert 0 < body["expires_in"] <= 3600
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bridge_reported_expires_in_does_not_overstate_jwt_exp():
|
|
"""The reported expires_in is derived from the envelope JWT's second-truncated exp (rounding the
|
|
elapsed portion up), so the client is never told the bearer lives past the point admission expires
|
|
it. Regression for the sub-second overstatement of the raw (expires_at - now) delta."""
|
|
import time
|
|
|
|
import jwt as _jwt
|
|
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
|
upstream = {"access_token": "UP", "token_type": "Bearer", "expires_in": 300}
|
|
before = int(time.time())
|
|
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
|
|
body = json.loads(response.body)
|
|
claims = _jwt.decode(body["access_token"].removeprefix("llm_env_"), options={"verify_signature": False})
|
|
# projecting the reported lifetime from a time no later than the mint must not exceed the JWT exp
|
|
assert before + body["expires_in"] <= claims["exp"]
|
|
|
|
|
|
def test_bridge_reported_expires_in_can_be_zero_at_jwt_exp_boundary():
|
|
from datetime import datetime, timezone
|
|
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
_BridgeMintReady,
|
|
finish_bridge_mint,
|
|
)
|
|
from litellm.proxy.mcp_server.outbound_credentials.bridge_credentials import (
|
|
envelope_keys_from_master_key,
|
|
)
|
|
from litellm.proxy.mcp_server.outbound_credentials.envelope import key_hash_identity
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
ready = _BridgeMintReady(
|
|
identity=key_hash_identity(server_id="bridge_srv", key_hash="hashed-litellm-key-77"),
|
|
keys=envelope_keys_from_master_key(_BRIDGE_MASTER_KEY),
|
|
)
|
|
response = finish_bridge_mint(
|
|
ready=ready,
|
|
mcp_server=_bridge_server(auth_type=MCPAuth.oauth_delegate),
|
|
token_response={"access_token": "UP", "expires_in": 1},
|
|
now=datetime.fromtimestamp(100.25, tz=timezone.utc),
|
|
)
|
|
|
|
assert isinstance(response, JSONResponse)
|
|
assert json.loads(response.body)["expires_in"] == 0
|
|
|
|
|
|
def test_classify_upstream_lifetime():
|
|
"""expires_in from an IdP may be an int, a float (3600.0), or a numeric string ("3600"); each
|
|
coerces to a positive number of seconds. Absent or unparseable input (bool, non-numeric, NaN/inf,
|
|
oversized) is "unspecified" so the envelope caps it, while a parseable non-positive value is
|
|
"expired": the upstream reporting an already-dead token, which the mint must reject rather than
|
|
silently give the 1h cap."""
|
|
from litellm.proxy.mcp_server.bridge_token_flow import _classify_upstream_lifetime
|
|
|
|
assert _classify_upstream_lifetime(300) == 300
|
|
assert _classify_upstream_lifetime(300.0) == 300
|
|
assert _classify_upstream_lifetime("300") == 300
|
|
assert _classify_upstream_lifetime(" 300 ") == 300
|
|
# explicit, parseable, non-positive -> the upstream says the token is already dead
|
|
assert _classify_upstream_lifetime(0) == "expired"
|
|
assert _classify_upstream_lifetime(-5) == "expired"
|
|
assert _classify_upstream_lifetime(-0.5) == "expired"
|
|
# a positive sub-second lifetime is alive, not elapsed; it clamps up to the envelope's 1s floor
|
|
# rather than truncating to 0 and being misread as expired
|
|
assert _classify_upstream_lifetime(0.5) == 1
|
|
assert _classify_upstream_lifetime(0.001) == 1
|
|
# a positive value >= 1 truncates toward zero (never overstating the stated lifetime)
|
|
assert _classify_upstream_lifetime(1.9) == 1
|
|
# unknown lifetime -> cap (never invent a longer life than the upstream stated)
|
|
assert _classify_upstream_lifetime(None) == "unspecified"
|
|
assert _classify_upstream_lifetime(True) == "unspecified"
|
|
assert _classify_upstream_lifetime("nope") == "unspecified"
|
|
# hostile numerics must not raise (int(float(...)) can OverflowError) -> unspecified
|
|
assert _classify_upstream_lifetime("inf") == "unspecified"
|
|
assert _classify_upstream_lifetime("1e999") == "unspecified"
|
|
assert _classify_upstream_lifetime("-inf") == "unspecified"
|
|
assert _classify_upstream_lifetime("nan") == "unspecified"
|
|
assert _classify_upstream_lifetime(float("inf")) == "unspecified"
|
|
assert _classify_upstream_lifetime(10**400) == "unspecified"
|
|
|
|
|
|
def test_bridge_grant_honors_and_rejects_upstream_lifetime():
|
|
"""The grant validator honors a positive lifetime, leaves an unknown one None for the envelope to
|
|
cap, and rejects an explicitly-expired one with "expired_lifetime" so a dead upstream token is
|
|
never sealed into an hour-long envelope."""
|
|
from litellm.proxy.mcp_server.bridge_token_flow import _bridge_grant_from_token_response
|
|
|
|
def grant(v):
|
|
return _bridge_grant_from_token_response({"access_token": "x", "expires_in": v})
|
|
|
|
assert grant(300).expires_in == 300
|
|
assert grant(120.0).expires_in == 120
|
|
# unknown lifetime backs a grant whose expires_in the envelope caps; it is not a rejection
|
|
assert grant("nope").expires_in is None
|
|
assert _bridge_grant_from_token_response({"access_token": "x"}).expires_in is None
|
|
# an explicitly already-dead lifetime is rejected, not silently capped at 1h
|
|
assert grant(0) == "expired_lifetime"
|
|
assert grant(-5) == "expired_lifetime"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bridge_token_exchange_honors_short_float_expires_in_ttl():
|
|
"""A short float expires_in from the upstream caps the envelope TTL, so the client-held envelope
|
|
does not outlive the upstream token. Before coercion a float was dropped and the envelope
|
|
defaulted to the 1h cap (3600), which would forward a stale bearer after the upstream token
|
|
expired."""
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
|
upstream = {"access_token": "UP", "token_type": "Bearer", "expires_in": 120.0}
|
|
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
|
|
assert json.loads(response.body)["expires_in"] <= 120
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_oauth_delegate_bridge_token_exchange_missing_access_token_is_502_not_keyerror():
|
|
"""When the upstream token response has no access_token, a dcr_bridge oauth_delegate exchange
|
|
returns a clean 502 error body rather than raising a KeyError. _finish_bridge_mint asks
|
|
_bridge_grant_from_token_response for a typed grant, gets None, and returns the "no_upstream_token"
|
|
failure, which maps to 502; nothing indexes token_response["access_token"] on the bridge path."""
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
|
|
upstream = {"token_type": "Bearer", "expires_in": 3600}
|
|
|
|
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
|
|
|
|
assert response.status_code == 502
|
|
assert json.loads(response.body)["error"] == "server_error"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_true_passthrough_bridge_token_exchange_returns_raw_upstream_token():
|
|
"""Only oauth_delegate mints. A true_passthrough dcr_bridge server relays the raw upstream token
|
|
to the client, since that mode has no litellm identity to bind and the caller owns the token."""
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(auth_type=MCPAuth.true_passthrough)
|
|
upstream = {"access_token": "UPSTREAM-SECRET-TOKEN", "token_type": "Bearer", "expires_in": 3600}
|
|
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
|
|
|
|
body = json.loads(response.body)
|
|
assert body["access_token"] == "UPSTREAM-SECRET-TOKEN"
|
|
assert not body["access_token"].startswith("llm_env_")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_non_bridge_oauth_delegate_token_exchange_returns_raw_upstream_token():
|
|
"""An oauth_delegate server without dcr_bridge keeps the pre-change contract: the raw upstream
|
|
token is returned, so flag-off behavior is byte-identical."""
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(auth_type=MCPAuth.oauth_delegate, dcr_bridge=None)
|
|
upstream = {"access_token": "UPSTREAM-SECRET-TOKEN", "token_type": "Bearer", "expires_in": 3600}
|
|
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
|
|
|
|
body = json.loads(response.body)
|
|
assert body["access_token"] == "UPSTREAM-SECRET-TOKEN"
|
|
|
|
|
|
async def _exchange_persistence_attempted_for_auth_type(auth_type) -> bool:
|
|
"""Run exchange_token_with_server for a server of ``auth_type`` and report whether it attempted
|
|
to persist the exchanged token server-side. The client-forwarded token modes must not persist:
|
|
their contract is that the upstream token stays browser-held, minted/stored/refreshed nowhere."""
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
exchange_token_with_server,
|
|
)
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
server = MCPServer(
|
|
server_id="t",
|
|
name="t",
|
|
server_name="t",
|
|
alias="t",
|
|
transport=MCPTransport.http,
|
|
auth_type=auth_type,
|
|
client_id="cid",
|
|
client_secret="cs",
|
|
authorization_url="https://provider.com/oauth/authorize",
|
|
token_url="https://provider.com/oauth/token",
|
|
)
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://litellm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
fake_http_response = MagicMock()
|
|
fake_http_response.json.return_value = {"access_token": "tok", "refresh_token": "r", "token_type": "Bearer"}
|
|
fake_http_response.raise_for_status = MagicMock()
|
|
fake_http_client = MagicMock()
|
|
fake_http_client.post = AsyncMock(return_value=fake_http_response)
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=fake_http_client,
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.extract_user_id_from_request",
|
|
new_callable=AsyncMock,
|
|
return_value="admin-user",
|
|
),
|
|
patch( # test-quality-ok: this control tests persistence by auth mode; write-policy behavior is covered separately
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.authorize_oauth_credential_request",
|
|
new_callable=AsyncMock,
|
|
return_value="admin-user",
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints._store_per_user_token_server_side",
|
|
new_callable=AsyncMock,
|
|
) as mock_store,
|
|
):
|
|
await exchange_token_with_server(
|
|
request=mock_request,
|
|
mcp_server=server,
|
|
grant_type="authorization_code",
|
|
code="c",
|
|
redirect_uri="http://127.0.0.1:3000/cb",
|
|
client_id="cid",
|
|
client_secret=None,
|
|
code_verifier=None,
|
|
)
|
|
return mock_store.await_count > 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("auth_type", [MCPAuth.true_passthrough, MCPAuth.oauth_delegate])
|
|
async def test_token_exchange_does_not_persist_for_client_forwarded_modes(auth_type):
|
|
"""The browser-only Authorize for true_passthrough / oauth_delegate must not write the upstream
|
|
token to the DB: these modes forward a browser-held token and persist nothing server-side."""
|
|
assert await _exchange_persistence_attempted_for_auth_type(auth_type) is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_exchange_persists_for_oauth2():
|
|
"""Guard the test's own discriminator: a genuine oauth2 (authorization_code) server DOES persist,
|
|
so the passthrough no-persist assertion above is meaningful and not vacuously true."""
|
|
assert await _exchange_persistence_attempted_for_auth_type(MCPAuth.oauth2) is True
|
|
|
|
|
|
# -------------------------------------------------------------------
|
|
# OBO (token_exchange) Protected Resource Metadata: discovery must name the
|
|
# JWT-auth issuer the client SSOs with, not the gateway.
|
|
# -------------------------------------------------------------------
|
|
|
|
_OBO_RESOURCE = "https://litellm.example.com/mcp/obo_mcp"
|
|
_PATCH_ISSUERS = "litellm.proxy.mcp_server.discoverable_endpoints._jwt_auth_issuers"
|
|
|
|
|
|
def _obo_server(scopes=None):
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
return MCPServer(
|
|
server_id="obo_mcp",
|
|
name="obo_mcp",
|
|
server_name="obo_mcp",
|
|
alias="obo_mcp",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2_token_exchange,
|
|
scopes=scopes,
|
|
)
|
|
|
|
|
|
def test_obo_protected_resource_response_names_jwt_issuers():
|
|
"""An OBO server's PRM points authorization_servers at the configured JWT issuers (the IdP that
|
|
mints and validates the subject token), with the gateway resource echoed back."""
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
_obo_protected_resource_response,
|
|
)
|
|
|
|
with patch(_PATCH_ISSUERS, return_value=["https://idp.example.com"]):
|
|
response = _obo_protected_resource_response(_obo_server(scopes=["read"]), _OBO_RESOURCE)
|
|
assert response == {
|
|
"authorization_servers": ["https://idp.example.com"],
|
|
"resource": _OBO_RESOURCE,
|
|
"scopes_supported": ["read"],
|
|
}
|
|
|
|
|
|
def test_obo_protected_resource_response_scopes_default_empty():
|
|
"""A scopeless OBO server reports scopes_supported as [] rather than None."""
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
_obo_protected_resource_response,
|
|
)
|
|
|
|
with patch(_PATCH_ISSUERS, return_value=["https://idp.example.com"]):
|
|
response = _obo_protected_resource_response(_obo_server(scopes=None), _OBO_RESOURCE)
|
|
assert response["scopes_supported"] == []
|
|
|
|
|
|
def test_obo_protected_resource_response_falls_back_when_no_issuer():
|
|
"""With no JWT issuer configured, the OBO branch returns None so the caller falls back to the
|
|
gateway-default PRM (discovery still works, it just can't name the IdP)."""
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
_obo_protected_resource_response,
|
|
)
|
|
|
|
with patch(_PATCH_ISSUERS, return_value=[]):
|
|
assert _obo_protected_resource_response(_obo_server(), _OBO_RESOURCE) is None
|
|
|
|
|
|
def test_obo_protected_resource_response_ignores_non_obo_server():
|
|
"""Non-OBO servers are not handled by this branch (returns None -> gateway default)."""
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
_obo_protected_resource_response,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
oauth2_server = MCPServer(
|
|
server_id="oauth2_mcp",
|
|
name="oauth2_mcp",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
)
|
|
assert _obo_protected_resource_response(oauth2_server, _OBO_RESOURCE) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_build_oauth_protected_resource_response_obo_end_to_end():
|
|
"""End to end through the response builder: an OBO server's PRM advertises the JWT issuer as
|
|
authorization_servers, proving the extracted branch is wired into the public discovery path."""
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
_build_oauth_protected_resource_response,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
global_mcp_server_manager.registry["obo_mcp"] = _obo_server(scopes=["read"])
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://litellm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
try:
|
|
with patch(_PATCH_ISSUERS, return_value=["https://idp.example.com"]):
|
|
response = await _build_oauth_protected_resource_response(
|
|
request=mock_request,
|
|
mcp_server_name="obo_mcp",
|
|
use_standard_pattern=True,
|
|
)
|
|
assert response["authorization_servers"] == ["https://idp.example.com"]
|
|
assert response["resource"] == "https://litellm.example.com/mcp/obo_mcp"
|
|
finally:
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
|
|
def _token_request(headers, path="/token"):
|
|
"""A real Starlette request with case-insensitive headers (matches production)."""
|
|
from starlette.requests import Request
|
|
|
|
raw = [(k.lower().encode(), v.encode()) for k, v in headers.items()]
|
|
return Request({"type": "http", "method": "POST", "path": path, "headers": raw, "query_string": b""})
|
|
|
|
|
|
@pytest.fixture
|
|
def proxy_globals():
|
|
"""Inject the cache/prisma the OAuth token endpoint resolves identity through, and restore
|
|
them afterward. These module globals are the proxy's real wiring points, so setting them is
|
|
dependency injection rather than monkeypatching a class."""
|
|
import litellm.proxy.proxy_server as ps
|
|
|
|
saved = (ps.user_api_key_cache, ps.prisma_client)
|
|
try:
|
|
yield ps
|
|
finally:
|
|
ps.user_api_key_cache, ps.prisma_client = saved
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extract_user_id_reads_x_litellm_api_key_header(proxy_globals):
|
|
"""The LiteLLM key arrives on x-litellm-api-key (what Claude Desktop/Code send), not
|
|
Authorization. Reading only Authorization dropped the identity, so the per-user token was
|
|
never stored and the egress 401'd forever. Resolution must honor x-litellm-api-key."""
|
|
from litellm.proxy._types import UserAPIKeyAuth, hash_token
|
|
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
|
from litellm.proxy.mcp_server.bridge_token_flow import (
|
|
extract_user_id_from_request,
|
|
)
|
|
|
|
key = "sk-alice-key"
|
|
cache = UserApiKeyCache()
|
|
await cache.async_set_cache(
|
|
hash_token(key),
|
|
UserAPIKeyAuth(token=hash_token(key), user_id="alice"),
|
|
model_type=UserAPIKeyAuth,
|
|
)
|
|
proxy_globals.user_api_key_cache = cache
|
|
proxy_globals.prisma_client = object()
|
|
|
|
request = _token_request({"x-litellm-api-key": f"Bearer {key}"})
|
|
assert await extract_user_id_from_request(request) == "alice"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extract_user_id_rehydrates_cross_replica_dict_cache(proxy_globals):
|
|
"""Cross-replica, async_get_cache hands back a serialized dict, not a UserAPIKeyAuth.
|
|
Resolution must rehydrate it; the old getattr(cached, "user_id") returned None on a dict,
|
|
which is exactly why a multi-replica gateway never found the stored token."""
|
|
from litellm.proxy._types import hash_token
|
|
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
|
from litellm.proxy.mcp_server.bridge_token_flow import (
|
|
extract_user_id_from_request,
|
|
)
|
|
|
|
key = "sk-alice-key"
|
|
cache = UserApiKeyCache()
|
|
cache.set_cache(hash_token(key), {"token": hash_token(key), "user_id": "alice"})
|
|
proxy_globals.user_api_key_cache = cache
|
|
proxy_globals.prisma_client = object()
|
|
|
|
request = _token_request({"Authorization": f"Bearer {key}"})
|
|
assert await extract_user_id_from_request(request) == "alice"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extract_user_id_falls_back_to_db_on_cache_miss(proxy_globals):
|
|
"""A cache miss must read the key from the DB rather than returning None; the old code did a
|
|
cache-only peek and skipped the DB, so any replica that hadn't just authenticated the key
|
|
failed to store the token."""
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
|
from litellm.proxy.mcp_server.bridge_token_flow import (
|
|
extract_user_id_from_request,
|
|
)
|
|
|
|
key = "sk-bob-key"
|
|
|
|
class _FakePrisma:
|
|
async def get_data(self, token, table_name, parent_otel_span=None, proxy_logging_obj=None):
|
|
return UserAPIKeyAuth(token=token, user_id="db-bob")
|
|
|
|
proxy_globals.user_api_key_cache = UserApiKeyCache()
|
|
proxy_globals.prisma_client = _FakePrisma()
|
|
|
|
request = _token_request({"x-litellm-api-key": key})
|
|
assert await extract_user_id_from_request(request) == "db-bob"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extract_user_id_none_without_litellm_key(proxy_globals):
|
|
"""No LiteLLM key on the request resolves to None without consulting the resolver."""
|
|
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
|
from litellm.proxy.mcp_server.bridge_token_flow import (
|
|
extract_user_id_from_request,
|
|
)
|
|
|
|
proxy_globals.user_api_key_cache = UserApiKeyCache()
|
|
proxy_globals.prisma_client = object()
|
|
|
|
request = _token_request({"content-type": "application/json"})
|
|
assert await extract_user_id_from_request(request) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extract_user_id_rejects_blocked_key(proxy_globals):
|
|
"""A blocked LiteLLM key must not resolve an identity. get_key_object returns the DB row without
|
|
checking blocked/expiry (the main auth pipeline does, and the public token endpoint bypasses it),
|
|
so a revoked key could otherwise overwrite the stored per-user OAuth token for its user."""
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
|
from litellm.proxy.mcp_server.bridge_token_flow import (
|
|
extract_user_id_from_request,
|
|
)
|
|
|
|
class _FakePrisma:
|
|
async def get_data(self, token, table_name, parent_otel_span=None, proxy_logging_obj=None):
|
|
return UserAPIKeyAuth(token=token, user_id="blocked-user", blocked=True)
|
|
|
|
proxy_globals.user_api_key_cache = UserApiKeyCache()
|
|
proxy_globals.prisma_client = _FakePrisma()
|
|
|
|
request = _token_request({"x-litellm-api-key": "sk-blocked-key"})
|
|
assert await extract_user_id_from_request(request) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extract_user_id_rejects_expired_key(proxy_globals):
|
|
"""An expired LiteLLM key must not resolve an identity, for the same reason as a blocked key."""
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
|
from litellm.proxy.mcp_server.bridge_token_flow import (
|
|
extract_user_id_from_request,
|
|
)
|
|
|
|
expired = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
|
|
|
|
class _FakePrisma:
|
|
async def get_data(self, token, table_name, parent_otel_span=None, proxy_logging_obj=None):
|
|
return UserAPIKeyAuth(token=token, user_id="expired-user", expires=expired)
|
|
|
|
proxy_globals.user_api_key_cache = UserApiKeyCache()
|
|
proxy_globals.prisma_client = _FakePrisma()
|
|
|
|
request = _token_request({"x-litellm-api-key": "sk-expired-key"})
|
|
assert await extract_user_id_from_request(request) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_active_litellm_key_returns_resolved_key_for_active_key(proxy_globals):
|
|
"""The dcr_bridge mint seals the hash of the authorizing key so admission can reload the live
|
|
record. For an active key the resolver returns exactly hash_token(key), the same value
|
|
get_key_object and the whole cache/DB layer key the record by, so the sealed reference resolves
|
|
back to this key at admission."""
|
|
from litellm.proxy._types import UserAPIKeyAuth, hash_token
|
|
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
|
from litellm.proxy.mcp_server.bridge_token_flow import (
|
|
_resolve_active_litellm_key,
|
|
_ResolvedKey,
|
|
)
|
|
|
|
key = "sk-alice-key"
|
|
cache = UserApiKeyCache()
|
|
await cache.async_set_cache(
|
|
hash_token(key),
|
|
UserAPIKeyAuth(token=hash_token(key), user_id="alice"),
|
|
model_type=UserAPIKeyAuth,
|
|
)
|
|
proxy_globals.user_api_key_cache = cache
|
|
proxy_globals.prisma_client = object()
|
|
|
|
request = _token_request({"x-litellm-api-key": f"Bearer {key}"})
|
|
resolved = await _resolve_active_litellm_key(request)
|
|
assert isinstance(resolved, _ResolvedKey)
|
|
assert resolved.key_hash == hash_token(key)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_active_litellm_key_resolves_key_without_user_id(proxy_globals):
|
|
"""A valid team-scoped or service-account key has no user_id but is a legitimate credential, so it
|
|
must still resolve to a hash and be able to mint a bridge envelope. Gating the resolver on user_id
|
|
presence wrongly rejected these keys with invalid_request; the active-state gate now checks only
|
|
blocked and expiry, and the key hash (not the user) is what the mint seals. The per-user token
|
|
store still gets no user for such a key, since there is none to key a stored credential by."""
|
|
from litellm.proxy._types import UserAPIKeyAuth, hash_token
|
|
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
|
from litellm.proxy.mcp_server.bridge_token_flow import (
|
|
_resolve_active_litellm_key,
|
|
_ResolvedKey,
|
|
extract_user_id_from_request,
|
|
)
|
|
|
|
key = "sk-team-scoped-key"
|
|
cache = UserApiKeyCache()
|
|
await cache.async_set_cache(
|
|
hash_token(key),
|
|
UserAPIKeyAuth(token=hash_token(key), user_id=None, team_id="team-x"),
|
|
model_type=UserAPIKeyAuth,
|
|
)
|
|
proxy_globals.user_api_key_cache = cache
|
|
proxy_globals.prisma_client = object()
|
|
|
|
request = _token_request({"x-litellm-api-key": f"Bearer {key}"})
|
|
resolved = await _resolve_active_litellm_key(request)
|
|
assert isinstance(resolved, _ResolvedKey)
|
|
assert resolved.key_hash == hash_token(key)
|
|
assert await extract_user_id_from_request(request) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_active_litellm_key_rejects_blocked_key(proxy_globals):
|
|
"""A blocked key must not yield a hash, so no gateway-bound envelope is minted for a revoked key;
|
|
the mint fails closed with invalid_request instead."""
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
|
from litellm.proxy.mcp_server.bridge_token_flow import (
|
|
_resolve_active_litellm_key,
|
|
)
|
|
|
|
class _FakePrisma:
|
|
async def get_data(self, token, table_name, parent_otel_span=None, proxy_logging_obj=None):
|
|
return UserAPIKeyAuth(token=token, user_id="blocked-user", blocked=True)
|
|
|
|
proxy_globals.user_api_key_cache = UserApiKeyCache()
|
|
proxy_globals.prisma_client = _FakePrisma()
|
|
|
|
request = _token_request({"x-litellm-api-key": "sk-blocked-key"})
|
|
assert await _resolve_active_litellm_key(request) == "no_active_key"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_active_litellm_key_fails_closed_on_malformed_expiry(proxy_globals):
|
|
"""A key whose stored expires string does not parse must fail closed to no-hash (the mint then
|
|
returns invalid_request), not surface an unhandled 500. The active-state check runs outside the
|
|
resolver's try, so it must be total over a bad expires rather than letting datetime.fromisoformat
|
|
raise. Before the fix this raised a ValueError instead of returning None."""
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
|
from litellm.proxy.mcp_server.bridge_token_flow import (
|
|
_resolve_active_litellm_key,
|
|
)
|
|
|
|
class _FakePrisma:
|
|
async def get_data(self, token, table_name, parent_otel_span=None, proxy_logging_obj=None):
|
|
return UserAPIKeyAuth(token=token, user_id="u", expires="not-a-parseable-date")
|
|
|
|
proxy_globals.user_api_key_cache = UserApiKeyCache()
|
|
proxy_globals.prisma_client = _FakePrisma()
|
|
|
|
request = _token_request({"x-litellm-api-key": "sk-bad-expiry-key"})
|
|
assert await _resolve_active_litellm_key(request) == "no_active_key"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_active_litellm_key_no_active_key_without_litellm_key(proxy_globals):
|
|
"""No LiteLLM key on the request yields no hash without consulting the resolver."""
|
|
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
|
from litellm.proxy.mcp_server.bridge_token_flow import (
|
|
_resolve_active_litellm_key,
|
|
)
|
|
|
|
proxy_globals.user_api_key_cache = UserApiKeyCache()
|
|
proxy_globals.prisma_client = object()
|
|
|
|
request = _token_request({"content-type": "application/json"})
|
|
assert await _resolve_active_litellm_key(request) == "no_active_key"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_active_litellm_key_db_outage_is_unavailable(proxy_globals):
|
|
"""A database outage while resolving the presented key is a retryable infrastructure failure, not
|
|
the caller's fault, so the resolver reports "unavailable" (the mint statuses it 503) rather than
|
|
collapsing it to the same value as a missing credential. is_database_service_unavailable_error
|
|
classifies a connection error (an OSError) as an outage, matching admission's egress-side handling."""
|
|
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
|
from litellm.proxy.mcp_server.bridge_token_flow import (
|
|
_resolve_active_litellm_key,
|
|
)
|
|
|
|
class _OutagePrisma:
|
|
async def get_data(self, token, table_name, parent_otel_span=None, proxy_logging_obj=None):
|
|
raise ConnectionError("connection refused")
|
|
|
|
proxy_globals.user_api_key_cache = UserApiKeyCache()
|
|
proxy_globals.prisma_client = _OutagePrisma()
|
|
|
|
request = _token_request({"x-litellm-api-key": "sk-during-outage"})
|
|
assert await _resolve_active_litellm_key(request) == "unavailable"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_active_litellm_key_permanent_engine_fault_is_faulted(proxy_globals):
|
|
"""A query engine that is missing or version-skewed cannot resolve any key until the deployment is
|
|
repaired, so the resolver reports "faulted" (still statused 503 by the mint) rather than "unavailable",
|
|
whose wording promises the outage is transient and asks the client to retry."""
|
|
from prisma.engine.errors import BinaryNotFoundError
|
|
|
|
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
|
from litellm.proxy.mcp_server.bridge_token_flow import (
|
|
_resolve_active_litellm_key,
|
|
)
|
|
|
|
class _FaultedPrisma:
|
|
async def get_data(self, token, table_name, parent_otel_span=None, proxy_logging_obj=None):
|
|
raise BinaryNotFoundError("query engine binary not found")
|
|
|
|
proxy_globals.user_api_key_cache = UserApiKeyCache()
|
|
proxy_globals.prisma_client = _FaultedPrisma()
|
|
|
|
request = _token_request({"x-litellm-api-key": "sk-during-engine-fault"})
|
|
assert await _resolve_active_litellm_key(request) == "faulted"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_active_litellm_key_transport_error_over_permanent_fault_is_faulted(proxy_globals):
|
|
"""A reconnect that dies on a missing engine binary raises the transport error last, with the
|
|
BinaryNotFoundError as __context__. The binary is what blocks recovery, so the key read is "faulted",
|
|
not the "unavailable" that the outer ConnectError alone would suggest."""
|
|
import httpx
|
|
from prisma.engine.errors import BinaryNotFoundError
|
|
|
|
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
|
from litellm.proxy.mcp_server.bridge_token_flow import (
|
|
_resolve_active_litellm_key,
|
|
)
|
|
|
|
class _ReconnectFailedPrisma:
|
|
async def get_data(self, token, table_name, parent_otel_span=None, proxy_logging_obj=None):
|
|
try:
|
|
raise BinaryNotFoundError("query engine binary not found")
|
|
except BinaryNotFoundError:
|
|
raise httpx.ConnectError("All connection attempts failed")
|
|
|
|
proxy_globals.user_api_key_cache = UserApiKeyCache()
|
|
proxy_globals.prisma_client = _ReconnectFailedPrisma()
|
|
|
|
request = _token_request({"x-litellm-api-key": "sk-during-failed-reconnect"})
|
|
assert await _resolve_active_litellm_key(request) == "faulted"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_active_litellm_key_no_database_is_unresolvable(proxy_globals):
|
|
"""With no database connection configured the gateway cannot verify the presented key at all, so
|
|
the resolver reports "unresolvable" (the mint statuses it 500) instead of blaming the caller.
|
|
Mirrors admission, which 500s a missing prisma_client on the egress side."""
|
|
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
|
from litellm.proxy.mcp_server.bridge_token_flow import (
|
|
_resolve_active_litellm_key,
|
|
)
|
|
|
|
proxy_globals.user_api_key_cache = UserApiKeyCache()
|
|
proxy_globals.prisma_client = None
|
|
|
|
request = _token_request({"x-litellm-api-key": "sk-no-db"})
|
|
assert await _resolve_active_litellm_key(request) == "unresolvable"
|
|
|
|
|
|
def _wrapped_user_lookup_error(original: BaseException) -> ValueError:
|
|
"""Reproduce get_user_object's real exception contract (litellm/proxy/auth/auth_checks.py): it
|
|
catches every DB failure in a broad ``except`` and re-raises a bare ``ValueError``, so the original
|
|
error (a missing-user Exception or a real outage) survives only as ``__context__``. Injecting a raw
|
|
ConnectionError/Exception instead would exercise a shape production never produces and let a
|
|
chain-blind outage classifier pass. The wrapping fidelity is pinned by
|
|
test_get_user_object_wraps_db_outage_as_valueerror_preserving_context in test_auth_checks."""
|
|
try:
|
|
raise original
|
|
except BaseException:
|
|
try:
|
|
raise ValueError(f"User doesn't exist in db. Got error - {original}")
|
|
except ValueError as wrapped:
|
|
return wrapped
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reload_active_user_by_id_missing_user_is_no_active_key(proxy_globals):
|
|
"""A user_id refresh envelope whose user has been deleted must fail closed to no_active_key (the
|
|
refresh path maps it to invalid_grant), not unresolvable/500. get_user_object catches the missing row
|
|
and re-raises a bare ValueError, so a missing user must not be misclassified as a DB outage or an
|
|
opaque gateway fault."""
|
|
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
|
from litellm.proxy.mcp_server.bridge_token_flow import reload_active_user_by_id
|
|
|
|
proxy_globals.user_api_key_cache = UserApiKeyCache()
|
|
proxy_globals.prisma_client = object()
|
|
|
|
with patch(
|
|
"litellm.proxy.auth.auth_checks.get_user_object",
|
|
new=AsyncMock(side_effect=_wrapped_user_lookup_error(Exception())),
|
|
):
|
|
assert await reload_active_user_by_id("gone-user") == "no_active_key"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reload_active_user_by_id_db_outage_is_unavailable(proxy_globals):
|
|
"""A transient DB outage while re-validating the user on refresh is a retryable outage, distinct from
|
|
a missing user, so the refresh path surfaces "unavailable" (a 503) rather than blaming the caller.
|
|
get_user_object wraps the outage in a bare ValueError, so this exercises the chain-aware classifier; a
|
|
raw ConnectionError would falsely pass even a chain-blind check because it is an OSError."""
|
|
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
|
from litellm.proxy.mcp_server.bridge_token_flow import reload_active_user_by_id
|
|
|
|
proxy_globals.user_api_key_cache = UserApiKeyCache()
|
|
proxy_globals.prisma_client = object()
|
|
|
|
with patch(
|
|
"litellm.proxy.auth.auth_checks.get_user_object",
|
|
new=AsyncMock(side_effect=_wrapped_user_lookup_error(ConnectionError("user database unreachable"))),
|
|
):
|
|
assert await reload_active_user_by_id("sso-user-7") == "unavailable"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reload_active_user_by_id_permanent_engine_fault_is_faulted(proxy_globals):
|
|
"""A permanent query engine fault while re-validating the user on refresh is "faulted", not
|
|
"unavailable": both are 503s, but only the transient one may tell the client to retry. get_user_object
|
|
wraps the fault in a bare ValueError, so the classification has to read the wrapped cause."""
|
|
from prisma.engine.errors import MismatchedVersionsError
|
|
|
|
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
|
from litellm.proxy.mcp_server.bridge_token_flow import reload_active_user_by_id
|
|
|
|
proxy_globals.user_api_key_cache = UserApiKeyCache()
|
|
proxy_globals.prisma_client = object()
|
|
|
|
with (
|
|
patch( # test-quality-ok: get_user_object is the DB seam that wraps the fault; same patch as the outage sibling
|
|
"litellm.proxy.auth.auth_checks.get_user_object",
|
|
new=AsyncMock(side_effect=_wrapped_user_lookup_error(MismatchedVersionsError(expected="1", got="2"))),
|
|
)
|
|
):
|
|
assert await reload_active_user_by_id("sso-user-7") == "faulted"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_load_active_user_by_id_reads_the_row_from_the_database_not_the_cache(proxy_globals):
|
|
"""JWT auth caches the user it creates before it adds that user to the JWT's team, and adding a
|
|
member never evicts the cached row, so a credential minted off the cached row refused the very first
|
|
token exchange as not a member. The database source has to read the row from the database and leave
|
|
the fresh row in the cache for the requests the credential makes next."""
|
|
from litellm.proxy._types import LiteLLM_UserTable
|
|
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
|
from litellm.proxy.mcp_server.bridge_token_flow import load_active_user_by_id
|
|
|
|
cache = UserApiKeyCache()
|
|
await cache.async_set_cache(
|
|
key="fresh-jwt-user", value=LiteLLM_UserTable(user_id="fresh-jwt-user", teams=[]), model_type=LiteLLM_UserTable
|
|
)
|
|
prisma = MagicMock()
|
|
prisma.writer_db.litellm_usertable.find_unique = AsyncMock(
|
|
return_value=LiteLLM_UserTable(user_id="fresh-jwt-user", teams=["team-a"])
|
|
)
|
|
proxy_globals.user_api_key_cache = cache
|
|
proxy_globals.prisma_client = prisma
|
|
|
|
loaded = await load_active_user_by_id("fresh-jwt-user", source="database")
|
|
|
|
assert not isinstance(loaded, str)
|
|
assert loaded.teams == ["team-a"]
|
|
cached = await cache.async_get_cache(key="fresh-jwt-user", model_type=LiteLLM_UserTable)
|
|
assert cached is not None
|
|
assert cached.teams == ["team-a"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_load_active_user_by_id_serves_a_cached_row_without_a_database_read(proxy_globals):
|
|
"""Introspection and refresh revalidation run per call, so the loader's default source is the cache: a
|
|
cached row answers without a database read, and only a caller that asks for the database row pays for
|
|
one."""
|
|
from litellm.proxy._types import LiteLLM_UserTable
|
|
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
|
from litellm.proxy.mcp_server.bridge_token_flow import (
|
|
load_active_user_by_id,
|
|
reload_active_user_by_id,
|
|
)
|
|
|
|
cache = UserApiKeyCache()
|
|
await cache.async_set_cache(
|
|
key="cached-jwt-user",
|
|
value=LiteLLM_UserTable(user_id="cached-jwt-user", teams=["team-a"]),
|
|
model_type=LiteLLM_UserTable,
|
|
)
|
|
prisma = MagicMock()
|
|
prisma.db.litellm_usertable.find_unique = AsyncMock(
|
|
return_value=LiteLLM_UserTable(user_id="cached-jwt-user", teams=[])
|
|
)
|
|
proxy_globals.user_api_key_cache = cache
|
|
proxy_globals.prisma_client = prisma
|
|
|
|
loaded = await load_active_user_by_id("cached-jwt-user")
|
|
|
|
assert not isinstance(loaded, str)
|
|
assert loaded.teams == ["team-a"]
|
|
assert await reload_active_user_by_id("cached-jwt-user") is None
|
|
prisma.db.litellm_usertable.find_unique.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_endpoint_uses_client_secret_basic_when_configured():
|
|
"""LIT-4091: a server with token_endpoint_auth_method=client_secret_basic must send the
|
|
credentials as an HTTP Basic Authorization header and omit client_secret from the body;
|
|
providers requiring Basic rejected body credentials with invalid_client."""
|
|
import base64
|
|
from unittest.mock import AsyncMock
|
|
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
token_endpoint,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
oauth2_server = MCPServer(
|
|
server_id="basic_mcp",
|
|
name="basic_mcp",
|
|
server_name="basic_mcp",
|
|
alias="basic_mcp",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id="basic-client",
|
|
client_secret="basic-secret",
|
|
authorization_url="https://idp.example.com/authorize",
|
|
token_url="https://idp.example.com/oauth2/token",
|
|
token_endpoint_auth_method="client_secret_basic",
|
|
)
|
|
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://litellm-proxy.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"access_token": "at",
|
|
"token_type": "Bearer",
|
|
"expires_in": 3599,
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client"
|
|
) as mock_get_client:
|
|
mock_async_client = MagicMock()
|
|
mock_async_client.post = AsyncMock(return_value=mock_response)
|
|
mock_get_client.return_value = mock_async_client
|
|
|
|
await token_endpoint(
|
|
request=mock_request,
|
|
grant_type="authorization_code",
|
|
code="auth-code",
|
|
redirect_uri="http://localhost/callback",
|
|
client_id="basic-client",
|
|
mcp_server_name="basic_mcp",
|
|
client_secret="basic-secret",
|
|
code_verifier="verifier",
|
|
)
|
|
|
|
call_args = mock_async_client.post.call_args
|
|
expected = "Basic " + base64.b64encode(b"basic-client:basic-secret").decode()
|
|
assert call_args[1]["headers"]["Authorization"] == expected
|
|
assert "client_secret" not in call_args[1]["data"]
|
|
assert "client_id" not in call_args[1]["data"]
|
|
assert call_args[1]["data"]["grant_type"] == "authorization_code"
|
|
assert call_args[1]["data"]["code"] == "auth-code"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_endpoint_client_secret_basic_without_secret_returns_400():
|
|
"""A server configured client_secret_basic but missing its secret is a misconfiguration; the
|
|
inbound /token endpoint surfaces it as a 400 rather than silently posting a downgraded request."""
|
|
from fastapi import HTTPException, Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
token_endpoint,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
oauth2_server = MCPServer(
|
|
server_id="basic_no_secret",
|
|
name="basic_no_secret",
|
|
server_name="basic_no_secret",
|
|
alias="basic_no_secret",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id="basic-client",
|
|
client_secret=None,
|
|
authorization_url="https://idp.example.com/authorize",
|
|
token_url="https://idp.example.com/oauth2/token",
|
|
token_endpoint_auth_method="client_secret_basic",
|
|
)
|
|
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://litellm-proxy.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await token_endpoint(
|
|
request=mock_request,
|
|
grant_type="authorization_code",
|
|
code="auth-code",
|
|
redirect_uri="http://localhost/callback",
|
|
client_id="basic-client",
|
|
mcp_server_name="basic_no_secret",
|
|
client_secret=None,
|
|
code_verifier="verifier",
|
|
)
|
|
assert exc_info.value.status_code == 400
|
|
|
|
|
|
def _access_group_none_server(server_name="access_group_server"):
|
|
"""A non-oauth2, access-group gated MCP server: no client_id, no OAuth."""
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
return MCPServer(
|
|
server_id=server_name,
|
|
name=server_name,
|
|
server_name=server_name,
|
|
alias=server_name,
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.none,
|
|
access_groups=["eng"],
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authorize_endpoint_rejects_non_oauth2_server():
|
|
"""authorize() against a none-auth server returns an accurate 'does not use OAuth' 400,
|
|
not the misleading 'client_id is required' that fired before the auth_type was checked."""
|
|
try:
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
authorize,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
server = _access_group_none_server()
|
|
global_mcp_server_manager.registry[server.server_id] = server
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://litellm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
try:
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await authorize(
|
|
request=mock_request,
|
|
client_id=None,
|
|
mcp_server_name="access_group_server",
|
|
redirect_uri="http://127.0.0.1:60108/callback",
|
|
state="test_state",
|
|
)
|
|
assert exc_info.value.status_code == 400
|
|
detail_text = str(exc_info.value.detail)
|
|
assert "does not use OAuth" in detail_text
|
|
assert "client_id is required" not in detail_text
|
|
finally:
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_endpoint_rejects_non_oauth2_server():
|
|
"""token_endpoint() against a none-auth server returns 'does not use OAuth' 400 instead
|
|
of the misleading 'token url is not set'."""
|
|
try:
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
token_endpoint,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
server = _access_group_none_server()
|
|
global_mcp_server_manager.registry[server.server_id] = server
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://litellm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
try:
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await token_endpoint(
|
|
request=mock_request,
|
|
grant_type="authorization_code",
|
|
code="auth-code",
|
|
redirect_uri="http://localhost/callback",
|
|
client_id="some-client",
|
|
mcp_server_name="access_group_server",
|
|
client_secret=None,
|
|
code_verifier="verifier",
|
|
)
|
|
assert exc_info.value.status_code == 400
|
|
detail_text = str(exc_info.value.detail)
|
|
assert "does not use OAuth" in detail_text
|
|
assert "token url is not set" not in detail_text
|
|
finally:
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_client_rejects_non_oauth2_server():
|
|
"""register_client() against a named none-auth server returns 'does not use OAuth' 400
|
|
instead of the misleading 'authorization url is not set'."""
|
|
try:
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
register_client,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
server = _access_group_none_server()
|
|
global_mcp_server_manager.registry[server.server_id] = server
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://litellm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
try:
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.read_request_body",
|
|
new=AsyncMock(return_value={}),
|
|
):
|
|
await register_client(request=mock_request, mcp_server_name="access_group_server")
|
|
assert exc_info.value.status_code == 400
|
|
detail_text = str(exc_info.value.detail)
|
|
assert "does not use OAuth" in detail_text
|
|
assert "authorization url is not set" not in detail_text
|
|
finally:
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"auth_type", (None, "none", "api_key", "bearer_token", "basic", "aws_sigv4", "authorization", "token")
|
|
)
|
|
@pytest.mark.parametrize("use_standard_pattern", (False, True))
|
|
async def test_oauth_protected_resource_for_gateway_owned_auth(auth_type, use_standard_pattern):
|
|
from starlette.requests import Request
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
_build_oauth_protected_resource_response,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
|
|
server = _access_group_none_server().model_copy(update={"auth_type": auth_type})
|
|
request = Request({"type": "http", "scheme": "https", "path": "/", "headers": [(b"host", b"litellm.example.com")]})
|
|
global_mcp_server_manager.registry.clear()
|
|
global_mcp_server_manager.registry[server.server_id] = server
|
|
try:
|
|
response = await _build_oauth_protected_resource_response(
|
|
request=request,
|
|
mcp_server_name="access_group_server",
|
|
use_standard_pattern=use_standard_pattern,
|
|
)
|
|
resource_path = "/mcp/access_group_server" if use_standard_pattern else "/access_group_server/mcp"
|
|
assert response == {
|
|
"resource": f"https://litellm.example.com{resource_path}",
|
|
"authorization_servers": ["https://litellm.example.com/mcp"],
|
|
"scopes_supported": [],
|
|
}
|
|
finally:
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_oauth_authorization_server_404_for_non_oauth2_server():
|
|
"""Discovery must not advertise a none-auth server as an OAuth authorization server."""
|
|
try:
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
_build_oauth_authorization_server_response,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
server = _access_group_none_server()
|
|
global_mcp_server_manager.registry[server.server_id] = server
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://litellm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
try:
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
_build_oauth_authorization_server_response(
|
|
request=mock_request,
|
|
mcp_server_name="access_group_server",
|
|
)
|
|
assert exc_info.value.status_code == 404
|
|
assert "not an OAuth authorization server" in str(exc_info.value.detail)
|
|
finally:
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_oauth_protected_resource_passthrough_none_auth_not_404():
|
|
"""Regression guard for the protected-resource auth_type gate placement: a none-auth
|
|
server that opted into OAuth pass-through must still proxy upstream metadata, it must
|
|
NOT be 404'd. The gate has to sit after the pass-through branch."""
|
|
try:
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
_build_oauth_protected_resource_response,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
except ImportError:
|
|
pytest.skip("MCP discoverable endpoints not available")
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
passthrough_server = MCPServer(
|
|
server_id="passthrough_server",
|
|
name="passthrough_server",
|
|
server_name="passthrough_server",
|
|
alias="passthrough_server",
|
|
url="https://upstream.example.com/mcp",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.none,
|
|
oauth_passthrough=True,
|
|
extra_headers=["Authorization"],
|
|
)
|
|
global_mcp_server_manager.registry[passthrough_server.server_id] = passthrough_server
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://litellm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
try:
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.fetch_upstream_oauth_protected_resource",
|
|
new=AsyncMock(return_value={"authorization_servers": ["https://upstream-idp.example.com"]}),
|
|
):
|
|
response = await _build_oauth_protected_resource_response(
|
|
request=mock_request,
|
|
mcp_server_name="passthrough_server",
|
|
use_standard_pattern=False,
|
|
)
|
|
assert response["authorization_servers"] == ["https://upstream-idp.example.com"]
|
|
assert response["resource"].endswith("/passthrough_server/mcp")
|
|
finally:
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_oauth_protected_resource_404_for_unknown_server_name():
|
|
"""Unknown server names must not produce metadata advertising nonexistent resources."""
|
|
try:
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
_build_oauth_protected_resource_response,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
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://litellm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await _build_oauth_protected_resource_response(
|
|
request=mock_request,
|
|
mcp_server_name="does_not_exist",
|
|
use_standard_pattern=True,
|
|
)
|
|
assert exc_info.value.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_oauth_authorization_server_404_for_unknown_server_name():
|
|
"""A named authorization-server discovery request for an unknown server returns 404, not a
|
|
200 metadata document pointing at non-existent /{name}/authorize and /{name}/token."""
|
|
try:
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
_build_oauth_authorization_server_response,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
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://litellm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
_build_oauth_authorization_server_response(
|
|
request=mock_request,
|
|
mcp_server_name="does_not_exist",
|
|
)
|
|
assert exc_info.value.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_store_per_user_token_server_side_invalidates_v2_token_cache():
|
|
"""A token stored by the OAuth callback (code exchange or refresh) drops the v2 per-user
|
|
token cache entry, so egress stops serving the replaced token immediately instead of
|
|
until its TTL."""
|
|
from litellm.proxy.mcp_server import mcp_server_manager as manager_module
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
_store_per_user_token_server_side,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
server = MCPServer(
|
|
server_id="srv-cb-1",
|
|
name="cb_server",
|
|
url="https://upstream.example/mcp",
|
|
transport="http",
|
|
auth_type=MCPAuth.oauth2,
|
|
)
|
|
invalidate_mock = AsyncMock(return_value=None)
|
|
cache_set_mock = AsyncMock(return_value=None)
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.utils.get_prisma_client_or_throw",
|
|
return_value=MagicMock(),
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.db.store_user_oauth_credential",
|
|
new=AsyncMock(return_value=None),
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.oauth2_token_cache.mcp_per_user_token_cache.set",
|
|
new=cache_set_mock,
|
|
),
|
|
patch.object(
|
|
manager_module.global_mcp_server_manager,
|
|
"invalidate_user_oauth_token_cache",
|
|
new=invalidate_mock,
|
|
),
|
|
):
|
|
await _store_per_user_token_server_side(
|
|
server=server,
|
|
user_id="user-cb-1",
|
|
token_response={"access_token": "fresh-tok", "expires_in": 3600},
|
|
)
|
|
|
|
invalidate_mock.assert_awaited_once_with("user-cb-1", "srv-cb-1")
|
|
cache_set_mock.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_store_per_user_token_server_side_skips_invalidate_when_db_write_fails():
|
|
"""A failed DB write neither warms the v1 cache nor drops the v2 cache entry; the
|
|
previously stored token is still the truth."""
|
|
from litellm.proxy.mcp_server import mcp_server_manager as manager_module
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
_store_per_user_token_server_side,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
server = MCPServer(
|
|
server_id="srv-cb-2",
|
|
name="cb_server_2",
|
|
url="https://upstream.example/mcp",
|
|
transport="http",
|
|
auth_type=MCPAuth.oauth2,
|
|
)
|
|
invalidate_mock = AsyncMock(return_value=None)
|
|
cache_set_mock = AsyncMock(return_value=None)
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.utils.get_prisma_client_or_throw",
|
|
return_value=MagicMock(),
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.db.store_user_oauth_credential",
|
|
new=AsyncMock(side_effect=RuntimeError("db down")),
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.oauth2_token_cache.mcp_per_user_token_cache.set",
|
|
new=cache_set_mock,
|
|
),
|
|
patch.object(
|
|
manager_module.global_mcp_server_manager,
|
|
"invalidate_user_oauth_token_cache",
|
|
new=invalidate_mock,
|
|
),
|
|
):
|
|
await _store_per_user_token_server_side(
|
|
server=server,
|
|
user_id="user-cb-2",
|
|
token_response={"access_token": "fresh-tok", "expires_in": 3600},
|
|
)
|
|
|
|
invalidate_mock.assert_not_awaited()
|
|
cache_set_mock.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_exchange_pairs_client_secret_with_server_client_id():
|
|
"""Re-auth regression: the register short-circuit hands the browser a placeholder
|
|
``client_secret: "dummy"``, which the browser echoes back to /token. The server-side
|
|
persisted client_id wins the resolution, so the secret must come from the same (server)
|
|
source; pairing the persisted public PKCE client (no stored secret) with the caller's
|
|
placeholder makes the IdP reject the exchange with 401 on every re-auth."""
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
exchange_token_with_server,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
server = MCPServer(
|
|
server_id="srv-1",
|
|
name="srv-1",
|
|
server_name="srv-1",
|
|
alias="srv-1",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id="persisted-client",
|
|
client_secret=None,
|
|
authorization_url="https://provider.example/oauth/authorize",
|
|
token_url="https://provider.example/oauth/token",
|
|
)
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://litellm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_response.json.return_value = {"access_token": "at", "token_type": "Bearer"}
|
|
mock_async_client = MagicMock()
|
|
mock_async_client.post = AsyncMock(return_value=mock_response)
|
|
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=mock_async_client,
|
|
):
|
|
await exchange_token_with_server(
|
|
request=mock_request,
|
|
mcp_server=server,
|
|
grant_type="authorization_code",
|
|
code="auth-code",
|
|
redirect_uri="https://litellm.example.com/ui/mcp/oauth/callback",
|
|
client_id="srv-1",
|
|
client_secret="dummy",
|
|
code_verifier="verifier",
|
|
)
|
|
|
|
sent = mock_async_client.post.call_args.kwargs["data"]
|
|
assert sent["client_id"] == "persisted-client"
|
|
assert "client_secret" not in sent
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_exchange_refresh_passes_presented_refresh_ownership():
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
exchange_token_with_server,
|
|
)
|
|
from litellm.proxy.mcp_server.oauth_identity_binding import RefreshTokenPresented
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPOAuthIdentityBinding, MCPServer
|
|
|
|
server = MCPServer(
|
|
server_id="srv-1",
|
|
name="srv-1",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id="cid",
|
|
token_url="https://provider.example/token",
|
|
oauth_identity_binding=MCPOAuthIdentityBinding(
|
|
mode="enforce",
|
|
issuer="https://provider.example",
|
|
audiences=["cid"],
|
|
),
|
|
)
|
|
request = MagicMock(spec=Request)
|
|
request.base_url = "https://litellm.example.com/"
|
|
request.headers = {}
|
|
response = MagicMock()
|
|
response.json.return_value = {"access_token": "at"}
|
|
response.raise_for_status = MagicMock()
|
|
client = MagicMock()
|
|
client.post = AsyncMock(return_value=response)
|
|
enforce = AsyncMock()
|
|
|
|
with (
|
|
patch( # test-quality-ok: no injection seam exists for the exchange's HTTP and identity collaborators
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=client,
|
|
),
|
|
patch( # test-quality-ok: no injection seam exists for request identity extraction
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.extract_user_id_from_request",
|
|
new=AsyncMock(return_value="user-a"),
|
|
),
|
|
patch( # test-quality-ok: captures the ownership value at the exchange boundary
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.enforce_oauth_identity_binding",
|
|
new=enforce,
|
|
),
|
|
):
|
|
await exchange_token_with_server(
|
|
request=request,
|
|
mcp_server=server,
|
|
grant_type="refresh_token",
|
|
code=None,
|
|
redirect_uri=None,
|
|
client_id="cid",
|
|
client_secret=None,
|
|
code_verifier=None,
|
|
refresh_token="rt-1",
|
|
)
|
|
|
|
ownership = enforce.await_args.kwargs["refresh_ownership"]
|
|
assert isinstance(ownership, RefreshTokenPresented)
|
|
assert ownership.refresh_token == "rt-1"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_exchange_authorization_code_passes_no_refresh_ownership(monkeypatch):
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
exchange_token_with_server,
|
|
seal_bridge_authorization_code,
|
|
)
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPOAuthIdentityBinding, MCPServer
|
|
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", "identity-binding-test-salt")
|
|
server = MCPServer(
|
|
server_id="srv-1",
|
|
name="srv-1",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id="cid",
|
|
token_url="https://provider.example/token",
|
|
oauth_identity_binding=MCPOAuthIdentityBinding(
|
|
mode="enforce",
|
|
issuer="https://provider.example",
|
|
audiences=["cid"],
|
|
),
|
|
)
|
|
request = MagicMock(spec=Request)
|
|
request.base_url = "https://litellm.example.com/"
|
|
request.headers = {}
|
|
response = MagicMock()
|
|
response.json.return_value = {"access_token": "at"}
|
|
response.raise_for_status = MagicMock()
|
|
client = MagicMock()
|
|
client.post = AsyncMock(return_value=response)
|
|
enforce = AsyncMock()
|
|
|
|
with (
|
|
patch( # test-quality-ok: no injection seam exists for the exchange's HTTP and identity collaborators
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=client,
|
|
),
|
|
patch( # test-quality-ok: no injection seam exists for request identity extraction
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.extract_user_id_from_request",
|
|
new=AsyncMock(return_value="user-a"),
|
|
),
|
|
patch( # test-quality-ok: captures the ownership value at the exchange boundary
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.enforce_oauth_identity_binding",
|
|
new=enforce,
|
|
),
|
|
):
|
|
result = await exchange_token_with_server(
|
|
request=request,
|
|
mcp_server=server,
|
|
grant_type="authorization_code",
|
|
code=seal_bridge_authorization_code("auth-code", "user-a", "srv-1", "login-nonce"),
|
|
redirect_uri="https://litellm.example.com/callback",
|
|
client_id="cid",
|
|
client_secret=None,
|
|
code_verifier="test-verifier",
|
|
)
|
|
|
|
assert result.status_code == 200
|
|
assert json.loads(result.body)["access_token"] == "at"
|
|
assert enforce.await_args.kwargs["refresh_ownership"] is None
|
|
assert enforce.await_args.kwargs["expected_nonce"] == "login-nonce"
|
|
|
|
|
|
def _upstream_token_response(status_code: int, *, json_body: object = None, text_body: str = "") -> "httpx.Response":
|
|
import httpx
|
|
|
|
request = httpx.Request("POST", "https://oauth2.googleapis.com/token")
|
|
if json_body is not None:
|
|
return httpx.Response(status_code, json=json_body, request=request)
|
|
return httpx.Response(status_code, text=text_body, request=request)
|
|
|
|
|
|
async def _exchange_with_upstream_response(
|
|
upstream_response, *, server_client_id="web-client.apps.googleusercontent.com"
|
|
):
|
|
"""Run the raw (non-bridge) authorization_code exchange against a canned upstream token-endpoint
|
|
response and return what the gateway would hand the client. ``server_client_id=None`` models the
|
|
caller-supplied-credentials flow (no stored client on the server)."""
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
exchange_token_with_server,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
server = MCPServer(
|
|
server_id="gcal",
|
|
name="gcal",
|
|
server_name="gcal",
|
|
alias="gcal",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id=server_client_id,
|
|
authorization_url="https://accounts.google.com/o/oauth2/v2/auth",
|
|
token_url="https://oauth2.googleapis.com/token",
|
|
)
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://litellm.example.com/"
|
|
mock_request.headers = {}
|
|
mock_async_client = MagicMock()
|
|
mock_async_client.post = AsyncMock(return_value=upstream_response)
|
|
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=mock_async_client,
|
|
):
|
|
return await exchange_token_with_server(
|
|
request=mock_request,
|
|
mcp_server=server,
|
|
grant_type="authorization_code",
|
|
code="auth-code",
|
|
redirect_uri="https://litellm.example.com/ui/mcp/oauth/callback",
|
|
client_id="web-client.apps.googleusercontent.com",
|
|
client_secret=None,
|
|
code_verifier="verifier",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_exchange_gateway_credential_rejection_is_502_with_gateway_prose():
|
|
"""When the gateway presented the server's stored client credentials and the IdP rejected them
|
|
(Google refusing a secret-less or unknown client), the fault is the operator's, not the caller's:
|
|
502 server_error with gateway-authored prose naming the code, and the IdP's own prose stays in
|
|
server logs. Before the framework this either 500ed raw or relayed provider prose verbatim."""
|
|
response = await _exchange_with_upstream_response(
|
|
_upstream_token_response(
|
|
401, json_body={"error": "invalid_client", "error_description": "The OAuth client was not found."}
|
|
)
|
|
)
|
|
|
|
assert response.status_code == 502
|
|
body = json.loads(response.body)
|
|
assert body["error"] == "server_error"
|
|
assert "invalid_client" in body["error_description"]
|
|
assert "client_id and client_secret" in body["error_description"]
|
|
assert "The OAuth client was not found." not in body["error_description"]
|
|
assert response.headers["cache-control"] == "no-store"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_exchange_caller_supplied_credential_rejection_relays_code():
|
|
"""When the caller supplied the client credentials themselves (no stored client on the server),
|
|
an invalid_client rejection is theirs to act on: the §5.2 code relays on the 401 that code
|
|
implies."""
|
|
response = await _exchange_with_upstream_response(
|
|
_upstream_token_response(
|
|
401, json_body={"error": "invalid_client", "error_description": "The OAuth client was not found."}
|
|
),
|
|
server_client_id=None,
|
|
)
|
|
|
|
assert response.status_code == 401
|
|
body = json.loads(response.body)
|
|
assert body == {"error": "invalid_client", "error_description": "The OAuth client was not found."}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_exchange_status_derives_from_error_code_not_upstream_status():
|
|
"""An upstream that pairs a caller-fault code with a server-fault status (invalid_grant on a 500)
|
|
must not produce a contradictory response: status derives from the classified fault, so the
|
|
caller sees 400 invalid_grant and knows to re-authorize rather than blaming the gateway."""
|
|
response = await _exchange_with_upstream_response(
|
|
_upstream_token_response(500, json_body={"error": "invalid_grant", "error_description": "Code expired."})
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
body = json.loads(response.body)
|
|
assert body == {"error": "invalid_grant", "error_description": "Code expired."}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_exchange_relays_only_rfc6749_error_fields():
|
|
"""Only error / error_description / error_uri cross the gateway; any other upstream body field is
|
|
dropped so an arbitrary rejection payload cannot ride the relay to the client."""
|
|
response = await _exchange_with_upstream_response(
|
|
_upstream_token_response(
|
|
400,
|
|
json_body={
|
|
"error": "invalid_grant",
|
|
"error_description": "Code was already redeemed.",
|
|
"error_uri": "https://idp.example.com/errors/invalid_grant",
|
|
"internal_trace": "should never reach the client",
|
|
},
|
|
)
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
body = json.loads(response.body)
|
|
assert set(body.keys()) == {"error", "error_description", "error_uri"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_exchange_maps_out_of_contract_rejection_to_502():
|
|
"""A rejection outside the §5.2 contract (no JSON error field, or a status the token-endpoint
|
|
contract does not define) is an upstream fault; 502 keeps it from being misread as a caller
|
|
mistake while the description still names the upstream status."""
|
|
response = await _exchange_with_upstream_response(
|
|
_upstream_token_response(503, text_body="<html>upstream maintenance</html>")
|
|
)
|
|
|
|
assert response.status_code == 502
|
|
body = json.loads(response.body)
|
|
assert body["error"] == "server_error"
|
|
assert "HTTP 503" in body["error_description"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_exchange_bounds_relayed_error_fields():
|
|
"""Relayed §5.2 fields are length-bounded so a hostile or broken upstream cannot bloat the
|
|
gateway response."""
|
|
response = await _exchange_with_upstream_response(
|
|
_upstream_token_response(400, json_body={"error": "invalid_request", "error_description": "x" * 5000})
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
body = json.loads(response.body)
|
|
assert len(body["error_description"]) == 500
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_exchange_200_without_access_token_is_502_not_keyerror():
|
|
"""A 200 whose body has no usable access_token used to KeyError into a 500; the raw arm now
|
|
answers 502 with the same wording as the bridge arm's no_upstream_token rejection."""
|
|
response = await _exchange_with_upstream_response(_upstream_token_response(200, json_body={"token_type": "Bearer"}))
|
|
|
|
assert response.status_code == 502
|
|
body = json.loads(response.body)
|
|
assert body["error"] == "server_error"
|
|
assert "access_token" in body["error_description"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_exchange_relays_rejection_when_http_client_raises():
|
|
"""litellm's AsyncHTTPHandler.post raise_for_status()es internally and raises MaskedHTTPStatusError
|
|
at call time, so in production the rejection escapes from the post call itself rather than from the
|
|
explicit raise_for_status; the relay must catch it there too (proven live: a mock returning the
|
|
error response passed while the real proxy still 500ed)."""
|
|
import httpx
|
|
|
|
rejection = _upstream_token_response(
|
|
401, json_body={"error": "invalid_client", "error_description": "The OAuth client was not found."}
|
|
)
|
|
raising_client = MagicMock()
|
|
raising_client.post = AsyncMock(
|
|
side_effect=httpx.HTTPStatusError(
|
|
"Client error '401 Unauthorized'", request=rejection.request, response=rejection
|
|
)
|
|
)
|
|
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
exchange_token_with_server,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
server = MCPServer(
|
|
server_id="gcal",
|
|
name="gcal",
|
|
server_name="gcal",
|
|
alias="gcal",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id="web-client.apps.googleusercontent.com",
|
|
authorization_url="https://accounts.google.com/o/oauth2/v2/auth",
|
|
token_url="https://oauth2.googleapis.com/token",
|
|
)
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://litellm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=raising_client,
|
|
):
|
|
response = await exchange_token_with_server(
|
|
request=mock_request,
|
|
mcp_server=server,
|
|
grant_type="authorization_code",
|
|
code="auth-code",
|
|
redirect_uri="https://litellm.example.com/ui/mcp/oauth/callback",
|
|
client_id="web-client.apps.googleusercontent.com",
|
|
client_secret=None,
|
|
code_verifier="verifier",
|
|
)
|
|
|
|
assert response.status_code == 502
|
|
body = json.loads(response.body)
|
|
assert body["error"] == "server_error"
|
|
assert "invalid_client" in body["error_description"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_relays_rejection_when_http_client_raises():
|
|
"""Same live mechanism as the token exchange: the DCR rejection escapes from the post call itself,
|
|
so the register relay must catch it there, not only from the explicit raise_for_status."""
|
|
import httpx
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
register_client_with_server,
|
|
)
|
|
|
|
rejection = httpx.Response(
|
|
400,
|
|
json={"error": "invalid_client_metadata"},
|
|
request=httpx.Request("POST", "https://idp.example.com/register"),
|
|
)
|
|
raising_client = MagicMock()
|
|
raising_client.post = AsyncMock(
|
|
side_effect=httpx.HTTPStatusError(
|
|
"Client error '400 Bad Request'", request=rejection.request, response=rejection
|
|
)
|
|
)
|
|
|
|
oauth2_server = _bridge_server(auth_type=MCPAuth.oauth2, dcr_bridge=None)
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=raising_client,
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints._reuse_persisted_dcr_client_if_available",
|
|
new_callable=AsyncMock,
|
|
return_value=False,
|
|
),
|
|
):
|
|
with pytest.raises(HTTPException) as exc:
|
|
await register_client_with_server(
|
|
request=_bridge_mock_request(),
|
|
mcp_server=oauth2_server,
|
|
client_name="Claude",
|
|
grant_types=None,
|
|
response_types=None,
|
|
token_endpoint_auth_method=None,
|
|
)
|
|
|
|
assert exc.value.status_code == 400
|
|
assert "invalid_client_metadata" in str(exc.value.detail)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_exchange_never_relays_out_of_contract_body_to_client():
|
|
"""These endpoints serve unauthenticated OAuth clients, so a non-RFC6749 upstream body (HTML
|
|
error page, proxy banner, stack trace) must stay in server logs; the client sees only the
|
|
upstream status."""
|
|
response = await _exchange_with_upstream_response(
|
|
_upstream_token_response(404, text_body="<html>Error 404 stack trace: secret internals</html>")
|
|
)
|
|
|
|
assert response.status_code == 502
|
|
body = json.loads(response.body)
|
|
assert body == {"error": "server_error", "error_description": "upstream token endpoint returned HTTP 404"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_never_relays_out_of_contract_body_to_client():
|
|
"""Same trust boundary for DCR: a non-RFC7591 rejection body is logged server-side and the
|
|
client detail names only the status."""
|
|
import httpx
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
register_client_with_server,
|
|
)
|
|
|
|
rejection = httpx.Response(
|
|
500,
|
|
text="<html>Tomcat stack trace with internals</html>",
|
|
request=httpx.Request("POST", "https://idp.example.com/register"),
|
|
)
|
|
raising_client = MagicMock()
|
|
raising_client.post = AsyncMock(
|
|
side_effect=httpx.HTTPStatusError("Server error '500'", request=rejection.request, response=rejection)
|
|
)
|
|
|
|
oauth2_server = _bridge_server(auth_type=MCPAuth.oauth2, dcr_bridge=None)
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=raising_client,
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints._reuse_persisted_dcr_client_if_available",
|
|
new_callable=AsyncMock,
|
|
return_value=False,
|
|
),
|
|
):
|
|
with pytest.raises(HTTPException) as exc:
|
|
await register_client_with_server(
|
|
request=_bridge_mock_request(),
|
|
mcp_server=oauth2_server,
|
|
client_name="Claude",
|
|
grant_types=None,
|
|
response_types=None,
|
|
token_endpoint_auth_method=None,
|
|
)
|
|
|
|
assert exc.value.status_code == 502
|
|
assert str(exc.value.detail) == "upstream registration failed with HTTP 500"
|
|
assert "Tomcat" not in str(exc.value.detail)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_exchange_unreadable_body_still_renders_oauth_fault():
|
|
"""An upstream whose failure body cannot be read (unconsumed stream, lying content-encoding)
|
|
makes response.text/.json raise; the classifier must stay total so the caller still gets the
|
|
§5.2-shaped 502 instead of the opaque 500 this change set out to remove."""
|
|
import httpx
|
|
|
|
unreadable = httpx.Response(
|
|
400,
|
|
stream=httpx.ByteStream(b"\x1f\x8bnot-actually-gzip"),
|
|
headers={"content-encoding": "gzip"},
|
|
request=httpx.Request("POST", "https://oauth2.googleapis.com/token"),
|
|
)
|
|
|
|
response = await _exchange_with_upstream_response(unreadable)
|
|
|
|
assert response.status_code == 502
|
|
body = json.loads(response.body)
|
|
assert body == {"error": "server_error", "error_description": "upstream token endpoint returned HTTP 400"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_persist_dcr_client_for_config_server_uses_side_store():
|
|
"""A config.yaml-declared OAuth2 DCR server has no LiteLLM_MCPServerTable row, so
|
|
update_mcp_server returns None. The minted client must then persist to the server-scoped
|
|
OAuth-client store keyed by server_id (never a shadow server row), overlay onto the in-memory
|
|
server so refresh can authenticate this process, and never call update_server(None) (which
|
|
previously raised AttributeError on .approval_status, was swallowed, and reported a 200 that
|
|
persisted nothing)."""
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
_persist_dcr_client_registration,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
config_server = MCPServer(
|
|
server_id="config_faros",
|
|
name="config_faros",
|
|
server_name="config_faros",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id=None,
|
|
client_secret=None,
|
|
authorization_url="https://provider.example/oauth/authorize",
|
|
token_url="https://provider.example/oauth/token",
|
|
registration_url="https://provider.example/oauth/register",
|
|
)
|
|
|
|
mock_upsert = AsyncMock()
|
|
mock_update_server = AsyncMock()
|
|
|
|
with (
|
|
patch.object(global_mcp_server_manager, "is_config_declared_server", return_value=True),
|
|
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
|
|
patch(
|
|
"litellm.proxy.mcp_server.db.update_mcp_server",
|
|
new=AsyncMock(return_value=None),
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.db.get_mcp_server",
|
|
new=AsyncMock(return_value=None),
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.db.get_mcp_server_oauth_client_credentials",
|
|
new=AsyncMock(return_value=None),
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.db.upsert_mcp_server_oauth_client_credentials",
|
|
new=mock_upsert,
|
|
),
|
|
patch.object(global_mcp_server_manager, "update_server", new=mock_update_server),
|
|
):
|
|
result = await _persist_dcr_client_registration(
|
|
mcp_server=config_server,
|
|
registration_response={
|
|
"client_id": "minted-client",
|
|
"client_secret": "minted-secret",
|
|
"token_endpoint_auth_method": "client_secret_basic",
|
|
},
|
|
current_redirect_uri="https://proxy.litellm.example/callback",
|
|
)
|
|
|
|
assert result == "persisted"
|
|
|
|
mock_upsert.assert_called_once()
|
|
assert mock_upsert.call_args.kwargs["server_id"] == "config_faros"
|
|
stored = mock_upsert.call_args.kwargs["credentials"]
|
|
assert stored["client_id"] == "minted-client"
|
|
assert stored["client_secret"] == "minted-secret"
|
|
assert stored["token_endpoint_auth_method"] == "client_secret_basic"
|
|
assert stored["redirect_uris"] == ["https://proxy.litellm.example/callback"]
|
|
|
|
assert config_server.client_id == "minted-client"
|
|
assert config_server.client_secret == "minted-secret"
|
|
assert config_server.token_endpoint_auth_method == "client_secret_basic"
|
|
|
|
mock_update_server.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("legacy", [False, True])
|
|
async def test_hydrate_config_server_applies_stored_dcr_client(monkeypatch, legacy):
|
|
"""On restart a config server's in-memory object has no client_id; hydration overlays the
|
|
persisted DCR client from the server-scoped store, decrypting the encrypted-at-rest blob, so the
|
|
refresh_token grant can authenticate as the registered client instead of re-authenticating."""
|
|
import litellm.proxy.common_utils.encrypt_decrypt_utils as enc
|
|
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.db import encrypt_credentials
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
hydrate_config_server_dcr_client,
|
|
)
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
server = MCPServer(
|
|
server_id="config_faros",
|
|
name="config_faros",
|
|
server_name="config_faros",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id=None,
|
|
url="https://resource.example/mcp",
|
|
issuer="https://idp.example",
|
|
)
|
|
|
|
monkeypatch.setattr(enc, "get_salt_key", lambda: "salt-hydrate-key")
|
|
stored_blob = safe_dumps(
|
|
encrypt_credentials(
|
|
credentials={
|
|
**(
|
|
{}
|
|
if legacy
|
|
else {"dcr_issuer": "https://idp.example", "dcr_server_url": "https://resource.example/mcp"}
|
|
),
|
|
"client_id": "stored-client",
|
|
"client_secret": "stored-secret",
|
|
"token_endpoint_auth_method": "client_secret_basic",
|
|
"redirect_uris": ["https://proxy.litellm.example/callback"],
|
|
},
|
|
encryption_key="salt-hydrate-key",
|
|
)
|
|
)
|
|
assert "stored-client" not in stored_blob and "stored-secret" not in stored_blob
|
|
|
|
with (
|
|
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
|
|
patch(
|
|
"litellm.proxy.mcp_server.db.get_mcp_server_oauth_client_credentials",
|
|
new=AsyncMock(return_value=stored_blob),
|
|
),
|
|
):
|
|
applied = await hydrate_config_server_dcr_client(server)
|
|
|
|
assert applied is True
|
|
assert server.client_id == "stored-client"
|
|
assert server.client_secret == "stored-secret"
|
|
assert server.token_endpoint_auth_method == "client_secret_basic"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reuse_config_server_reads_store_with_real_crypto(monkeypatch):
|
|
"""A config-declared server (rowless) keeps its DCR client in the store, so the reuse read
|
|
resolves it from the store and decrypts the encrypted-at-rest client, mirroring the write path so
|
|
a re-authorize reuses the client instead of re-minting one."""
|
|
import litellm.proxy.common_utils.encrypt_decrypt_utils as enc
|
|
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.db import encrypt_credentials
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
_reuse_persisted_dcr_client_if_available,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
server = MCPServer(
|
|
server_id="config_faros",
|
|
name="config_faros",
|
|
server_name="config_faros",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id=None,
|
|
url="https://resource.example/mcp",
|
|
issuer="https://idp.example",
|
|
)
|
|
|
|
monkeypatch.setattr(enc, "get_salt_key", lambda: "salt-reuse-key")
|
|
blob = safe_dumps(
|
|
encrypt_credentials(
|
|
credentials={
|
|
"dcr_issuer": "https://idp.example",
|
|
"dcr_server_url": "https://resource.example/mcp",
|
|
"client_id": "stored-client",
|
|
"client_secret": "sec",
|
|
"redirect_uris": ["https://x/callback"],
|
|
},
|
|
encryption_key="salt-reuse-key",
|
|
)
|
|
)
|
|
assert "stored-client" not in blob
|
|
store_lookup = AsyncMock(return_value=blob)
|
|
with (
|
|
patch.object(global_mcp_server_manager, "is_config_declared_server", return_value=True),
|
|
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
|
|
patch("litellm.proxy.mcp_server.db.get_mcp_server", new=AsyncMock(return_value=None)),
|
|
patch(
|
|
"litellm.proxy.mcp_server.db.get_mcp_server_oauth_client_credentials",
|
|
new=store_lookup,
|
|
),
|
|
):
|
|
result = await _reuse_persisted_dcr_client_if_available(server, current_redirect_uri="https://x/callback")
|
|
|
|
assert result is True
|
|
assert server.client_id == "stored-client"
|
|
store_lookup.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_temp_server_is_not_persisted_to_store():
|
|
"""A rowless server that is NOT config-declared (a throwaway /server/oauth/session server) must
|
|
not leave a permanent store row on persist, and the read must never consult the store for it. Its
|
|
minted client is overlaid in memory for the session only."""
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
_persist_dcr_client_registration,
|
|
_reuse_persisted_dcr_client_if_available,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
temp = MCPServer(
|
|
server_id="temp-uuid",
|
|
name="temp",
|
|
server_name="temp",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id=None,
|
|
authorization_url="https://p.example/authorize",
|
|
token_url="https://p.example/token",
|
|
registration_url="https://p.example/register",
|
|
)
|
|
|
|
upsert = AsyncMock()
|
|
store_read = AsyncMock(return_value=None)
|
|
with (
|
|
patch.object(global_mcp_server_manager, "is_config_declared_server", return_value=False),
|
|
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
|
|
patch("litellm.proxy.mcp_server.db.update_mcp_server", new=AsyncMock(return_value=None)),
|
|
patch("litellm.proxy.mcp_server.db.get_mcp_server", new=AsyncMock(return_value=None)),
|
|
patch("litellm.proxy.mcp_server.db.upsert_mcp_server_oauth_client_credentials", new=upsert),
|
|
patch(
|
|
"litellm.proxy.mcp_server.db.get_mcp_server_oauth_client_credentials",
|
|
new=store_read,
|
|
),
|
|
patch.object(global_mcp_server_manager, "update_server", new=AsyncMock()),
|
|
):
|
|
result = await _persist_dcr_client_registration(
|
|
temp, {"client_id": "temp-client", "client_secret": "s"}, "https://x/callback"
|
|
)
|
|
reused = await _reuse_persisted_dcr_client_if_available(
|
|
MCPServer(
|
|
server_id="temp-uuid",
|
|
name="temp",
|
|
server_name="temp",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id=None,
|
|
),
|
|
current_redirect_uri="https://x/callback",
|
|
)
|
|
|
|
assert result == "persisted"
|
|
assert temp.client_id == "temp-client"
|
|
upsert.assert_not_called()
|
|
store_read.assert_not_called()
|
|
assert reused is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_hydrate_does_not_overwrite_explicit_config_client_id():
|
|
"""An explicit client_id set in config.yaml wins: hydration must not overwrite it with a stale
|
|
persisted store client, and must not even read the store when config already supplied a client."""
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
hydrate_config_server_dcr_client,
|
|
)
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
server = MCPServer(
|
|
server_id="config_static",
|
|
name="config_static",
|
|
server_name="config_static",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id="explicit-from-config",
|
|
)
|
|
store_read = AsyncMock(return_value={"client_id": "stale-store-client", "client_secret": "x", "redirect_uris": []})
|
|
with (
|
|
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
|
|
patch(
|
|
"litellm.proxy.mcp_server.db.get_mcp_server_oauth_client_credentials",
|
|
new=store_read,
|
|
),
|
|
):
|
|
applied = await hydrate_config_server_dcr_client(server)
|
|
|
|
assert applied is False
|
|
assert server.client_id == "explicit-from-config"
|
|
store_read.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reuse_does_not_inherit_store_client_when_a_row_exists():
|
|
"""Security: a server that HAS a LiteLLM_MCPServerTable row reads its DCR client only from that
|
|
row, never from the server-scoped store. server_id is caller-settable on create, so a submitted
|
|
server whose id collides with a config-declared server must not be able to load that config
|
|
server's client from the store and send it to its own token endpoint. A row that exists but has
|
|
no client_id yields no reusable client and must not fall back to the store."""
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
_reuse_persisted_dcr_client_if_available,
|
|
)
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
submitted = MCPServer(
|
|
server_id="collides_with_config",
|
|
name="submitted",
|
|
server_name="submitted",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id=None,
|
|
)
|
|
|
|
row_without_client = MagicMock()
|
|
row_without_client.credentials = None
|
|
row_without_client.server_id = "collides_with_config"
|
|
store_lookup = AsyncMock(
|
|
return_value={"client_id": "config-secret-client", "client_secret": "leak", "redirect_uris": []}
|
|
)
|
|
with (
|
|
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
|
|
patch(
|
|
"litellm.proxy.mcp_server.db.get_mcp_server",
|
|
new=AsyncMock(return_value=row_without_client),
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.db.get_mcp_server_oauth_client_credentials",
|
|
new=store_lookup,
|
|
),
|
|
):
|
|
result = await _reuse_persisted_dcr_client_if_available(submitted, current_redirect_uri="https://x/callback")
|
|
|
|
assert result is False
|
|
assert submitted.client_id is None
|
|
store_lookup.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_load_servers_from_config_hydrates_dcr_clients():
|
|
"""load_servers_from_config must invoke DCR-client hydration so config servers pick up their
|
|
persisted client on startup; deleting the call site leaves a restarted server with no client_id
|
|
and forces re-authentication on every token expiry."""
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
|
|
hydrate_spy = AsyncMock()
|
|
with patch.object(global_mcp_server_manager, "hydrate_config_servers_dcr_clients", new=hydrate_spy):
|
|
await global_mcp_server_manager.load_servers_from_config({})
|
|
|
|
hydrate_spy.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reload_servers_from_database_hydrates_dcr_clients():
|
|
"""load_servers_from_config runs before the DB connects at startup, so its hydration no-ops;
|
|
reload_servers_from_database runs after the DB connects and must hydrate config servers' persisted
|
|
DCR clients too, or a fresh pod has no client_id for a config server and forces re-authentication
|
|
on the first token refresh."""
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
|
|
prisma = MagicMock()
|
|
prisma.writer_db = prisma.db
|
|
prisma.db.litellm_mcpservertable.find_many = AsyncMock(return_value=[])
|
|
prisma.db.litellm_config.find_unique = AsyncMock(return_value=None)
|
|
|
|
hydrate_spy = AsyncMock()
|
|
with (
|
|
patch(
|
|
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
|
|
return_value=prisma,
|
|
),
|
|
patch.object(global_mcp_server_manager, "hydrate_config_servers_dcr_clients", new=hydrate_spy),
|
|
):
|
|
await global_mcp_server_manager.reload_servers_from_database()
|
|
|
|
hydrate_spy.assert_awaited_once()
|
|
|
|
|
|
def test_aggregate_wellknown_routes_serve_gateway_metadata():
|
|
"""Both path-appended aggregate routes serve the gateway documents. Exercises real
|
|
routing, so this also pins registration order: the parameterized
|
|
/.well-known/oauth-authorization-server/{name} route would otherwise capture the /mcp
|
|
suffix as a server name."""
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import router
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
client = TestClient(app)
|
|
|
|
prm = client.get("/.well-known/oauth-protected-resource/mcp")
|
|
asm = client.get("/.well-known/oauth-authorization-server/mcp")
|
|
|
|
assert prm.status_code == 200
|
|
assert prm.json()["resource"] == "http://testserver/mcp"
|
|
assert prm.json()["authorization_servers"] == ["http://testserver/mcp"]
|
|
|
|
assert asm.status_code == 200
|
|
assert asm.json()["issuer"] == "http://testserver/mcp"
|
|
assert asm.json()["authorization_endpoint"] == "http://testserver/authorize/mcp-session"
|
|
assert "none" in asm.json()["token_endpoint_auth_methods_supported"]
|
|
|
|
|
|
def test_as_aggregate_route_reserves_mcp_for_the_aggregate():
|
|
"""The single-segment /.well-known/oauth-authorization-server/mcp is reserved for the
|
|
aggregate even when a server is literally named ``mcp``. The aggregate protected-resource
|
|
document advertises {base}/mcp as its authorization server, so the document served here
|
|
must carry issuer {base}/mcp for the RFC 8414 issuer check to pass. Letting the per-server
|
|
row win (issuer {base}) breaks that chain, so the aggregate wins and the mcp-named server
|
|
keeps its standard two-segment discovery at /.well-known/oauth-authorization-server/mcp/mcp."""
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import router
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
server_named_mcp = _create_oauth2_server(server_id="mcp_srv", name="mcp", server_name="mcp", alias="mcp")
|
|
global_mcp_server_manager.registry[server_named_mcp.server_id] = server_named_mcp
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
client = TestClient(app)
|
|
|
|
try:
|
|
asm = client.get("/.well-known/oauth-authorization-server/mcp")
|
|
assert asm.status_code == 200
|
|
# the aggregate document, whose issuer matches what the aggregate PRM advertises
|
|
assert asm.json()["issuer"] == "http://testserver/mcp"
|
|
|
|
prm = client.get("/.well-known/oauth-protected-resource/mcp")
|
|
assert prm.status_code == 200
|
|
assert prm.json()["authorization_servers"] == [asm.json()["issuer"]]
|
|
|
|
# the mcp-named server keeps its own document on the standard two-segment route
|
|
per_server = client.get("/.well-known/oauth-authorization-server/mcp/mcp")
|
|
assert per_server.status_code == 200
|
|
assert "/mcp/authorize" in per_server.json()["authorization_endpoint"]
|
|
finally:
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
|
|
def test_well_known_root_suffix_reflects_server_root_path():
|
|
"""The single path segment both the discovery routes and the 401 challenges insert for RFC
|
|
8414/9728 path insertion: empty for a root-mounted proxy or an explicit ``/``, the configured
|
|
path otherwise. Sharing this one function is what keeps the advertised resource_metadata URL
|
|
equal to the route that serves it."""
|
|
import os
|
|
from unittest.mock import patch
|
|
|
|
from litellm.proxy.mcp_server.oauth_utils import well_known_root_suffix
|
|
|
|
with patch.dict(os.environ, {"SERVER_ROOT_PATH": ""}):
|
|
assert well_known_root_suffix() == ""
|
|
with patch.dict(os.environ, {"SERVER_ROOT_PATH": "/"}):
|
|
assert well_known_root_suffix() == ""
|
|
with patch.dict(os.environ, {"SERVER_ROOT_PATH": "/litellm"}):
|
|
assert well_known_root_suffix() == "/litellm"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_root_resource_uses_gateway_without_changing_authorization_relay():
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
_build_oauth_authorization_server_response,
|
|
_build_oauth_protected_resource_response,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
oauth2_server = _create_oauth2_server()
|
|
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://llm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
try:
|
|
authorization_response = _build_oauth_authorization_server_response(request=mock_request, mcp_server_name=None)
|
|
resource_response = await _build_oauth_protected_resource_response(
|
|
request=mock_request, mcp_server_name=None, use_standard_pattern=True
|
|
)
|
|
assert "/test_oauth/authorize" in authorization_response["authorization_endpoint"]
|
|
assert authorization_response["issuer"] == "https://llm.example.com"
|
|
assert tuple(resource_response["authorization_servers"]) == ("https://llm.example.com/mcp",)
|
|
assert resource_response["resource"] == "https://llm.example.com"
|
|
finally:
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_named_discovery_issuer_matches_protected_resource_authorization_servers():
|
|
"""RFC 8414 requires the issuer to equal the authorization server identifier the client
|
|
resolved the metadata from, which is the protected-resource authorization_servers entry."""
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
_build_oauth_authorization_server_response,
|
|
_build_oauth_protected_resource_response,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
server = _create_oauth2_server(delegate_auth_to_upstream=True)
|
|
global_mcp_server_manager.registry[server.server_id] = server
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://llm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
try:
|
|
resource_response = await _build_oauth_protected_resource_response(
|
|
request=mock_request, mcp_server_name="test_oauth", use_standard_pattern=True
|
|
)
|
|
authorization_response = _build_oauth_authorization_server_response(
|
|
request=mock_request, mcp_server_name="test_oauth"
|
|
)
|
|
assert resource_response["authorization_servers"] == ["https://llm.example.com/test_oauth"]
|
|
assert authorization_response["issuer"] == resource_response["authorization_servers"][0]
|
|
finally:
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_openid_configuration_issuer_stays_bare_origin_for_single_oauth2_server():
|
|
"""The OIDC discovery document is served from the bare origin, so its issuer must stay the
|
|
bare origin even when root discovery resolves the one configured OAuth2 server."""
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
openid_configuration,
|
|
)
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
server = _create_oauth2_server()
|
|
global_mcp_server_manager.registry[server.server_id] = server
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://llm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
try:
|
|
response = await openid_configuration(mock_request)
|
|
assert response["issuer"] == "https://llm.example.com"
|
|
finally:
|
|
global_mcp_server_manager.registry.clear()
|
|
|
|
|
|
def test_gateway_dcr_flow_routing_engages_only_for_llm_dcrc_clients(monkeypatch):
|
|
"""The aggregate DCR arms engage for llm_dcrc_ client_ids (register always mints one,
|
|
authorize/token route into the aggregate flow); a non-gateway client_id keeps the
|
|
per-server behavior, and /authorize/complete exists but 400s without a valid flow."""
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import router
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-test-salt-for-lit3637")
|
|
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", "sk-test-salt-for-lit3637", raising=False)
|
|
global_mcp_server_manager.registry.clear()
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
client = TestClient(app)
|
|
|
|
registered = client.post("/register", json={"redirect_uris": ["https://claude.ai/cb"]})
|
|
assert registered.status_code == 201
|
|
assert registered.json()["client_id"].startswith("llm_dcrc_")
|
|
assert registered.json()["token_endpoint_auth_method"] == "none"
|
|
|
|
authorize_params = {
|
|
"client_id": "llm_dcrc_bogus",
|
|
"redirect_uri": "https://claude.ai/cb",
|
|
"response_type": "code",
|
|
"code_challenge": "c" * 43,
|
|
"code_challenge_method": "S256",
|
|
}
|
|
bogus_client = client.get("/authorize", params=authorize_params)
|
|
assert bogus_client.status_code == 400
|
|
assert bogus_client.json()["error"] == "invalid_client"
|
|
|
|
no_cookie = client.post("/authorize/complete", data={"flow": "h"})
|
|
assert no_cookie.status_code == 400
|
|
assert no_cookie.json()["error"] == "invalid_request"
|
|
|
|
token_response = client.post(
|
|
"/token",
|
|
data={
|
|
"grant_type": "authorization_code",
|
|
"client_id": "llm_dcrc_bogus",
|
|
"code": "x",
|
|
"redirect_uri": "https://claude.ai/cb",
|
|
"code_verifier": "v" * 43,
|
|
},
|
|
)
|
|
assert token_response.status_code == 400
|
|
assert token_response.json()["error"] == "invalid_grant"
|
|
|
|
upstream_shaped = client.post(
|
|
"/token",
|
|
data={"grant_type": "authorization_code", "client_id": "regular-upstream-client", "code": "x"},
|
|
)
|
|
assert upstream_shaped.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authorize_wall_names_the_fix_for_urlless_servers():
|
|
"""LIT-4629: the authorize wall previously said only "authorization url is not set" with no
|
|
hint that spec-only servers never discover; the detail must now name both remedies (manual
|
|
Authorization URL + Token URL, or an Issuer for RFC 8414 discovery)."""
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
authorize_with_server,
|
|
)
|
|
from litellm.types.mcp import MCPAuth, MCPTransport
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
server = MCPServer(
|
|
server_id="urlless-wall",
|
|
name="sheets_wall",
|
|
server_name="sheets_wall",
|
|
url=None,
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
spec_path="https://example.com/openapi.yaml",
|
|
)
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://litellm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await authorize_with_server(
|
|
request=mock_request,
|
|
mcp_server=server,
|
|
client_id="client",
|
|
redirect_uri="http://localhost/callback",
|
|
)
|
|
assert exc_info.value.status_code == 400
|
|
detail_text = str(exc_info.value.detail)
|
|
assert "set Authorization URL and Token URL" in detail_text
|
|
assert "Issuer" in detail_text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_wall_names_the_fix_for_urlless_servers():
|
|
"""The /token wall is the second stop on the same misconfiguration (LIT-4629): after an admin
|
|
fills only the Authorization URL, the code exchange dies here; the detail must name the
|
|
remedies like the authorize wall does."""
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
exchange_token_with_server,
|
|
)
|
|
from litellm.types.mcp import MCPAuth, MCPTransport
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
server = MCPServer(
|
|
server_id="urlless-token-wall",
|
|
name="sheets_token_wall",
|
|
server_name="sheets_token_wall",
|
|
url=None,
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
spec_path="https://example.com/openapi.yaml",
|
|
authorization_url="https://accounts.google.com/o/oauth2/v2/auth",
|
|
)
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://litellm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await exchange_token_with_server(
|
|
request=mock_request,
|
|
mcp_server=server,
|
|
grant_type="authorization_code",
|
|
code="auth-code",
|
|
redirect_uri="http://localhost/callback",
|
|
client_id="client",
|
|
client_secret=None,
|
|
code_verifier="verifier",
|
|
)
|
|
assert exc_info.value.status_code == 400
|
|
detail_text = str(exc_info.value.detail)
|
|
assert "set Token URL manually" in detail_text
|
|
assert "Issuer" in detail_text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_wall_names_the_fix_for_urlless_servers():
|
|
"""The /register wall serves the same missing-authorization-url 400 as authorize; its detail
|
|
must carry the same actionable remedies."""
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
register_client_with_server,
|
|
)
|
|
from litellm.types.mcp import MCPAuth, MCPTransport
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
server = MCPServer(
|
|
server_id="urlless-register-wall",
|
|
name="sheets_register_wall",
|
|
server_name="sheets_register_wall",
|
|
url=None,
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
spec_path="https://example.com/openapi.yaml",
|
|
)
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://litellm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await register_client_with_server(
|
|
request=mock_request,
|
|
mcp_server=server,
|
|
client_name="client",
|
|
grant_types=None,
|
|
response_types=None,
|
|
token_endpoint_auth_method=None,
|
|
)
|
|
assert exc_info.value.status_code == 400
|
|
detail_text = str(exc_info.value.detail)
|
|
assert "set Authorization URL and Token URL" in detail_text
|
|
assert "Issuer" in detail_text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authorize_wall_points_at_discovery_failure_for_url_servers():
|
|
"""LIT-4658: a server WITH a url that still has no authorization_url got here because OAuth
|
|
discovery against that url failed (typically a misconfigured url); the old detail blamed
|
|
"servers with no url", sending the operator down the wrong path. The detail must now name the
|
|
discovery failure and point at the proxy logs where LIT-4658's warnings carry the reason."""
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
authorize_with_server,
|
|
)
|
|
from litellm.types.mcp import MCPAuth, MCPTransport
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
server = MCPServer(
|
|
server_id="typo-url-wall",
|
|
name="typo_wall",
|
|
server_name="typo_wall",
|
|
url="https://typo-host.example.com/mcp",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
)
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://litellm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await authorize_with_server(
|
|
request=mock_request,
|
|
mcp_server=server,
|
|
client_id="client",
|
|
redirect_uri="http://localhost/callback",
|
|
)
|
|
assert exc_info.value.status_code == 400
|
|
detail_text = str(exc_info.value.detail)
|
|
assert "may be misconfigured" in detail_text
|
|
assert "proxy logs" in detail_text
|
|
assert "Servers with no url" not in detail_text
|
|
assert "typo-host.example.com" not in detail_text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_wall_points_at_discovery_failure_for_url_servers():
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
exchange_token_with_server,
|
|
)
|
|
from litellm.types.mcp import MCPAuth, MCPTransport
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
server = MCPServer(
|
|
server_id="typo-url-token-wall",
|
|
name="typo_token_wall",
|
|
server_name="typo_token_wall",
|
|
url="https://typo-host.example.com/mcp",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
authorization_url="https://idp.example.com/authorize",
|
|
)
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://litellm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await exchange_token_with_server(
|
|
request=mock_request,
|
|
mcp_server=server,
|
|
grant_type="authorization_code",
|
|
code="auth-code",
|
|
redirect_uri="http://localhost/callback",
|
|
client_id="client",
|
|
client_secret=None,
|
|
code_verifier="verifier",
|
|
)
|
|
assert exc_info.value.status_code == 400
|
|
detail_text = str(exc_info.value.detail)
|
|
assert "token url is not configured" in detail_text
|
|
assert "may be misconfigured" in detail_text
|
|
assert "Servers with no url" not in detail_text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authorize_wall_names_the_issuer_for_anchored_servers():
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
authorize_with_server,
|
|
)
|
|
from litellm.types.mcp import MCPAuth, MCPTransport
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
server = MCPServer(
|
|
server_id="anchored-wall",
|
|
name="anchored_wall",
|
|
server_name="anchored_wall",
|
|
url="https://up.example.com/mcp",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
issuer="https://idp.example.com",
|
|
issuer_is_anchored=True,
|
|
)
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://litellm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await authorize_with_server(
|
|
request=mock_request,
|
|
mcp_server=server,
|
|
client_id="client",
|
|
redirect_uri="http://localhost/callback",
|
|
)
|
|
assert exc_info.value.status_code == 400
|
|
detail_text = str(exc_info.value.detail)
|
|
assert "verify the Issuer" in detail_text
|
|
assert "Servers with no url" not in detail_text
|
|
assert "idp.example.com" not in detail_text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authorize_uses_admin_entered_github_oauth_urls_after_issuer_yield(monkeypatch):
|
|
"""GitHub MCP servers store Authorization URL and Token URL on the row. 1.99 can empty
|
|
the resolved authorization_url when a leftover issuer is treated as a pin (RFC 8414
|
|
yield). The UI authorize must still redirect to the admin-entered GitHub authorize URL
|
|
instead of 400ing that discovery against api.githubcopilot.com failed."""
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
authorize_with_server,
|
|
)
|
|
from litellm.types.mcp import MCPAuth, MCPTransport
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
server = MCPServer(
|
|
server_id="ecac50c4-8eca-438a-af80-9bdebadafc69",
|
|
name="github_mcp",
|
|
alias="github_mcp",
|
|
server_name="github_mcp",
|
|
url="https://api.githubcopilot.com/mcp/",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
oauth2_flow="authorization_code",
|
|
client_id="github-app-client",
|
|
authorization_url=None,
|
|
token_url=None,
|
|
issuer="https://github.com",
|
|
issuer_is_anchored=True,
|
|
configured_authorization_url="https://github.com/login/oauth/authorize",
|
|
configured_token_url="https://github.com/login/oauth/access_token",
|
|
)
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://litellm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-test-salt-for-lit-6255")
|
|
response = await authorize_with_server(
|
|
request=mock_request,
|
|
mcp_server=server,
|
|
client_id="github-app-client",
|
|
redirect_uri="http://127.0.0.1:60108/callback",
|
|
state="state123",
|
|
)
|
|
|
|
assert response.status_code == 307
|
|
assert "https://github.com/login/oauth/authorize" in response.headers["location"]
|
|
assert "client_id=github-app-client" in response.headers["location"]
|
|
|
|
|
|
def test_oauth_endpoints_count_admin_entered_urls_as_resolved():
|
|
"""A leftover issuer empties the resolved authorize/token fields but must not keep the
|
|
server on the deferred-discovery retry path when the admin already stored those URLs."""
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
oauth_endpoints_unresolved,
|
|
)
|
|
from litellm.types.mcp import MCPAuth, MCPTransport
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
server = MCPServer(
|
|
server_id="github-configured",
|
|
name="github_mcp",
|
|
url="https://api.githubcopilot.com/mcp/",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
oauth2_flow="authorization_code",
|
|
authorization_url=None,
|
|
token_url=None,
|
|
configured_authorization_url="https://github.com/login/oauth/authorize",
|
|
configured_token_url="https://github.com/login/oauth/access_token",
|
|
)
|
|
assert oauth_endpoints_unresolved(server) is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_exchange_with_configured_token_url_never_joins_discovery(monkeypatch):
|
|
"""A server can hold an admin-entered Token URL while its Authorization URL is absent. The
|
|
token exchange must post to that stored endpoint without awaiting deferred discovery, which
|
|
can 503 against an unreachable issuer even though nothing it resolves is needed here."""
|
|
from litellm.proxy.mcp_server import (
|
|
discoverable_endpoints,
|
|
mcp_server_manager,
|
|
)
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
exchange_token_with_server,
|
|
)
|
|
from litellm.types.mcp import MCPAuth, MCPTransport
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
server = MCPServer(
|
|
server_id="token-url-only",
|
|
name="token_url_only",
|
|
server_name="token_url_only",
|
|
alias="token_url_only",
|
|
url="https://mcp.example.com/mcp/",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id="cid",
|
|
client_secret="cs",
|
|
authorization_url=None,
|
|
token_url=None,
|
|
issuer="https://idp.example.com",
|
|
issuer_is_anchored=True,
|
|
configured_token_url="https://idp.example.com/oauth/token",
|
|
)
|
|
|
|
async def fail_discovery(_srv):
|
|
raise AssertionError("the exchange joined deferred discovery despite a stored token url")
|
|
|
|
monkeypatch.setattr(
|
|
mcp_server_manager.global_mcp_server_manager,
|
|
"ensure_oauth_metadata_discovered",
|
|
fail_discovery,
|
|
)
|
|
fake_http_response = MagicMock()
|
|
fake_http_response.json.return_value = {"access_token": "tok", "token_type": "Bearer"}
|
|
fake_http_response.raise_for_status = MagicMock()
|
|
fake_http_client = MagicMock()
|
|
fake_http_client.post = AsyncMock(return_value=fake_http_response)
|
|
monkeypatch.setattr(
|
|
discoverable_endpoints,
|
|
"get_async_httpx_client",
|
|
lambda llm_provider: fake_http_client,
|
|
)
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://litellm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
response = await exchange_token_with_server(
|
|
request=mock_request,
|
|
mcp_server=server,
|
|
grant_type="authorization_code",
|
|
code="upstream-code",
|
|
redirect_uri="http://127.0.0.1:3000/cb",
|
|
client_id="cid",
|
|
client_secret=None,
|
|
code_verifier=None,
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert fake_http_client.post.await_args.args[0] == "https://idp.example.com/oauth/token"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_root_token_route_with_configured_token_url_never_joins_discovery(monkeypatch):
|
|
"""A root POST /token that falls back to the sole OAuth2 server must reach the exchange's
|
|
endpoint-gated discovery join instead of awaiting full discovery at the route: with the
|
|
token url admin-entered, a failing or slow discovery must not turn the exchange into a 503."""
|
|
from litellm.proxy.mcp_server import (
|
|
discoverable_endpoints,
|
|
mcp_server_manager,
|
|
)
|
|
from litellm.types.mcp import MCPAuth, MCPTransport
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
manager = mcp_server_manager.global_mcp_server_manager
|
|
server = MCPServer(
|
|
server_id="sole-token-url-only",
|
|
name="sole_token_url_only",
|
|
server_name="sole_token_url_only",
|
|
alias="sole_token_url_only",
|
|
url="https://mcp.example.com/mcp/",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id="cid",
|
|
client_secret="cs",
|
|
issuer="https://idp.example.com",
|
|
issuer_is_anchored=True,
|
|
configured_token_url="https://idp.example.com/oauth/token",
|
|
)
|
|
saved_registry = dict(manager.registry)
|
|
manager.registry.clear()
|
|
manager.registry[server.server_id] = server
|
|
manager._set_oauth_discovery_deferred(server.server_id, True)
|
|
|
|
async def fail_discovery(_srv):
|
|
raise AssertionError("the root token route joined deferred discovery despite a stored token url")
|
|
|
|
monkeypatch.setattr(manager, "ensure_oauth_metadata_discovered", fail_discovery)
|
|
fake_http_response = MagicMock()
|
|
fake_http_response.json.return_value = {"access_token": "tok", "token_type": "Bearer"}
|
|
fake_http_response.raise_for_status = MagicMock()
|
|
fake_http_client = MagicMock()
|
|
fake_http_client.post = AsyncMock(return_value=fake_http_response)
|
|
monkeypatch.setattr(
|
|
discoverable_endpoints,
|
|
"get_async_httpx_client",
|
|
lambda llm_provider: fake_http_client,
|
|
)
|
|
request = _mock_callback_request("https://litellm.example.com/")
|
|
|
|
try:
|
|
response = await discoverable_endpoints.token_endpoint(
|
|
request=request,
|
|
grant_type="authorization_code",
|
|
code="upstream-code",
|
|
redirect_uri="http://127.0.0.1:3000/cb",
|
|
client_id="unregistered-dcr-client",
|
|
)
|
|
finally:
|
|
manager.registry.clear()
|
|
manager.registry.update(saved_registry)
|
|
|
|
assert response.status_code == 200
|
|
assert fake_http_client.post.await_args.args[0] == "https://idp.example.com/oauth/token"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bridge_authorize_relays_with_registration_url_resolved_by_deferred_discovery(monkeypatch):
|
|
"""When deferred discovery resolves a DCR-bridge server during the authorize request, the
|
|
relay-vs-short-circuit call must read the resolved server: a client that registered itself
|
|
through the front door keeps its own redirect binding instead of being routed through the
|
|
gateway callback the upstream never granted it."""
|
|
from litellm.proxy.mcp_server import mcp_server_manager
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
authorize_with_server,
|
|
)
|
|
from litellm.types.mcp import MCPAuth, MCPTransport
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
server = MCPServer(
|
|
server_id="bridge-deferred",
|
|
name="bridge_deferred",
|
|
server_name="bridge_deferred",
|
|
alias="bridge_deferred",
|
|
url="https://mcp.example.com/mcp/",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.true_passthrough,
|
|
dcr_bridge=True,
|
|
authorization_url=None,
|
|
token_url=None,
|
|
registration_url=None,
|
|
)
|
|
resolved = server.model_copy(
|
|
update={
|
|
"authorization_url": "https://idp.example.com/oauth/authorize",
|
|
"token_url": "https://idp.example.com/oauth/token",
|
|
"registration_url": "https://idp.example.com/oauth/register",
|
|
}
|
|
)
|
|
|
|
async def resolve_discovery(_srv):
|
|
return resolved
|
|
|
|
monkeypatch.setattr(
|
|
mcp_server_manager.global_mcp_server_manager,
|
|
"ensure_oauth_metadata_discovered",
|
|
resolve_discovery,
|
|
)
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://litellm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
response = await authorize_with_server(
|
|
request=mock_request,
|
|
mcp_server=server,
|
|
client_id="front-door-client",
|
|
redirect_uri="http://127.0.0.1:60110/client-callback",
|
|
state="state456",
|
|
code_challenge="E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
|
code_challenge_method="S256",
|
|
)
|
|
|
|
assert response.status_code == 307
|
|
location = response.headers["location"]
|
|
assert location.startswith("https://idp.example.com/oauth/authorize")
|
|
assert "redirect_uri=http%3A%2F%2F127.0.0.1%3A60110%2Fclient-callback" in location
|
|
|
|
|
|
def test_passthrough_authorization_code_round_trips_and_rejects_hostile_input():
|
|
"""The passthrough gateway code seals and recovers the ephemeral DCR client and upstream code,
|
|
and is total over hostile input: a raw upstream code opens to None, and a tampered or
|
|
non-gateway value opens to None rather than raising, so every existing caller-supplied-client
|
|
flow is untouched."""
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
open_passthrough_authorization_code,
|
|
seal_passthrough_authorization_code,
|
|
)
|
|
|
|
with patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY):
|
|
sealed = seal_passthrough_authorization_code(
|
|
upstream_code="up-code",
|
|
client_id="minted-77",
|
|
client_secret="mint-secret",
|
|
mcp_server_id="srv-1",
|
|
token_endpoint_auth_method="client_secret_basic",
|
|
)
|
|
opened = open_passthrough_authorization_code(sealed)
|
|
assert opened is not None
|
|
assert opened.upstream_code == "up-code"
|
|
assert opened.client_id == "minted-77"
|
|
assert opened.client_secret == "mint-secret"
|
|
assert opened.mcp_server_id == "srv-1"
|
|
assert opened.token_endpoint_auth_method == "client_secret_basic"
|
|
assert open_passthrough_authorization_code("raw-upstream-code") is None
|
|
assert open_passthrough_authorization_code(sealed[:-4] + "aaaa") is None
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
_BRIDGE_AUTH_CODE_PREFIX,
|
|
_PASSTHROUGH_AUTH_CODE_PREFIX,
|
|
open_bridge_authorization_code,
|
|
seal_bridge_authorization_code,
|
|
)
|
|
|
|
bridge_sealed = seal_bridge_authorization_code(
|
|
upstream_code="up-code", litellm_user_id="sso-user-9", mcp_server_id="srv-1"
|
|
)
|
|
reprefixed_as_passthrough = _PASSTHROUGH_AUTH_CODE_PREFIX + bridge_sealed[len(_BRIDGE_AUTH_CODE_PREFIX) :]
|
|
reprefixed_as_bridge = _BRIDGE_AUTH_CODE_PREFIX + sealed[len(_PASSTHROUGH_AUTH_CODE_PREFIX) :]
|
|
assert open_passthrough_authorization_code(reprefixed_as_passthrough) is None
|
|
assert open_bridge_authorization_code(reprefixed_as_bridge) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authorize_with_ephemeral_dcr_client_seals_client_into_state():
|
|
"""When mcp_authorize fell through to a gateway-side DCR mint, authorize_with_server seals the
|
|
minted client and the target server into the encrypted OAuth state, so the callback can bind
|
|
them into the forwarded authorization code while the gateway stores nothing."""
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
EphemeralDcrClient,
|
|
authorize_with_server,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(auth_type=MCPAuth.true_passthrough, dcr_bridge=None)
|
|
captured: dict = {}
|
|
|
|
def _capture(**kwargs):
|
|
captured.update(kwargs)
|
|
return "mocked_encrypted_state"
|
|
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.encode_state_with_base_url",
|
|
side_effect=_capture,
|
|
):
|
|
response = await authorize_with_server(
|
|
request=_bridge_mock_request(),
|
|
mcp_server=server,
|
|
client_id="minted-77",
|
|
redirect_uri="http://127.0.0.1:60108/callback",
|
|
state="s",
|
|
code_challenge="chal",
|
|
code_challenge_method="S256",
|
|
ephemeral_dcr_client=EphemeralDcrClient(
|
|
client_id="minted-77", client_secret="mint-secret", token_endpoint_auth_method="client_secret_basic"
|
|
),
|
|
)
|
|
|
|
assert captured["dcr_client_id"] == "minted-77"
|
|
assert captured["dcr_client_secret"] == "mint-secret"
|
|
assert captured["dcr_token_endpoint_auth_method"] == "client_secret_basic"
|
|
assert captured["mcp_server_id"] == server.server_id
|
|
assert "client_id=minted-77" in response.headers["location"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_callback_wraps_code_into_passthrough_code_for_ephemeral_dcr_state():
|
|
"""When the OAuth state carries an ephemeral DCR client, the callback forwards a sealed
|
|
passthrough code (binding the client and the upstream code to the server) instead of the raw
|
|
upstream code, so the client's later token call can authenticate the exchange with a client the
|
|
gateway never stored."""
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
callback,
|
|
open_passthrough_authorization_code,
|
|
)
|
|
|
|
state_data = {
|
|
"original_state": "client-state",
|
|
"client_redirect_uri": "http://127.0.0.1:60108/cb",
|
|
"base_url": "http://127.0.0.1:60108/cb",
|
|
"mcp_server_id": "srv-1",
|
|
"dcr_client_id": "minted-77",
|
|
"dcr_client_secret": "mint-secret",
|
|
"dcr_token_endpoint_auth_method": "client_secret_basic",
|
|
}
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints._resolve_encoded_oauth_state",
|
|
return_value="enc",
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.decode_state_hash",
|
|
return_value=state_data,
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints._get_validated_client_redirect_uri",
|
|
return_value="http://127.0.0.1:60108/cb",
|
|
),
|
|
patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY),
|
|
):
|
|
response = await callback(request=_bridge_mock_request(), code="REAL-UPSTREAM-CODE", state="relay")
|
|
|
|
forwarded_code = parse_qs(urlparse(response.headers["location"]).query)["code"][0]
|
|
opened = open_passthrough_authorization_code(forwarded_code)
|
|
|
|
assert opened is not None
|
|
assert opened.upstream_code == "REAL-UPSTREAM-CODE"
|
|
assert opened.client_id == "minted-77"
|
|
assert opened.client_secret == "mint-secret"
|
|
assert opened.mcp_server_id == "srv-1"
|
|
assert opened.token_endpoint_auth_method == "client_secret_basic"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authorize_bridge_server_with_ephemeral_client_takes_short_circuit_arm():
|
|
"""A gateway-minted client is registered against {base}/callback, so a bridge server's
|
|
authorize with an ephemeral client must run the short-circuit (gateway /callback) arm with a
|
|
relay state cookie, never the verbatim relay: relaying would send the browser's redirect_uri
|
|
to an IdP that has the gateway callback registered, stranding the flow."""
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
EphemeralDcrClient,
|
|
authorize_with_server,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(auth_type=MCPAuth.true_passthrough)
|
|
with patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY):
|
|
response = await authorize_with_server(
|
|
request=_bridge_mock_request(),
|
|
mcp_server=server,
|
|
client_id="minted-77",
|
|
redirect_uri="http://127.0.0.1:60108/callback",
|
|
state="client-state",
|
|
code_challenge="chal",
|
|
code_challenge_method="S256",
|
|
ephemeral_dcr_client=EphemeralDcrClient(client_id="minted-77", client_secret=None),
|
|
)
|
|
|
|
location = response.headers["location"]
|
|
params = parse_qs(urlparse(location).query)
|
|
assert params["redirect_uri"] == ["https://litellm.example.com/callback"]
|
|
assert params["client_id"] == ["minted-77"]
|
|
assert params["state"] != ["client-state"]
|
|
assert any(cookie.startswith("mcp_oauth_state_") for cookie in response.headers.get("set-cookie", "").split(";"))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_callback_forwards_raw_code_when_dcr_state_lacks_server_binding():
|
|
"""A state carrying a dcr client but no server id cannot produce a server-bound sealed code, so
|
|
the callback falls back to forwarding the raw upstream code instead of sealing an unbindable
|
|
one."""
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import callback
|
|
|
|
state_data = {
|
|
"original_state": "client-state",
|
|
"client_redirect_uri": "http://127.0.0.1:60108/cb",
|
|
"base_url": "http://127.0.0.1:60108/cb",
|
|
"dcr_client_id": "minted-77",
|
|
}
|
|
with (
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints._resolve_encoded_oauth_state",
|
|
return_value="enc",
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.decode_state_hash",
|
|
return_value=state_data,
|
|
),
|
|
patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints._get_validated_client_redirect_uri",
|
|
return_value="http://127.0.0.1:60108/cb",
|
|
),
|
|
patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY),
|
|
):
|
|
response = await callback(request=_bridge_mock_request(), code="REAL-UPSTREAM-CODE", state="relay")
|
|
|
|
forwarded_code = parse_qs(urlparse(response.headers["location"]).query)["code"][0]
|
|
assert forwarded_code == "REAL-UPSTREAM-CODE"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"auth_type_value",
|
|
[
|
|
"none",
|
|
"api_key",
|
|
"bearer_token",
|
|
"basic",
|
|
"authorization",
|
|
"oauth2",
|
|
"aws_sigv4",
|
|
"token",
|
|
"oauth2_token_exchange",
|
|
"oauth2_id_jag",
|
|
"true_passthrough",
|
|
"oauth_delegate",
|
|
],
|
|
)
|
|
@pytest.mark.parametrize("dcr_bridge", [True, False])
|
|
async def test_resolve_ephemeral_dcr_client_mint_set_is_exact(auth_type_value, dcr_bridge):
|
|
"""The full authorize-time mint decision matrix, one cell per (auth_type, dcr_bridge). The gateway
|
|
mints iff true_passthrough (any bridge) or oauth_delegate-and-not-dcr_bridge; every other mode
|
|
returns None so no non-OAuth mode ever registers an upstream client, and the interactive
|
|
oauth_delegate dcr_bridge sign-in is left to its own browser-front-door flow. The UI
|
|
gatewayMintsClientFor helper mirrors this exact set; ui/.../mcp_tools/types.test.tsx pins the
|
|
frontend side against the same table, so a divergence fails on one side or the other."""
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
EphemeralDcrClient,
|
|
resolve_ephemeral_dcr_client,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(
|
|
auth_type=MCPAuth(auth_type_value),
|
|
dcr_bridge=dcr_bridge,
|
|
server_id=f"matrix_{auth_type_value}_{dcr_bridge}",
|
|
server_name=f"matrix_{auth_type_value}_{dcr_bridge}",
|
|
)
|
|
expected_mint = server.is_true_passthrough or (server.is_oauth_delegate and not server.is_dcr_bridge)
|
|
mint_mock = AsyncMock(return_value=EphemeralDcrClient(client_id="minted", client_secret=None))
|
|
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.mint_ephemeral_dcr_client",
|
|
mint_mock,
|
|
):
|
|
result = await resolve_ephemeral_dcr_client(
|
|
request=_bridge_mock_request(),
|
|
mcp_server=server,
|
|
code_challenge="chal",
|
|
code_challenge_method="S256",
|
|
redirect_uri="http://127.0.0.1:9/callback",
|
|
)
|
|
|
|
if expected_mint:
|
|
mint_mock.assert_awaited_once()
|
|
assert result is not None
|
|
else:
|
|
mint_mock.assert_not_awaited()
|
|
assert result is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mint_ephemeral_dcr_client_returns_none_without_registration_endpoint():
|
|
"""A server whose upstream exposes no RFC 7591 registration endpoint cannot mint, so the
|
|
fall-through reports None and the caller keeps its existing missing_client_id failure."""
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
mint_ephemeral_dcr_client,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(auth_type=MCPAuth.true_passthrough, dcr_bridge=None, registration_url=None)
|
|
assert await mint_ephemeral_dcr_client(_bridge_mock_request(), server) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mint_ephemeral_dcr_client_posts_rfc7591_and_returns_client():
|
|
"""The mint POSTs a public-client RFC 7591 registration bound to the gateway /callback and hands
|
|
back the upstream's client without persisting it anywhere."""
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
mint_ephemeral_dcr_client,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(
|
|
auth_type=MCPAuth.true_passthrough, dcr_bridge=None, server_id="mint_posts_srv", server_name="mint_posts_srv"
|
|
)
|
|
mock_response = MagicMock()
|
|
mock_response.text = json.dumps(
|
|
{"client_id": "minted-77", "client_secret": "mint-secret", "token_endpoint_auth_method": "client_secret_basic"}
|
|
)
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_async_client = MagicMock()
|
|
mock_async_client.post = AsyncMock(return_value=mock_response)
|
|
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=mock_async_client,
|
|
):
|
|
minted = await mint_ephemeral_dcr_client(_bridge_mock_request(), server)
|
|
|
|
assert minted is not None
|
|
assert minted.client_id == "minted-77"
|
|
assert minted.client_secret == "mint-secret"
|
|
assert minted.token_endpoint_auth_method == "client_secret_basic"
|
|
register_data = mock_async_client.post.call_args.kwargs["json"]
|
|
assert register_data["redirect_uris"] == ["https://litellm.example.com/callback"]
|
|
assert register_data["token_endpoint_auth_method"] == "none"
|
|
assert register_data["grant_types"] == ["authorization_code", "refresh_token"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mint_ephemeral_dcr_client_reuses_minted_client_within_flow_ttl():
|
|
"""Reloading the authorize page must not spam the upstream registration endpoint with orphan
|
|
clients: within the OAuth state's lifetime a second mint for the same server and gateway origin
|
|
reuses the cached client and performs no second upstream POST."""
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
mint_ephemeral_dcr_client,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(
|
|
auth_type=MCPAuth.true_passthrough, dcr_bridge=None, server_id="mint_reuse_srv", server_name="mint_reuse_srv"
|
|
)
|
|
mock_response = MagicMock()
|
|
mock_response.text = json.dumps({"client_id": "minted-77"})
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_async_client = MagicMock()
|
|
mock_async_client.post = AsyncMock(return_value=mock_response)
|
|
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=mock_async_client,
|
|
):
|
|
first = await mint_ephemeral_dcr_client(_bridge_mock_request(), server)
|
|
second = await mint_ephemeral_dcr_client(_bridge_mock_request(), server)
|
|
|
|
assert first is not None
|
|
assert second == first
|
|
mock_async_client.post.assert_called_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mint_ephemeral_dcr_client_single_flights_concurrent_mints():
|
|
"""Two in-flight authorize requests for the same server must not both register an upstream
|
|
client: the per-key lock makes the second waiter reuse the first mint, so exactly one upstream
|
|
POST happens."""
|
|
import asyncio
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
mint_ephemeral_dcr_client,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(
|
|
auth_type=MCPAuth.true_passthrough,
|
|
dcr_bridge=None,
|
|
server_id="mint_concurrent_srv",
|
|
server_name="mint_concurrent_srv",
|
|
)
|
|
mock_response = MagicMock()
|
|
mock_response.text = json.dumps({"client_id": "minted-77"})
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
async def _slow_post(*args, **kwargs):
|
|
await asyncio.sleep(0.05)
|
|
return mock_response
|
|
|
|
mock_async_client = MagicMock()
|
|
mock_async_client.post = AsyncMock(side_effect=_slow_post)
|
|
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=mock_async_client,
|
|
):
|
|
first, second = await asyncio.gather(
|
|
mint_ephemeral_dcr_client(_bridge_mock_request(), server),
|
|
mint_ephemeral_dcr_client(_bridge_mock_request(), server),
|
|
)
|
|
|
|
assert first is not None
|
|
assert second == first
|
|
mock_async_client.post.assert_called_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"payload, server_id",
|
|
[
|
|
({"unexpected": "shape"}, "mint_bad_shape_srv"),
|
|
({"client_id": ""}, "mint_empty_id_srv"),
|
|
],
|
|
)
|
|
async def test_mint_ephemeral_dcr_client_unusable_registration_response_is_502(payload, server_id):
|
|
"""An upstream registration response without a usable client_id, whether the field is missing or
|
|
an empty string, surfaces as a loud 502 instead of letting the authorize proceed with an empty
|
|
client and fail opaquely at the IdP."""
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
mint_ephemeral_dcr_client,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(
|
|
auth_type=MCPAuth.true_passthrough, dcr_bridge=None, server_id=server_id, server_name=server_id
|
|
)
|
|
mock_response = MagicMock()
|
|
mock_response.text = json.dumps(payload)
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_async_client = MagicMock()
|
|
mock_async_client.post = AsyncMock(return_value=mock_response)
|
|
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=mock_async_client,
|
|
):
|
|
with pytest.raises(HTTPException) as exc:
|
|
await mint_ephemeral_dcr_client(_bridge_mock_request(), server)
|
|
|
|
assert exc.value.status_code == 502
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"sealed_auth_method, expects_basic_header",
|
|
[
|
|
("client_secret_basic", True),
|
|
(None, False),
|
|
],
|
|
)
|
|
async def test_token_exchange_authenticates_with_the_sealed_clients_own_auth_method(
|
|
sealed_auth_method, expects_basic_header
|
|
):
|
|
"""The id, secret, and token-endpoint auth method must come from the same source: a client
|
|
recovered from a sealed passthrough code authenticates the upstream exchange the way its own
|
|
registration was granted, not the way the server row is configured. A sealed
|
|
``client_secret_basic`` grant sends the Basic header and keeps the secret out of the body; a
|
|
sealed public client (no method) keeps the body-credential path."""
|
|
import base64
|
|
|
|
import httpx
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
exchange_token_with_server,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _bridge_server(auth_type=MCPAuth.true_passthrough, dcr_bridge=None, server_id="sealed_method_srv")
|
|
upstream_request = httpx.Request("POST", server.token_url)
|
|
upstream_response = httpx.Response(
|
|
200, json={"access_token": "up-token", "token_type": "Bearer"}, request=upstream_request
|
|
)
|
|
mock_async_client = MagicMock()
|
|
mock_async_client.post = AsyncMock(return_value=upstream_response)
|
|
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=mock_async_client,
|
|
):
|
|
await exchange_token_with_server(
|
|
request=_bridge_mock_request(),
|
|
mcp_server=server,
|
|
grant_type="authorization_code",
|
|
code="up-code",
|
|
redirect_uri="https://litellm.example.com/callback",
|
|
client_id="minted-77",
|
|
client_secret="mint-secret",
|
|
code_verifier="verifier",
|
|
client_token_endpoint_auth_method=sealed_auth_method,
|
|
)
|
|
|
|
sent_headers = mock_async_client.post.call_args.kwargs["headers"]
|
|
sent_body = mock_async_client.post.call_args.kwargs["data"]
|
|
if expects_basic_header:
|
|
expected = base64.b64encode(b"minted-77:mint-secret").decode()
|
|
assert sent_headers["Authorization"] == f"Basic {expected}"
|
|
assert "client_secret" not in sent_body
|
|
else:
|
|
assert "Authorization" not in sent_headers
|
|
assert sent_body["client_id"] == "minted-77"
|
|
assert sent_body["client_secret"] == "mint-secret"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# LIT-4339: RFC 8707 resource indicators on the upstream OAuth legs
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _resource_server(**overrides) -> "MCPServer":
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
defaults = dict(
|
|
server_id="res-srv",
|
|
name="res-srv",
|
|
server_name="res-srv",
|
|
alias="res-srv",
|
|
url="https://mcp.example.com/mcp",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id="gateway-client",
|
|
client_secret="gateway-secret",
|
|
authorization_url="https://idp.example.com/oauth/authorize",
|
|
token_url="https://idp.example.com/oauth/token",
|
|
)
|
|
defaults.update(overrides)
|
|
return MCPServer(**defaults)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"url, configured, expected",
|
|
[
|
|
("https://mcp.example.com/mcp", None, None),
|
|
("https://mcp.example.com/mcp", "", None),
|
|
("https://mcp.example.com/mcp", " ", None),
|
|
("https://mcp.example.com/mcp", "auto", "https://mcp.example.com/mcp"),
|
|
("https://mcp.example.com/mcp", "AUTO", "https://mcp.example.com/mcp"),
|
|
("https://mcp.example.com/mcp/", "auto", "https://mcp.example.com/mcp"),
|
|
("https://mcp.example.com/", "auto", "https://mcp.example.com"),
|
|
("https://MCP.Example.COM/mcp", "auto", "https://mcp.example.com/mcp"),
|
|
("HTTPS://mcp.example.com/mcp", "auto", "https://mcp.example.com/mcp"),
|
|
("https://mcp.example.com/mcp#frag", "auto", "https://mcp.example.com/mcp"),
|
|
("https://mcp.example.com:8443/server/mcp", "auto", "https://mcp.example.com:8443/server/mcp"),
|
|
# The scheme's default port is dropped, so :443/:80 never present as a different resource than
|
|
# the portless form against the strict authorization servers this feature targets.
|
|
("https://mcp.example.com:443/mcp", "auto", "https://mcp.example.com/mcp"),
|
|
("http://mcp.example.com:80/mcp", "auto", "http://mcp.example.com/mcp"),
|
|
# IPv6 authority keeps its brackets (a bare ::1:8080 would be a malformed authority).
|
|
("https://[::1]:8080/mcp", "auto", "https://[::1]:8080/mcp"),
|
|
("https://[::1]:443/mcp", "auto", "https://[::1]/mcp"),
|
|
("https://mcp.example.com/Server/MCP", "auto", "https://mcp.example.com/Server/MCP"),
|
|
("https://User:PaSs@MCP.Example.com/mcp", "auto", "https://mcp.example.com/mcp"),
|
|
("https://token@MCP.Example.com/mcp", "auto", "https://mcp.example.com/mcp"),
|
|
("https://mcp.example.com/mcp?api_key=s3cr3t", "auto", "https://mcp.example.com/mcp"),
|
|
("https://u:p@MCP.Example.com:8443/mcp/?token=abc#frag", "auto", "https://mcp.example.com:8443/mcp"),
|
|
("mcp.example.com/mcp", "auto", None),
|
|
(None, "auto", None),
|
|
("https://mcp.example.com/mcp", "api://custom-audience", "api://custom-audience"),
|
|
("https://mcp.example.com/mcp", " https://Other.example.com/RS/ ", "https://Other.example.com/RS/"),
|
|
],
|
|
)
|
|
def test_resolve_upstream_resource_tristate_and_canonicalization(url, configured, expected):
|
|
"""The knob is a tri-state: unset/blank omits the parameter, ``auto`` derives the MCP spec's
|
|
canonical server URI from the server url, and anything else is sent verbatim.
|
|
|
|
Canonicalization follows the MCP authorization spec: lowercase scheme and host, drop the scheme's
|
|
default port, drop the fragment (RFC 8707 forbids one), drop the query and userinfo (credential
|
|
hygiene), and drop a trailing slash, while preserving a non-default port and the path case. An
|
|
explicit value is never canonicalized, because it has to match what the authorization server
|
|
expects byte for byte."""
|
|
from litellm.proxy.mcp_server.oauth_utils import resolve_upstream_resource
|
|
|
|
assert resolve_upstream_resource(_resource_server(url=url, upstream_resource=configured)) == expected
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"configured, url, expected_resource",
|
|
[
|
|
(None, "https://mcp.example.com/mcp", None),
|
|
("auto", "https://MCP.Example.com/mcp/", "https://mcp.example.com/mcp"),
|
|
("api://audience", "https://mcp.example.com/mcp", "api://audience"),
|
|
],
|
|
)
|
|
def test_build_upstream_oauth2_token_request_bundles_resource_with_client_auth(configured, url, expected_resource):
|
|
"""Every plain-OAuth2 token leg (authorization_code, refresh_token, client_credentials) builds its
|
|
request body through this one helper, so the RFC 8707 resource is resolved in the same call as the
|
|
mandatory client authentication and no leg can authenticate without also naming the resource its
|
|
sibling legs named. A leg that reverted to hand-building its body would drop the resource and
|
|
diverge from the authorize leg, which a strict authorization server rejects as invalid_target."""
|
|
from litellm.proxy.mcp_server.oauth_utils import build_upstream_oauth2_token_request
|
|
|
|
req = build_upstream_oauth2_token_request(
|
|
_resource_server(url=url, upstream_resource=configured),
|
|
auth_method=None,
|
|
client_id="cid",
|
|
client_secret="sec",
|
|
)
|
|
assert req.body.get("resource") == expected_resource
|
|
assert req.body["client_id"] == "cid"
|
|
assert req.body["client_secret"] == "sec"
|
|
|
|
|
|
def test_build_upstream_oauth2_token_request_client_secret_basic_keeps_secret_out_of_body():
|
|
"""client_secret_basic authenticates through the Authorization header, so the secret must never
|
|
also appear in the body, while the RFC 8707 resource still rides in the body."""
|
|
import base64
|
|
|
|
from litellm.proxy.mcp_server.oauth_utils import build_upstream_oauth2_token_request
|
|
|
|
req = build_upstream_oauth2_token_request(
|
|
_resource_server(upstream_resource="api://audience"),
|
|
auth_method="client_secret_basic",
|
|
client_id="cid",
|
|
client_secret="sec",
|
|
)
|
|
assert req.headers["Authorization"] == "Basic " + base64.b64encode(b"cid:sec").decode()
|
|
assert "client_secret" not in req.body
|
|
assert "client_id" not in req.body
|
|
assert req.body["resource"] == "api://audience"
|
|
|
|
|
|
async def _authorize_query(server) -> dict:
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
authorize_with_server,
|
|
)
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://litellm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
with patch("litellm.proxy.mcp_server.discoverable_endpoints.encrypt_value_helper") as mock_encrypt:
|
|
mock_encrypt.return_value = "encrypted_state"
|
|
response = await authorize_with_server(
|
|
request=mock_request,
|
|
mcp_server=server,
|
|
client_id="caller-client",
|
|
redirect_uri="http://localhost:3000/callback",
|
|
state="client-state",
|
|
code_challenge="challenge",
|
|
code_challenge_method="S256",
|
|
response_type="code",
|
|
scope=None,
|
|
)
|
|
return parse_qs(urlparse(response.headers["location"]).query)
|
|
|
|
|
|
async def _token_body(server, grant_type: str) -> dict:
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
exchange_token_with_server,
|
|
)
|
|
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.base_url = "https://litellm.example.com/"
|
|
mock_request.headers = {}
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_response.json.return_value = {"access_token": "at", "token_type": "Bearer"}
|
|
mock_async_client = MagicMock()
|
|
mock_async_client.post = AsyncMock(return_value=mock_response)
|
|
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=mock_async_client,
|
|
):
|
|
await exchange_token_with_server(
|
|
request=mock_request,
|
|
mcp_server=server,
|
|
grant_type=grant_type,
|
|
code="auth-code" if grant_type == "authorization_code" else None,
|
|
redirect_uri="https://litellm.example.com/callback",
|
|
client_id="caller-client",
|
|
client_secret=None,
|
|
code_verifier="verifier",
|
|
refresh_token="upstream-refresh" if grant_type == "refresh_token" else None,
|
|
)
|
|
return mock_async_client.post.call_args.kwargs["data"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upstream_resource_unset_sends_no_resource_on_any_leg():
|
|
"""Default behavior is unchanged: with the knob unset the gateway sends no RFC 8707 resource
|
|
on the authorize leg or on either token grant, so every server working today keeps working
|
|
(notably the authorization servers that hard-reject the parameter)."""
|
|
server = _resource_server()
|
|
|
|
assert "resource" not in await _authorize_query(server)
|
|
assert "resource" not in await _token_body(server, "authorization_code")
|
|
assert "resource" not in await _token_body(server, "refresh_token")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upstream_resource_auto_sends_same_canonical_uri_on_every_leg():
|
|
"""The cross-leg invariant. RFC 8707 requires the token request to name a resource the
|
|
authorization request already asked for, so the authorize leg and both token grants must send
|
|
an identical value; they all resolve through one helper to make that structural. Deleting the
|
|
resolve call at any single leg fails this test."""
|
|
server = _resource_server(url="https://MCP.Example.com/mcp/", upstream_resource="auto")
|
|
canonical = "https://mcp.example.com/mcp"
|
|
|
|
assert (await _authorize_query(server))["resource"] == [canonical]
|
|
assert (await _token_body(server, "authorization_code"))["resource"] == canonical
|
|
assert (await _token_body(server, "refresh_token"))["resource"] == canonical
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upstream_resource_explicit_value_is_sent_verbatim_on_every_leg():
|
|
"""An explicit identifier is never canonicalized or derived from the url; authorization servers
|
|
match the resource exactly, so an operator-supplied value goes out byte for byte."""
|
|
server = _resource_server(upstream_resource="api://7c9f-audience/.default")
|
|
|
|
assert (await _authorize_query(server))["resource"] == ["api://7c9f-audience/.default"]
|
|
assert (await _token_body(server, "authorization_code"))["resource"] == "api://7c9f-audience/.default"
|
|
assert (await _token_body(server, "refresh_token"))["resource"] == "api://7c9f-audience/.default"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upstream_resource_auto_never_leaks_credentials_from_the_server_url():
|
|
"""A resource indicator names the resource, never the credentials used to reach it. Transport
|
|
URLs routinely carry secrets in userinfo and in the query string, and this value is published
|
|
into the authorization redirect the browser follows and into token request bodies, so neither
|
|
component may survive into the derived resource."""
|
|
server = _resource_server(
|
|
url="https://svc-account:s3cr3t@MCP.Example.com/mcp?api_key=qu3ry-s3cr3t",
|
|
upstream_resource="auto",
|
|
)
|
|
leaks = ("s3cr3t", "svc-account", "qu3ry-s3cr3t", "api_key")
|
|
|
|
query = await _authorize_query(server)
|
|
assert query["resource"] == ["https://mcp.example.com/mcp"]
|
|
assert not any(leak in query["resource"][0] for leak in leaks)
|
|
|
|
body = await _token_body(server, "authorization_code")
|
|
assert not any(leak in body["resource"] for leak in leaks)
|
|
|
|
|
|
def test_upstream_resource_auto_keeps_the_path_because_it_identifies_the_server():
|
|
"""The path is load-bearing identity and must survive canonicalization, unlike userinfo and
|
|
query which are transport concerns.
|
|
|
|
The MCP authorization spec requires the most specific URI and lists
|
|
``https://mcp.example.com/server/mcp`` as canonical "when path component is necessary to
|
|
identify individual MCP server". Two servers behind one host differ only by path, so dropping
|
|
it would collide them onto one resource identifier and bind each token to the wrong audience,
|
|
which is the exact confusion RFC 8707 exists to prevent. An operator whose path embeds a secret
|
|
sets ``upstream_resource`` explicitly instead of using ``auto``."""
|
|
from litellm.proxy.mcp_server.oauth_utils import resolve_upstream_resource
|
|
|
|
first = resolve_upstream_resource(
|
|
_resource_server(url="https://gw.example.com/team-a/mcp", upstream_resource="auto")
|
|
)
|
|
second = resolve_upstream_resource(
|
|
_resource_server(url="https://gw.example.com/team-b/mcp", upstream_resource="auto")
|
|
)
|
|
|
|
assert first == "https://gw.example.com/team-a/mcp"
|
|
assert second == "https://gw.example.com/team-b/mcp"
|
|
assert first != second
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upstream_resource_auto_without_url_omits_the_parameter():
|
|
"""A server with no url (OpenAPI spec or stdio) has nothing to derive a canonical URI from, so
|
|
``auto`` omits the parameter rather than sending an empty or malformed resource."""
|
|
server = _resource_server(url=None, upstream_resource="auto")
|
|
|
|
assert "resource" not in await _authorize_query(server)
|
|
assert "resource" not in await _token_body(server, "authorization_code")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upstream_resource_sent_on_dcr_bridge_relay_authorize():
|
|
"""The DCR-bridge relay arm builds its own upstream authorize params, so it needs the resource
|
|
too. Without it the relayed authorize would omit the resource while the gateway's token leg
|
|
still sent one, which is itself an invalid_target."""
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
_dcr_bridge_relays_client_registration,
|
|
)
|
|
from litellm.types.mcp import MCPAuth
|
|
|
|
server = _resource_server(
|
|
client_id=None,
|
|
client_secret=None,
|
|
auth_type=MCPAuth.oauth_delegate,
|
|
dcr_bridge=True,
|
|
registration_url="https://idp.example.com/register",
|
|
upstream_resource="auto",
|
|
)
|
|
assert _dcr_bridge_relays_client_registration(server), "test must exercise the relay arm"
|
|
|
|
query = await _authorize_query(server)
|
|
assert query["resource"] == ["https://mcp.example.com/mcp"]
|
|
assert query["client_id"] == ["caller-client"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Per-request root_path (SERVER_ROOT_PATHS / PerRequestRootPathMiddleware):
|
|
# one app fronting several client-visible URL path prefixes, each prefix's
|
|
# discovery documents emitting URLs under the prefix the client called
|
|
# (RFC 9728 §3 exact-match). A scalar PROXY_BASE_URL / SERVER_ROOT_PATH can
|
|
# encode at most one prefix per pod; these tests pin the N-prefix case.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def _no_proxy_base_url(monkeypatch):
|
|
"""Discovery must derive URLs from the request in these tests, so the
|
|
scalar env overrides are cleared."""
|
|
monkeypatch.delenv("PROXY_BASE_URL", raising=False)
|
|
monkeypatch.delenv("SERVER_ROOT_PATH", raising=False)
|
|
|
|
|
|
@pytest.fixture
|
|
def _isolated_mcp_registry():
|
|
"""Fixture-owned registry state: snapshot the shared registry, hand the
|
|
test an empty one, restore afterwards so nothing leaks between cases."""
|
|
from litellm.proxy.mcp_server.mcp_server_manager import (
|
|
global_mcp_server_manager,
|
|
)
|
|
|
|
saved = dict(global_mcp_server_manager.registry)
|
|
global_mcp_server_manager.registry.clear()
|
|
try:
|
|
yield global_mcp_server_manager.registry
|
|
finally:
|
|
global_mcp_server_manager.registry.clear()
|
|
global_mcp_server_manager.registry.update(saved)
|
|
|
|
|
|
def _prefixed_discovery_client(prefixes):
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import router
|
|
from litellm.proxy.middleware.per_request_root_path_middleware import (
|
|
PerRequestRootPathMiddleware,
|
|
)
|
|
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
app.add_middleware(PerRequestRootPathMiddleware, root_paths=prefixes)
|
|
return TestClient(app)
|
|
|
|
|
|
class TestPerRequestRootPathDiscovery:
|
|
def test_prefixed_wellknown_not_routable_without_middleware(self, _isolated_mcp_registry):
|
|
"""Control: on a plain app (the only shape a scalar root_path can
|
|
express), a prefixed well-known request 404s before any discovery
|
|
builder runs — the routing gap this feature exists to close."""
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import router
|
|
|
|
server = _create_oauth2_server(server_id="srv_a", name="server_a", server_name="server_a", alias="server_a")
|
|
_isolated_mcp_registry[server.server_id] = server
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
client = TestClient(app)
|
|
resp = client.get("/tenant-a/.well-known/oauth-protected-resource/mcp/server_a")
|
|
assert resp.status_code == 404
|
|
|
|
def test_two_prefixes_one_app_each_resource_matches_the_called_url(
|
|
self, _no_proxy_base_url, _isolated_mcp_registry
|
|
):
|
|
"""The multi-origin case itself: two prefixes served by the same app,
|
|
each per-server document's ``resource`` equal to the URL its client
|
|
called — including the prefix."""
|
|
for sid, name in (("srv_a", "server_a"), ("srv_b", "server_b")):
|
|
_isolated_mcp_registry[sid] = _create_oauth2_server(server_id=sid, name=name, server_name=name, alias=name)
|
|
client = _prefixed_discovery_client(["/tenant-a", "/tenant-b"])
|
|
|
|
resp_a = client.get("/tenant-a/.well-known/oauth-protected-resource/mcp/server_a")
|
|
resp_b = client.get("/tenant-b/.well-known/oauth-protected-resource/mcp/server_b")
|
|
|
|
assert resp_a.status_code == 200
|
|
assert resp_a.json()["resource"] == "http://testserver/tenant-a/mcp/server_a"
|
|
assert resp_b.status_code == 200
|
|
assert resp_b.json()["resource"] == "http://testserver/tenant-b/mcp/server_b"
|
|
|
|
# Every URL the document advertises stays under the request's
|
|
# prefix, so it resolves on this same app.
|
|
for auth_server in resp_a.json()["authorization_servers"]:
|
|
assert auth_server.startswith("http://testserver/tenant-a/")
|
|
|
|
def test_unprefixed_requests_unchanged_on_the_same_app(self, _no_proxy_base_url, _isolated_mcp_registry):
|
|
"""Backward compat on the very same app: a root request emits the
|
|
document byte-identical to a deployment without the middleware."""
|
|
server = _create_oauth2_server(server_id="srv_a", name="server_a", server_name="server_a", alias="server_a")
|
|
_isolated_mcp_registry[server.server_id] = server
|
|
client = _prefixed_discovery_client(["/tenant-a"])
|
|
resp = client.get("/.well-known/oauth-protected-resource/mcp/server_a")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["resource"] == "http://testserver/mcp/server_a"
|
|
|
|
def test_unlisted_prefix_404s(self, _no_proxy_base_url):
|
|
client = _prefixed_discovery_client(["/tenant-a"])
|
|
assert client.get("/tenant-c/.well-known/oauth-protected-resource/mcp/server_a").status_code == 404
|
|
|
|
def test_aggregate_documents_and_as_endpoints_under_prefix(self, _no_proxy_base_url, _isolated_mcp_registry):
|
|
"""Aggregate PRM/AS documents carry the prefix, and the advertised
|
|
authorize endpoint actually resolves under it — the 404 trap that
|
|
invalidated prefixing discovery URLs without per-request routing
|
|
(#35226 review round 1)."""
|
|
client = _prefixed_discovery_client(["/tenant-a"])
|
|
|
|
prm = client.get("/tenant-a/.well-known/oauth-protected-resource/mcp")
|
|
asm = client.get("/tenant-a/.well-known/oauth-authorization-server/mcp")
|
|
|
|
assert prm.status_code == 200
|
|
assert prm.json()["resource"] == "http://testserver/tenant-a/mcp"
|
|
assert prm.json()["authorization_servers"] == ["http://testserver/tenant-a/mcp"]
|
|
|
|
assert asm.status_code == 200
|
|
assert asm.json()["issuer"] == "http://testserver/tenant-a/mcp"
|
|
assert asm.json()["authorization_endpoint"] == "http://testserver/tenant-a/authorize/mcp-session"
|
|
|
|
# The prefixed authorize URL routes to the real handler (not 404):
|
|
# under per-request root_path the whole app is reachable per-prefix,
|
|
# so discovery may advertise prefixed AS endpoints safely.
|
|
assert client.get("/tenant-a/authorize").status_code != 404
|
|
|
|
def test_passthrough_challenge_metadata_url_carries_prefix(self, _no_proxy_base_url):
|
|
"""The WWW-Authenticate resource_metadata URL a 401 advertises must
|
|
land under the request's prefix, or the client is bounced to a
|
|
document whose ``resource`` cannot match the URL it called."""
|
|
from litellm.proxy.mcp_server.oauth_utils import (
|
|
get_passthrough_resource_metadata_url,
|
|
)
|
|
|
|
def _scope(path, root_path=None):
|
|
scope = {
|
|
"type": "http",
|
|
"method": "POST",
|
|
"path": path,
|
|
"headers": [],
|
|
"scheme": "http",
|
|
"server": ("testserver", 80),
|
|
"client": ("1.2.3.4", 4444),
|
|
}
|
|
if root_path is not None:
|
|
scope["root_path"] = root_path
|
|
return scope
|
|
|
|
prefixed = get_passthrough_resource_metadata_url(
|
|
scope=_scope("/tenant-a/mcp/github", root_path="/tenant-a"),
|
|
server_name="github",
|
|
)
|
|
assert prefixed == "http://testserver/tenant-a/.well-known/oauth-protected-resource/mcp/github"
|
|
|
|
# Regression guard: no root_path → today's URL, unchanged.
|
|
bare = get_passthrough_resource_metadata_url(
|
|
scope=_scope("/mcp/github"),
|
|
server_name="github",
|
|
)
|
|
assert bare == "http://testserver/.well-known/oauth-protected-resource/mcp/github"
|
|
|
|
def test_user_oauth_challenge_url_routes_and_resource_matches_client_url(
|
|
self, _no_proxy_base_url, _isolated_mcp_registry
|
|
):
|
|
"""The reviewer's expected end-state, pinned end-to-end: an MCP endpoint
|
|
raising ``raise_user_oauth_challenge`` under a per-request prefix must
|
|
emit a resource_metadata URL the client can actually fetch, and the
|
|
document it returns must carry the same prefix the client originally
|
|
called. If either half breaks the client's discovery is dead."""
|
|
import re
|
|
|
|
from fastapi import FastAPI, HTTPException, Request
|
|
from fastapi.testclient import TestClient
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import router
|
|
from litellm.proxy.mcp_server.outbound_credentials.adapter import (
|
|
raise_user_oauth_challenge,
|
|
)
|
|
from litellm.proxy.middleware.per_request_root_path_middleware import (
|
|
PerRequestRootPathMiddleware,
|
|
get_request_root_path,
|
|
)
|
|
|
|
server = _create_oauth2_server(server_id="srv_a", name="server_a", server_name="server_a", alias="server_a")
|
|
_isolated_mcp_registry[server.server_id] = server
|
|
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
|
|
@app.post("/mcp/{name}")
|
|
def _mcp(name: str, request: Request):
|
|
try:
|
|
raise_user_oauth_challenge(server, root_path=get_request_root_path())
|
|
except HTTPException as exc:
|
|
return {"www_authenticate": exc.headers["WWW-Authenticate"]}
|
|
|
|
app.add_middleware(PerRequestRootPathMiddleware, root_paths=["/tenant-a", "/tenant-b"])
|
|
client = TestClient(app)
|
|
|
|
for prefix, mcp_url in (
|
|
("/tenant-a", "http://testserver/tenant-a/mcp/server_a"),
|
|
("/tenant-b", "http://testserver/tenant-b/mcp/server_a"),
|
|
("", "http://testserver/mcp/server_a"),
|
|
):
|
|
call = client.post(f"{prefix}/mcp/server_a")
|
|
assert call.status_code == 200, call.text
|
|
www = call.json()["www_authenticate"]
|
|
match = re.search(r'resource_metadata="([^"]+)"', www)
|
|
assert match, www
|
|
discovery = client.get(match.group(1))
|
|
# The challenge URL must route (a client that can't fetch it has
|
|
# no way to reach the resource metadata).
|
|
assert discovery.status_code == 200, (
|
|
f"challenge URL {match.group(1)} for prefix {prefix!r} 404s; "
|
|
"the client can't reach the resource metadata."
|
|
)
|
|
# And the doc's `resource` must equal the URL the client called
|
|
# (RFC 9728 §3 exact match): a mismatch bounces a strict client.
|
|
assert discovery.json()["resource"] == mcp_url, discovery.json()
|
|
|
|
|
|
def _s256(verifier: str) -> str:
|
|
return urlsafe_b64encode(hashlib.sha256(verifier.encode("ascii")).digest()).rstrip(b"=").decode("ascii")
|
|
|
|
|
|
_NATIVE_CLIENT_MASTER_KEY = "sk-test-salt-for-LIT-5874"
|
|
|
|
|
|
def _browser_session_cookie(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
user_id: str,
|
|
*,
|
|
signing_key: str = _NATIVE_CLIENT_MASTER_KEY,
|
|
session_key: str | None = None,
|
|
expired_session: bool = False,
|
|
expired_cookie: bool = False,
|
|
) -> str:
|
|
import jwt
|
|
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.auth.auth_checks import LITELLM_SESSION_TOKEN_PREFIX
|
|
from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_bearer_token
|
|
|
|
monkeypatch.setattr(proxy_server, "master_key", signing_key)
|
|
if proxy_server.prisma_client is None:
|
|
monkeypatch.setattr(proxy_server, "prisma_client", object())
|
|
monkeypatch.setattr(proxy_server, "general_settings", {**proxy_server.general_settings, "supported_db_objects": []})
|
|
session: Final = UserAPIKeyAuth(
|
|
user_id=user_id,
|
|
expires=datetime(2000 if expired_session else 2099, 1, 1, tzinfo=timezone.utc),
|
|
)
|
|
key: Final = (
|
|
encrypt_bearer_token(session.model_dump_json(exclude_none=True), LITELLM_SESSION_TOKEN_PREFIX)
|
|
if session_key is None
|
|
else session_key
|
|
)
|
|
return jwt.encode(
|
|
{"user_id": user_id, "key": key, "login_method": "sso", "exp": 946684800 if expired_cookie else 9999999999},
|
|
signing_key,
|
|
algorithm="HS256",
|
|
)
|
|
|
|
|
|
class _BrowserSessionDatabase:
|
|
def __init__(self) -> None:
|
|
from litellm.proxy._types import UserAPIKeyAuth, hash_token
|
|
|
|
self.row: UserAPIKeyAuth | None = UserAPIKeyAuth(
|
|
token=hash_token("sk-browser-session"),
|
|
user_id="u1",
|
|
expires=datetime(2099, 1, 1, tzinfo=timezone.utc),
|
|
)
|
|
self.litellm_deprecatedverificationtoken = self
|
|
|
|
def __call__(self, **kwargs: object) -> "_BrowserSessionDatabase":
|
|
return self
|
|
|
|
async def query_first(self, query: str, *args: object) -> dict[str, object] | None:
|
|
if self.row is None:
|
|
return None
|
|
return {
|
|
**self.row.model_dump(exclude={"last_refreshed_at"}),
|
|
"team_models": None,
|
|
"team_blocked": None,
|
|
"team_members_with_roles": None,
|
|
}
|
|
|
|
async def find_first(self, **kwargs: object) -> None:
|
|
return None
|
|
|
|
|
|
@pytest.fixture
|
|
def browser_session_database(monkeypatch: pytest.MonkeyPatch) -> _BrowserSessionDatabase:
|
|
import prisma
|
|
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy._types import hash_token
|
|
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
|
from litellm.proxy.utils import PrismaClient
|
|
|
|
database: Final = _BrowserSessionDatabase()
|
|
monkeypatch.setattr(prisma, "Prisma", database)
|
|
for name in ("DATABASE_URL_READ_REPLICA", "IAM_TOKEN_DB_AUTH", "AZURE_POSTGRESQL_AUTH"):
|
|
monkeypatch.delenv(name, raising=False)
|
|
monkeypatch.setattr(
|
|
proxy_server,
|
|
"prisma_client",
|
|
PrismaClient("postgresql://local-test@localhost/browser-session", proxy_server.proxy_logging_obj),
|
|
)
|
|
cache: Final = UserApiKeyCache()
|
|
cache.set_cache(hash_token("sk-browser-session"), database.row)
|
|
monkeypatch.setattr(proxy_server, "user_api_key_cache", cache)
|
|
return database
|
|
|
|
|
|
def _native_client_app(monkeypatch):
|
|
"""The unauthenticated discoverable router served over TestClient with a signed UI session
|
|
cookie available, plus fakes for the two database-backed hooks the native-client flow calls."""
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import router
|
|
from litellm.proxy.mcp_server.gateway_dcr_flow import ConsentTeam, MintedProxyCredential
|
|
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
|
|
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", _NATIVE_CLIENT_MASTER_KEY)
|
|
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", _NATIVE_CLIENT_MASTER_KEY, raising=False)
|
|
minted = []
|
|
|
|
async def fake_mint(user_id, team_id, allowed_routes=None):
|
|
minted.append((user_id, team_id))
|
|
return MintedProxyCredential(key=f"sk-cli-{len(minted)}", expires_in=3600, user_id=user_id, team_id=team_id)
|
|
|
|
async def fake_lookup(user_id):
|
|
return (ConsentTeam(team_id="team-a", team_alias="Team A"), ConsentTeam(team_id="team-b"))
|
|
|
|
monkeypatch.setattr(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.mint_proxy_credential", fake_mint
|
|
)
|
|
monkeypatch.setattr(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.lookup_consent_teams", fake_lookup
|
|
)
|
|
global_mcp_server_manager.registry.clear()
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
client = TestClient(app)
|
|
session_cookie: Final = _browser_session_cookie(monkeypatch, "u1")
|
|
return client, session_cookie, minted
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"authorize_path,resource",
|
|
[("/authorize", None), ("/authorize/mcp-session", None), ("/authorize", "http://testserver")],
|
|
)
|
|
@pytest.mark.parametrize(
|
|
"session_state", ["valid", "revoked", "blocked", "expired", "wrong_owner", "missing_key", "no_database"]
|
|
)
|
|
def test_gateway_authorize_requires_live_browser_session(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
browser_session_database: _BrowserSessionDatabase,
|
|
authorize_path: str,
|
|
resource: str | None,
|
|
session_state: str,
|
|
) -> None:
|
|
from urllib.parse import urlparse
|
|
|
|
from litellm.proxy import proxy_server
|
|
|
|
client, _, _ = _native_client_app(monkeypatch)
|
|
database: Final = browser_session_database
|
|
assert database.row is not None
|
|
database.row = None if session_state == "revoked" else database.row.model_copy(
|
|
update={
|
|
"blocked": session_state == "blocked",
|
|
"expires": datetime(2000 if session_state == "expired" else 2099, 1, 1, tzinfo=timezone.utc),
|
|
"user_id": "another-user" if session_state == "wrong_owner" else "u1",
|
|
}
|
|
)
|
|
cookie: Final = _browser_session_cookie(
|
|
monkeypatch, "u1", session_key="" if session_state == "missing_key" else "sk-browser-session"
|
|
)
|
|
client.cookies.set("token", cookie)
|
|
if session_state == "no_database":
|
|
monkeypatch.setattr(proxy_server, "prisma_client", None)
|
|
registered: Final = client.post("/register", json={"redirect_uris": ["http://127.0.0.1/callback"]})
|
|
assert registered.status_code == 201, registered.text
|
|
response: Final = client.get(
|
|
authorize_path,
|
|
params={
|
|
"client_id": registered.json()["client_id"],
|
|
"redirect_uri": "http://127.0.0.1/callback",
|
|
"response_type": "code",
|
|
"code_challenge": _s256("v" * 43),
|
|
"code_challenge_method": "S256",
|
|
**({"resource": resource} if resource else {}),
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
if session_state == "valid" and resource:
|
|
assert response.status_code == 200 and 'name="decision"' in response.text, response.text
|
|
else:
|
|
assert response.status_code == 303, response.text
|
|
assert urlparse(response.headers["location"]).path == (
|
|
"/ui/connect" if session_state == "valid" else "/sso/key/generate"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("endpoint", ["flow", "complete"])
|
|
@pytest.mark.parametrize("revoked", [False, True])
|
|
def test_gateway_connect_rechecks_session_after_authorize(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
browser_session_database: _BrowserSessionDatabase,
|
|
endpoint: str,
|
|
revoked: bool,
|
|
) -> None:
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
client, _, _ = _native_client_app(monkeypatch)
|
|
client.cookies.set("token", _browser_session_cookie(monkeypatch, "u1", session_key="sk-browser-session"))
|
|
registered: Final = client.post("/register", json={"redirect_uris": ["http://127.0.0.1/callback"]})
|
|
assert registered.status_code == 201, registered.text
|
|
authorized: Final = client.get(
|
|
"/authorize/mcp-session",
|
|
params={
|
|
"client_id": registered.json()["client_id"],
|
|
"redirect_uri": "http://127.0.0.1/callback",
|
|
"response_type": "code",
|
|
"code_challenge": _s256("v" * 43),
|
|
"code_challenge_method": "S256",
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
flow: Final = parse_qs(urlparse(authorized.headers["location"]).query)["connect_flow"][0]
|
|
if revoked:
|
|
browser_session_database.row = None
|
|
response: Final = (
|
|
client.get("/authorize/flow", params={"flow": flow})
|
|
if endpoint == "flow"
|
|
else client.post("/authorize/complete", data={"flow": flow}, follow_redirects=False)
|
|
)
|
|
assert response.status_code == (401 if revoked else 200 if endpoint == "flow" else 303), response.text
|
|
if revoked:
|
|
assert response.json()["error"] == "login_required"
|
|
assert "location" not in response.headers
|
|
elif endpoint == "complete":
|
|
assert parse_qs(urlparse(response.headers["location"]).query)["code"]
|
|
|
|
|
|
@pytest.mark.parametrize("authorize_path", ["/authorize/mcp-session", "/authorize"])
|
|
def test_gateway_authorize_routes_forward_server_scope(monkeypatch: pytest.MonkeyPatch, authorize_path: str) -> None:
|
|
from http.cookies import SimpleCookie
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper
|
|
from litellm.proxy.mcp_server.gateway_dcr_flow import CONNECT_FLOW_COOKIE_PREFIX
|
|
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
|
|
|
|
monkeypatch.setattr(
|
|
global_mcp_server_manager,
|
|
"registry",
|
|
global_mcp_server_manager.registry.copy(),
|
|
)
|
|
client, session_cookie, _ = _native_client_app(monkeypatch)
|
|
server: Final = _create_oauth2_server(
|
|
server_id="scope-server-id",
|
|
name="alpha",
|
|
server_name="alpha",
|
|
alias="alpha",
|
|
)
|
|
monkeypatch.setitem(global_mcp_server_manager.registry, server.server_id, server)
|
|
|
|
redirect_uri: Final = "https://client.example/callback"
|
|
registration: Final = client.post("/register", json={"redirect_uris": [redirect_uri]})
|
|
assert registration.status_code == 201, registration.text
|
|
client_id: Final = registration.json()["client_id"]
|
|
response: Final = client.get(
|
|
authorize_path,
|
|
params={
|
|
"client_id": client_id,
|
|
"redirect_uri": redirect_uri,
|
|
"state": "scope-test-state",
|
|
"code_challenge": _s256("scope-test-verifier"),
|
|
"code_challenge_method": "S256",
|
|
"response_type": "code",
|
|
"resource": "http://testserver/mcp",
|
|
"scope": "litellm:mcp_server:alpha",
|
|
},
|
|
cookies={"token": session_cookie},
|
|
follow_redirects=False,
|
|
)
|
|
assert response.status_code == 303, response.text
|
|
|
|
flow: Final = parse_qs(urlparse(response.headers["location"]).query)["connect_flow"][0]
|
|
flow_cookies: Final = SimpleCookie()
|
|
flow_cookies.load(response.headers["set-cookie"])
|
|
sealed_flow: Final = flow_cookies[f"{CONNECT_FLOW_COOKIE_PREFIX}{flow}"].value
|
|
decoded_flow: Final = decrypt_value_helper(sealed_flow, "gateway_connect_flow", return_original_value=False)
|
|
assert isinstance(decoded_flow, str)
|
|
flow_payload: Final = json.loads(decoded_flow)
|
|
assert flow_payload["resource_server_id"] == "scope-server-id"
|
|
assert "resource_server_ids" not in flow_payload
|
|
|
|
|
|
def _consent_flow_handle(page: str) -> str:
|
|
import re
|
|
|
|
match = re.search(r'name="flow" value="([^"]+)"', page)
|
|
assert match is not None, page
|
|
return match.group(1)
|
|
|
|
|
|
@pytest.mark.parametrize("redirect_uri", ["http://127.0.0.1:51234/callback", "https://client.example.com/callback"])
|
|
@pytest.mark.parametrize("signed_in", [True, False])
|
|
def test_root_discovery_origin_authorizes_mcp_session(monkeypatch, redirect_uri, signed_in):
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
client, session_cookie, minted = _native_client_app(monkeypatch)
|
|
root = client.get("/.well-known/oauth-protected-resource")
|
|
assert root.status_code == 200
|
|
assert root.json()["resource"] == "http://testserver"
|
|
authorization = client.get("/.well-known/oauth-authorization-server/mcp")
|
|
assert authorization.status_code == 200
|
|
metadata = authorization.json()
|
|
registered = client.post(metadata["registration_endpoint"], json={"redirect_uris": [redirect_uri]})
|
|
assert registered.status_code == 201
|
|
if signed_in:
|
|
client.cookies.set("token", session_cookie)
|
|
response = client.get(
|
|
metadata["authorization_endpoint"],
|
|
params={
|
|
"response_type": "code",
|
|
"client_id": registered.json()["client_id"],
|
|
"redirect_uri": redirect_uri,
|
|
"state": "mcp-state",
|
|
"code_challenge": _s256("v" * 43),
|
|
"code_challenge_method": "S256",
|
|
"resource": root.json()["resource"],
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert response.status_code == 303
|
|
target = urlparse(response.headers["location"])
|
|
assert target.path == ("/ui/connect" if signed_in else "/sso/key/generate")
|
|
if signed_in:
|
|
flow = parse_qs(target.query)["connect_flow"][0]
|
|
described = client.get("/authorize/flow", params={"flow": flow})
|
|
assert described.status_code == 200
|
|
assert described.json()["state"] == "unscoped"
|
|
assert minted == []
|
|
|
|
|
|
@pytest.mark.parametrize("valid_client", [True, False])
|
|
def test_mcp_session_authorize_rejects_invalid_registration_or_pkce(monkeypatch, valid_client):
|
|
client, session_cookie, minted = _native_client_app(monkeypatch)
|
|
redirect_uri = "https://client.example.com/callback"
|
|
registered = client.post("/register", json={"redirect_uris": [redirect_uri]})
|
|
assert registered.status_code == 201
|
|
client.cookies.set("token", session_cookie)
|
|
response = client.get(
|
|
"/authorize/mcp-session",
|
|
params={
|
|
"client_id": registered.json()["client_id"] if valid_client else "unknown-client",
|
|
"redirect_uri": redirect_uri,
|
|
"response_type": "code",
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert response.status_code == 400
|
|
assert response.json()["error"] == ("invalid_request" if valid_client else "invalid_client")
|
|
assert "location" not in response.headers
|
|
assert minted == []
|
|
|
|
|
|
def test_native_client_login_walks_discovery_consent_token_refresh_and_revoke(monkeypatch):
|
|
"""The whole ``lite login --pkce`` server side over the real router: a Go CLI reads the versioned
|
|
discovery document, registers a loopback public client, the signed-in user consents to a team,
|
|
the code redeems for the ``lite login`` credential, the refresh token rotates, and revocation
|
|
kills it."""
|
|
from http.cookies import SimpleCookie
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
client, session_cookie, minted = _native_client_app(monkeypatch)
|
|
redirect_uri = "http://127.0.0.1:51234/callback"
|
|
|
|
discovery = client.get("/.well-known/litellm-cli-auth")
|
|
assert discovery.status_code == 200
|
|
assert discovery.headers["cache-control"] == "no-store"
|
|
contract = discovery.json()
|
|
assert contract["contract_version"] == 1
|
|
assert contract["resource"] == "http://testserver"
|
|
assert contract["code_challenge_methods_supported"] == ["S256"]
|
|
assert contract["token_endpoint_auth_methods_supported"] == ["none"]
|
|
for endpoint in ("authorization_endpoint", "token_endpoint", "registration_endpoint", "revocation_endpoint"):
|
|
assert contract[endpoint].startswith("http://testserver/")
|
|
|
|
registered = client.post(
|
|
contract["registration_endpoint"],
|
|
json={
|
|
"client_name": "litellm-cli",
|
|
"redirect_uris": [redirect_uri],
|
|
"grant_types": ["authorization_code", "refresh_token"],
|
|
"response_types": ["code"],
|
|
"token_endpoint_auth_method": "none",
|
|
},
|
|
)
|
|
assert registered.status_code == 201
|
|
client_id = registered.json()["client_id"]
|
|
verifier = "v" * 43
|
|
authorize_params = {
|
|
"response_type": "code",
|
|
"client_id": client_id,
|
|
"redirect_uri": redirect_uri,
|
|
"state": "cli-state",
|
|
"code_challenge": _s256(verifier),
|
|
"code_challenge_method": "S256",
|
|
"resource": contract["resource"],
|
|
}
|
|
|
|
anonymous = client.get(contract["authorization_endpoint"], params=authorize_params, follow_redirects=False)
|
|
assert anonymous.status_code == 303
|
|
login_target = urlparse(anonymous.headers["location"])
|
|
assert login_target.path == "/sso/key/generate"
|
|
assert parse_qs(login_target.query)["return_to"][0].startswith("/authorize?")
|
|
|
|
client.cookies.set("token", session_cookie)
|
|
consent = client.get(contract["authorization_endpoint"], params=authorize_params, follow_redirects=False)
|
|
assert consent.status_code == 200
|
|
assert consent.headers["x-frame-options"] == "DENY"
|
|
assert consent.headers["cache-control"] == "no-store"
|
|
assert "http://127.0.0.1:51234" in consent.text
|
|
assert '<option value="team-b">team-b</option>' in consent.text
|
|
jar = SimpleCookie()
|
|
jar.load(consent.headers["set-cookie"])
|
|
assert all(morsel["httponly"] for morsel in jar.values())
|
|
|
|
denied = client.post(
|
|
"/authorize/complete",
|
|
data={"flow": _consent_flow_handle(consent.text), "decision": "deny", "team_id": "team-a"},
|
|
follow_redirects=False,
|
|
)
|
|
assert denied.status_code == 303
|
|
denied_query = parse_qs(urlparse(denied.headers["location"]).query)
|
|
assert denied.headers["location"].startswith(redirect_uri)
|
|
assert denied_query["error"] == ["access_denied"]
|
|
assert denied_query["state"] == ["cli-state"]
|
|
assert minted == []
|
|
|
|
consent_again = client.get(contract["authorization_endpoint"], params=authorize_params, follow_redirects=False)
|
|
approved = client.post(
|
|
"/authorize/complete",
|
|
data={"flow": _consent_flow_handle(consent_again.text), "decision": "approve", "team_id": "team-b"},
|
|
follow_redirects=False,
|
|
)
|
|
assert approved.status_code == 303
|
|
assert approved.headers["location"].startswith(redirect_uri)
|
|
approved_query = parse_qs(urlparse(approved.headers["location"]).query)
|
|
assert approved_query["state"] == ["cli-state"]
|
|
code = approved_query["code"][0]
|
|
|
|
token = client.post(
|
|
contract["token_endpoint"],
|
|
data={
|
|
"grant_type": "authorization_code",
|
|
"code": code,
|
|
"redirect_uri": redirect_uri,
|
|
"client_id": client_id,
|
|
"code_verifier": verifier,
|
|
"resource": contract["resource"],
|
|
},
|
|
)
|
|
assert token.status_code == 200, token.text
|
|
assert token.headers["cache-control"] == "no-store"
|
|
body = token.json()
|
|
assert body["access_token"] == "sk-cli-1"
|
|
assert body["token_type"] == "Bearer"
|
|
assert body["expires_in"] == 3600
|
|
assert body["user_id"] == "u1"
|
|
assert body["team_id"] == "team-b"
|
|
assert body["refresh_token"].startswith("llm_srefresh_")
|
|
assert minted == [("u1", "team-b")]
|
|
|
|
refreshed = client.post(
|
|
contract["token_endpoint"],
|
|
data={
|
|
"grant_type": "refresh_token",
|
|
"refresh_token": body["refresh_token"],
|
|
"client_id": client_id,
|
|
"resource": contract["resource"],
|
|
},
|
|
)
|
|
assert refreshed.status_code == 200, refreshed.text
|
|
assert refreshed.json()["access_token"] == "sk-cli-2"
|
|
assert refreshed.json()["team_id"] == "team-b"
|
|
assert refreshed.json()["refresh_token"] != body["refresh_token"]
|
|
assert minted == [("u1", "team-b"), ("u1", "team-b")]
|
|
|
|
revoked = client.post(
|
|
contract["revocation_endpoint"],
|
|
data={"token": refreshed.json()["refresh_token"], "token_type_hint": "refresh_token", "client_id": client_id},
|
|
)
|
|
assert revoked.status_code == 200
|
|
assert revoked.json() == {}
|
|
|
|
after_revoke = client.post(
|
|
contract["token_endpoint"],
|
|
data={
|
|
"grant_type": "refresh_token",
|
|
"refresh_token": refreshed.json()["refresh_token"],
|
|
"client_id": client_id,
|
|
"resource": contract["resource"],
|
|
},
|
|
)
|
|
assert after_revoke.status_code == 400
|
|
assert after_revoke.json()["error"] == "invalid_grant"
|
|
|
|
stranger = client.post(
|
|
contract["revocation_endpoint"], data={"token": "whatever", "client_id": "llm_dcrc_not_a_client"}
|
|
)
|
|
assert stranger.status_code == 401
|
|
assert stranger.json()["error"] == "invalid_client"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"jwt_auth_enabled, virtual_key_claim_field, exchange_servable",
|
|
[(True, None, True), (False, None, False), (True, "client_id", False)],
|
|
ids=["jwt auth on", "jwt auth off", "jwts mapped to virtual keys"],
|
|
)
|
|
def test_discovery_advertises_the_exchange_grant_only_where_the_gateway_can_serve_it(
|
|
monkeypatch, jwt_auth_enabled, virtual_key_claim_field, exchange_servable
|
|
):
|
|
"""Every document a native client reads before it picks a grant (the versioned contract, the
|
|
aggregate authorization-server metadata, and the registration response) lists the RFC 8693
|
|
exchange exactly when the running proxy can serve it: JWT auth on, a database, a license, and
|
|
no JWT-to-virtual-key mapping, since the exchange would mint past the mapped key's policy."""
|
|
from litellm.caching.caching import DualCache
|
|
from litellm.proxy._types import LiteLLM_JWTAuth
|
|
from litellm.proxy.auth.handle_jwt import JWTHandler
|
|
|
|
client, _session_cookie, _minted = _native_client_app(monkeypatch)
|
|
handler: Final = JWTHandler()
|
|
handler.update_environment(
|
|
prisma_client=None,
|
|
user_api_key_cache=DualCache(),
|
|
litellm_jwtauth=LiteLLM_JWTAuth(virtual_key_claim_field=virtual_key_claim_field),
|
|
)
|
|
monkeypatch.setattr("litellm.proxy.proxy_server.jwt_handler", handler)
|
|
monkeypatch.setattr(
|
|
"litellm.proxy.proxy_server.general_settings", {"enable_jwt_auth": jwt_auth_enabled, "supported_db_objects": []}
|
|
)
|
|
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", object())
|
|
monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", True)
|
|
exchange_grant = ["urn:ietf:params:oauth:grant-type:token-exchange"] if exchange_servable else []
|
|
expected = ["authorization_code", "refresh_token", *exchange_grant]
|
|
|
|
assert client.get("/.well-known/litellm-cli-auth").json()["grant_types_supported"] == expected
|
|
assert client.get("/.well-known/oauth-authorization-server/mcp").json()["grant_types_supported"] == expected
|
|
registered = client.post("/register", json={"redirect_uris": ["http://127.0.0.1:51234/callback"]})
|
|
assert registered.status_code == 201
|
|
assert registered.json()["grant_types"] == expected
|
|
|
|
|
|
def test_native_client_authorize_without_the_proxy_resource_keeps_the_mcp_flow(monkeypatch):
|
|
"""A registered client asking for the MCP resource (or no resource) never sees the consent
|
|
page, so existing MCP clients are untouched by the native-client arm."""
|
|
client, session_cookie, minted = _native_client_app(monkeypatch)
|
|
registered = client.post("/register", json={"redirect_uris": ["http://127.0.0.1:51234/callback"]})
|
|
client.cookies.set("token", session_cookie)
|
|
for resource in (None, "http://testserver/mcp"):
|
|
params = {
|
|
"response_type": "code",
|
|
"client_id": registered.json()["client_id"],
|
|
"redirect_uri": "http://127.0.0.1:51234/callback",
|
|
"state": "s",
|
|
"code_challenge": _s256("v" * 43),
|
|
"code_challenge_method": "S256",
|
|
**({"resource": resource} if resource else {}),
|
|
}
|
|
response = client.get("/authorize", params=params, follow_redirects=False)
|
|
assert 'name="decision"' not in response.text
|
|
assert "team-b" not in response.text
|
|
assert minted == []
|
|
|
|
|
|
def test_introspect_route_requires_virtual_key_auth_and_is_advertised():
|
|
"""RFC 7662 section 2.1: introspection must not be anonymous. Pins the route-level
|
|
user_api_key_auth dependency (structure, so removing it fails here without a proxy),
|
|
and that the aggregate AS metadata advertises the endpoint for discovery."""
|
|
from fastapi import FastAPI
|
|
from fastapi.routing import APIRoute
|
|
from fastapi.testclient import TestClient
|
|
|
|
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import router
|
|
|
|
route = next(r for r in router.routes if isinstance(r, APIRoute) and r.path == "/introspect")
|
|
assert route.methods == {"POST"}
|
|
assert any(dependency.call is user_api_key_auth for dependency in route.dependant.dependencies)
|
|
|
|
from litellm.proxy._types import LiteLLMRoutes
|
|
|
|
assert "/introspect" in LiteLLMRoutes.mcp_routes.value
|
|
|
|
from litellm.proxy._lazy_features import LAZY_FEATURES
|
|
|
|
discoverable = next(feature for feature in LAZY_FEATURES if feature.name == "mcp_discoverable")
|
|
assert "/introspect" in discoverable.path_prefixes
|
|
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
client = TestClient(app)
|
|
asm = client.get("/.well-known/oauth-authorization-server/mcp")
|
|
assert asm.json()["introspection_endpoint"] == "http://testserver/introspect"
|
|
|
|
|
|
def test_introspect_route_answers_for_authenticated_caller(monkeypatch):
|
|
"""End-to-end over the real route with the auth dependency satisfied: a garbage token
|
|
is active false, a freshly minted session access token is active true with its claims."""
|
|
from datetime import datetime, timezone
|
|
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import router
|
|
from litellm.proxy.mcp_server.outbound_credentials.session_credentials import (
|
|
session_keys_from_master_key,
|
|
)
|
|
from litellm.proxy.mcp_server.outbound_credentials.session_token import (
|
|
SessionPrincipal,
|
|
mint_session_token,
|
|
)
|
|
|
|
introspect_master_key = "sk-introspect-route-test"
|
|
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", introspect_master_key, raising=False)
|
|
|
|
async def fake_reload(user_id: str):
|
|
return None
|
|
|
|
monkeypatch.setattr(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.reload_active_user_by_id", fake_reload
|
|
)
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth()
|
|
client = TestClient(app)
|
|
|
|
garbage = client.post("/introspect", data={"token": "llm_session_garbage"})
|
|
assert garbage.status_code == 200
|
|
assert garbage.json() == {"active": False}
|
|
|
|
minted = mint_session_token(
|
|
SessionPrincipal(user_id="u1", client_id="llm_dcrc_client"),
|
|
session_keys_from_master_key(introspect_master_key),
|
|
datetime.now(timezone.utc),
|
|
)
|
|
active = client.post("/introspect", data={"token": minted.token.get_secret_value()})
|
|
assert active.status_code == 200
|
|
assert active.json()["active"] is True
|
|
assert active.json()["sub"] == "u1"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_identity_bound_authorization_carries_nonce_and_caller_through_callback(monkeypatch):
|
|
from http.cookies import SimpleCookie
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
_oauth_state_cookie_name,
|
|
authorize_with_server,
|
|
callback,
|
|
open_bridge_authorization_code,
|
|
)
|
|
from litellm.types.mcp import MCPTransport
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPOAuthIdentityBinding, MCPServer
|
|
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", "identity-binding-test-salt")
|
|
server = MCPServer(
|
|
server_id="srv",
|
|
name="srv",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id="client",
|
|
authorization_url="https://idp.example.com/authorize",
|
|
token_url="https://idp.example.com/token",
|
|
oauth_identity_binding=MCPOAuthIdentityBinding(
|
|
mode="enforce",
|
|
issuer="https://idp.example.com",
|
|
audiences=["client"],
|
|
),
|
|
)
|
|
request = Request(
|
|
{
|
|
"type": "http",
|
|
"scheme": "https",
|
|
"server": ("proxy.example.com", 443),
|
|
"path": "/authorize",
|
|
"query_string": b"",
|
|
"headers": [(b"authorization", b"Bearer sk-alice")],
|
|
}
|
|
)
|
|
with (
|
|
patch( # test-quality-ok: isolate authenticated request resolution from the real encrypted OAuth round trip
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.authorize_oauth_credential_request",
|
|
new=AsyncMock(return_value="alice"),
|
|
),
|
|
patch( # test-quality-ok: isolate user access lookup while testing nonce and caller preservation
|
|
"litellm.proxy.mcp_server.discoverable_endpoints._user_can_reach_mcp_server",
|
|
new=AsyncMock(return_value=True),
|
|
),
|
|
):
|
|
authorized = await authorize_with_server(
|
|
request,
|
|
server,
|
|
"client",
|
|
"http://127.0.0.1:6274/callback",
|
|
state="client-state",
|
|
code_challenge="pkce-challenge",
|
|
code_challenge_method="S256",
|
|
)
|
|
query = parse_qs(urlparse(authorized.headers["location"]).query)
|
|
assert len(query["nonce"][0]) >= 32
|
|
cookies = SimpleCookie()
|
|
cookies.load(authorized.headers["set-cookie"])
|
|
name = _oauth_state_cookie_name(query["state"][0])
|
|
callback_request = Request(
|
|
{**request.scope, "path": "/callback", "headers": [(b"cookie", f"{name}={cookies[name].value}".encode())]}
|
|
)
|
|
completed = await callback(callback_request, code="upstream-code", state=query["state"][0])
|
|
returned = parse_qs(urlparse(completed.headers["location"]).query)
|
|
sealed = open_bridge_authorization_code(returned["code"][0])
|
|
assert sealed.litellm_user_id == "alice"
|
|
assert sealed.mcp_server_id == "srv"
|
|
assert sealed.upstream_code == "upstream-code"
|
|
assert sealed.oauth_nonce == query["nonce"][0]
|
|
assert returned["state"] == ["client-state"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_enforced_login_warms_verified_token_readable_without_database_lookup(monkeypatch):
|
|
from types import SimpleNamespace
|
|
|
|
from litellm.caching.caching import DualCache
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy.mcp_server import db, mcp_server_manager
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import _store_per_user_token_server_side
|
|
from litellm.proxy.mcp_server.oauth2_token_cache import mcp_per_user_token_cache
|
|
from litellm.proxy.mcp_server.oauth_identity_binding import current_binding_proof
|
|
from litellm.proxy.mcp_server.outbound_credentials.dual_cache_token_backend import (
|
|
DualCacheTokenCacheBackend,
|
|
)
|
|
from litellm.proxy.mcp_server.outbound_credentials.oauth_token_store import CachedOAuthTokenStore
|
|
from litellm.proxy.mcp_server.outbound_credentials.per_user_oauth_store import (
|
|
LazyPerUserOAuthTokenStore,
|
|
)
|
|
from litellm.proxy.mcp_server.outbound_credentials.v2_token_store import V2PerUserTokenStore
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
server = MCPServer(
|
|
server_id="srv",
|
|
name="srv",
|
|
transport="http",
|
|
auth_type="oauth2",
|
|
oauth_identity_binding={
|
|
"mode": "enforce",
|
|
"issuer": "https://idp.example",
|
|
"audiences": ["client"],
|
|
"caller_field": "user_id",
|
|
"principal_claim": "sub",
|
|
},
|
|
)
|
|
proof = await current_binding_proof(server.oauth_identity_binding, "alice", "srv")
|
|
cache = DualCache()
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", "test-cache-warm-encryption-salt")
|
|
monkeypatch.setattr(proxy_server, "user_api_key_cache", cache)
|
|
monkeypatch.setattr(proxy_server, "prisma_client", MagicMock())
|
|
monkeypatch.setattr(
|
|
mcp_server_manager, "global_mcp_server_manager", SimpleNamespace(invalidate_user_oauth_token_cache=AsyncMock())
|
|
)
|
|
monkeypatch.setattr(db, "store_user_oauth_credential", AsyncMock())
|
|
await _store_per_user_token_server_side(
|
|
server=server,
|
|
user_id="alice",
|
|
token_response={"access_token": "alice-token", "refresh_token": "private", "expires_in": 3600},
|
|
identity_binding_proof=proof,
|
|
)
|
|
read = AsyncMock(return_value=None)
|
|
cached = CachedOAuthTokenStore(
|
|
V2PerUserTokenStore(read),
|
|
default_ttl_seconds=300,
|
|
backend=DualCacheTokenCacheBackend(cache, mcp_per_user_token_cache._codec()),
|
|
)
|
|
store = LazyPerUserOAuthTokenStore(
|
|
lambda _: server, store_builder=lambda _: (cached, True), redis_available=lambda: True
|
|
)
|
|
token = await store.fetch("alice", "srv")
|
|
assert token is not None and token.access_token == "alice-token"
|
|
assert token.identity_binding_proof == proof
|
|
assert token.refresh_token is None
|
|
read.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("upstream_status", [401, 403])
|
|
@pytest.mark.parametrize("auth_type", [MCPAuth.true_passthrough, MCPAuth.oauth_delegate])
|
|
@pytest.mark.parametrize("dcr_bridge", [False, True])
|
|
@pytest.mark.parametrize("flow", ["register", "mint"])
|
|
async def test_dcr_refusal_is_actionable_without_upstream_body(
|
|
upstream_status: int, auth_type: MCPAuth, dcr_bridge: bool, flow: str, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
from typing import Final
|
|
|
|
import httpx
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
mint_ephemeral_dcr_client,
|
|
register_client_with_server,
|
|
)
|
|
|
|
server: Final = _bridge_server(
|
|
auth_type=auth_type,
|
|
dcr_bridge=dcr_bridge,
|
|
server_id=f"refused-{auth_type}-{dcr_bridge}-{flow}-{upstream_status}",
|
|
client_id=None,
|
|
)
|
|
import respx
|
|
|
|
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
|
|
with respx.mock as upstream:
|
|
registration: Final = upstream.post(server.registration_url).mock(
|
|
return_value=httpx.Response(upstream_status, text="Forbidden private upstream details")
|
|
)
|
|
operation: Final = (
|
|
mint_ephemeral_dcr_client(_bridge_mock_request(), server)
|
|
if flow == "mint"
|
|
else register_client_with_server(
|
|
request=_bridge_mock_request(),
|
|
mcp_server=server,
|
|
client_name="Test client",
|
|
grant_types=None,
|
|
response_types=None,
|
|
token_endpoint_auth_method=None,
|
|
client_redirect_uris=["http://localhost:9999/callback"],
|
|
)
|
|
)
|
|
with pytest.raises(HTTPException) as exc:
|
|
await operation
|
|
assert registration.call_count == 1
|
|
assert exc.value.status_code == 403
|
|
assert f"HTTP {upstream_status}" in str(exc.value.detail)
|
|
assert "pre-registered OAuth client" in str(exc.value.detail)
|
|
assert "private upstream details" not in str(exc.value.detail)
|
|
|
|
|
|
@pytest.mark.parametrize("prefix", ["", "/tenant-a", "/tenant-b"])
|
|
@pytest.mark.parametrize(
|
|
("server_name", "pattern"),
|
|
[
|
|
("issuer_test", "mcp/{server}"),
|
|
("issuer_test", "{server}/mcp"),
|
|
("issuer_test", "{server}"),
|
|
("mcp", "mcp/{server}"),
|
|
("mcp", "{server}/mcp"),
|
|
],
|
|
)
|
|
def test_per_server_authorization_metadata_issuer_matches_discovery_path(
|
|
_no_proxy_base_url, _isolated_mcp_registry, prefix, server_name, pattern
|
|
):
|
|
server = _create_oauth2_server(server_id=server_name, name=server_name, server_name=server_name, alias=server_name)
|
|
_isolated_mcp_registry[server.server_id] = server
|
|
client = _prefixed_discovery_client(["/tenant-a", "/tenant-b"])
|
|
path = pattern.format(server=server_name)
|
|
response = client.get(f"{prefix}/.well-known/oauth-authorization-server/{path}")
|
|
assert response.status_code == 200
|
|
metadata = response.json()
|
|
assert metadata["issuer"] == f"http://testserver{prefix}/{path}"
|
|
assert metadata["authorization_endpoint"] == f"http://testserver{prefix}/{server_name}/authorize"
|
|
assert metadata["token_endpoint"] == f"http://testserver{prefix}/{server_name}/token"
|
|
assert metadata["registration_endpoint"] == f"http://testserver{prefix}/{server_name}/register"
|
|
|
|
|
|
@pytest.mark.parametrize("prefix", ["", "/tenant-a"])
|
|
@pytest.mark.parametrize("relay", [False, True])
|
|
@pytest.mark.parametrize("pattern", ["mcp/{server}", "{server}/mcp"])
|
|
def test_named_resource_discovery_follows_matching_authorization_issuer(
|
|
_no_proxy_base_url, _isolated_mcp_registry, prefix, relay, pattern
|
|
):
|
|
server = _create_oauth2_server().model_copy(update={"per_server_oauth_discovery": relay})
|
|
_isolated_mcp_registry[server.server_id] = server
|
|
client = _prefixed_discovery_client(["/tenant-a"])
|
|
path = pattern.format(server=server.server_name)
|
|
response = client.get(f"{prefix}/.well-known/oauth-protected-resource/{path}")
|
|
assert response.status_code == 200
|
|
resource = response.json()
|
|
issuer_path = server.server_name if relay else "mcp"
|
|
assert resource["resource"] == f"http://testserver{prefix}/{path}"
|
|
assert resource["authorization_servers"] == [f"http://testserver{prefix}/{issuer_path}"]
|
|
authorization = client.get(f"{prefix}/.well-known/oauth-authorization-server/{issuer_path}")
|
|
assert authorization.status_code == 200
|
|
assert authorization.json()["issuer"] == resource["authorization_servers"][0]
|
|
|
|
|
|
@pytest.fixture
|
|
def _gateway_root_path_discovery_router(monkeypatch: pytest.MonkeyPatch) -> "Iterator[APIRouter]":
|
|
"""The ``.well-known`` routes bake ``SERVER_ROOT_PATH`` into their paths when the module is
|
|
executed, so it is re-executed under the gateway env and its namespace restored afterwards
|
|
(a second reload would hand earlier importers a different ``router`` object)."""
|
|
import importlib
|
|
|
|
from litellm.proxy.mcp_server import discoverable_endpoints
|
|
|
|
snapshot: Final = dict(vars(discoverable_endpoints))
|
|
monkeypatch.setenv("SERVER_ROOT_PATH", "/gateway")
|
|
monkeypatch.setenv("PROXY_BASE_URL", "https://llm.example.test/gateway")
|
|
try:
|
|
yield importlib.reload(discoverable_endpoints).router
|
|
finally:
|
|
added_keys: Final = [key for key in vars(discoverable_endpoints) if key not in snapshot]
|
|
for key in added_keys:
|
|
delattr(discoverable_endpoints, key)
|
|
vars(discoverable_endpoints).update(snapshot)
|
|
|
|
|
|
def test_static_root_path_authorization_discovery_preserves_issuer(
|
|
_gateway_root_path_discovery_router: "APIRouter", _isolated_mcp_registry: "dict[str, MCPServer]"
|
|
) -> None:
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
server: Final = _create_oauth2_server(server_id="example", name="example", server_name="example", alias="example")
|
|
_isolated_mcp_registry[server.server_id] = server # rebind-ok: the fixture hands the test an empty registry to fill
|
|
app: Final = FastAPI(root_path="/gateway")
|
|
app.include_router(_gateway_root_path_discovery_router)
|
|
with TestClient(app) as client:
|
|
responses: Final = {
|
|
path: client.get(f"/.well-known/oauth-authorization-server/gateway/{path}")
|
|
for path in ("mcp/example", "example/mcp", "example", "mcp")
|
|
}
|
|
for path, response in responses.items():
|
|
assert response.status_code == 200, response.text
|
|
assert response.json()["issuer"] == f"https://llm.example.test/gateway/{path}"
|
|
assert responses["example/mcp"].json()["token_endpoint"] == "https://llm.example.test/gateway/example/token"
|
|
|
|
|
|
@pytest.fixture
|
|
def jwt_oauth_identity(monkeypatch: pytest.MonkeyPatch) -> tuple["JWTHandler", "RSAPrivateKey"]:
|
|
import jwt
|
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
|
|
from litellm.models.user import LiteLLM_UserTable
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy._types import LiteLLM_JWTAuth
|
|
from litellm.proxy.auth.handle_jwt import JWTHandler
|
|
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
|
|
|
signing_key: Final = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
cache: Final = UserApiKeyCache()
|
|
cache.set_cache(
|
|
"litellm_jwt_auth_keys_https://idp.example.test/jwks",
|
|
[json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(signing_key.public_key()))],
|
|
)
|
|
cache.set_cache("jwt-owner", LiteLLM_UserTable(user_id="jwt-owner", user_email="owner@example.test"))
|
|
handler: Final = JWTHandler()
|
|
handler.update_environment(
|
|
prisma_client=None,
|
|
user_api_key_cache=cache,
|
|
litellm_jwtauth=LiteLLM_JWTAuth(user_id_jwt_field="identity.user_id"),
|
|
)
|
|
monkeypatch.setenv("JWT_PUBLIC_KEY_URL", "https://idp.example.test/jwks")
|
|
monkeypatch.setenv("JWT_ISSUER", "https://idp.example.test")
|
|
monkeypatch.setenv("JWT_AUDIENCE", "litellm-proxy")
|
|
monkeypatch.setattr(proxy_server, "jwt_handler", handler)
|
|
monkeypatch.setattr(proxy_server, "general_settings", {"enable_jwt_auth": True})
|
|
monkeypatch.setattr(proxy_server, "premium_user", True)
|
|
monkeypatch.setattr(proxy_server, "user_api_key_cache", cache)
|
|
prisma: Final = MagicMock()
|
|
prisma.db.litellm_teammembership.find_unique = AsyncMock(return_value=None)
|
|
prisma.db.litellm_mcpusercredentials.find_unique = AsyncMock(return_value=None)
|
|
monkeypatch.setattr(proxy_server, "prisma_client", prisma)
|
|
return handler, signing_key
|
|
|
|
|
|
def _oauth_identity_jwt(
|
|
signing_key: "RSAPrivateKey",
|
|
*,
|
|
expires_in: int = 300,
|
|
audience: str = "litellm-proxy",
|
|
issuer: str = "https://idp.example.test",
|
|
owner: str | None = "jwt-owner",
|
|
scope: str = "",
|
|
claims: dict[str, object] | None = None,
|
|
) -> str:
|
|
import jwt
|
|
|
|
return jwt.encode(
|
|
{
|
|
"sub": "not-the-configured-user-id",
|
|
"identity": {"user_id": owner},
|
|
"email": "owner@example.test",
|
|
"iss": issuer,
|
|
"aud": audience,
|
|
"exp": int(time.time()) + expires_in,
|
|
"scope": scope,
|
|
**(claims or {}),
|
|
},
|
|
signing_key,
|
|
algorithm="RS256",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("header", ["Authorization", "x-litellm-api-key"])
|
|
@pytest.mark.parametrize("policy_allowed", [False, True])
|
|
@pytest.mark.parametrize("server_allowed", [False, True])
|
|
@pytest.mark.parametrize("admin", [False, True])
|
|
@pytest.mark.parametrize("owner_state", ["active", "missing", "inactive", "database_error"])
|
|
async def test_oauth_exchange_stores_token_for_validated_jwt_user(
|
|
jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"],
|
|
header: str,
|
|
policy_allowed: bool,
|
|
server_allowed: bool,
|
|
admin: bool,
|
|
owner_state: str,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
import httpx
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server import discoverable_endpoints
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
handler, signing_key = jwt_oauth_identity
|
|
handler.litellm_jwtauth.custom_validate = lambda claims: policy_allowed
|
|
from litellm.proxy.mcp_server import mcp_server_manager
|
|
|
|
manager: Final = MagicMock()
|
|
manager.get_allowed_mcp_servers = AsyncMock(return_value=["jwt-oauth-server"] if server_allowed else [])
|
|
manager.invalidate_user_oauth_token_cache = AsyncMock()
|
|
monkeypatch.setattr(mcp_server_manager, "global_mcp_server_manager", manager)
|
|
bearer: Final = _oauth_identity_jwt(signing_key, scope="litellm_proxy_admin" if admin else "")
|
|
request: Final = _token_request({header: f"Bearer {bearer}"}, path="/jwt-oauth-server/token")
|
|
server: Final = MCPServer(
|
|
server_id="jwt-oauth-server",
|
|
name="jwt-oauth-server",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
oauth2_flow="authorization_code",
|
|
authorization_url="https://upstream.example.test/authorize",
|
|
token_url="https://upstream.example.test/token",
|
|
client_id="registered-client",
|
|
)
|
|
import litellm
|
|
from litellm.caching.llm_caching_handler import LLMClientCache
|
|
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
|
|
from litellm.models.user import LiteLLM_UserTable
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper
|
|
from litellm.types.llms.custom_http import httpxSpecialProvider
|
|
|
|
def upstream_response(outbound: httpx.Request) -> httpx.Response:
|
|
assert outbound.url == server.token_url
|
|
assert bearer not in str(outbound.headers)
|
|
assert bearer.encode() not in outbound.content
|
|
return httpx.Response(200, json={"access_token": "upstream-token", "token_type": "Bearer"})
|
|
|
|
database: Final = MagicMock()
|
|
users: Final = database.db.litellm_usertable
|
|
users.find_unique = AsyncMock(return_value=None)
|
|
users.find_first = AsyncMock(return_value=None)
|
|
users.create = AsyncMock()
|
|
if owner_state in ("missing", "database_error"):
|
|
handler.user_api_key_cache.delete_cache("jwt-owner")
|
|
if owner_state == "database_error":
|
|
users.find_unique.side_effect = RuntimeError("database unavailable")
|
|
if owner_state == "inactive":
|
|
handler.user_api_key_cache.set_cache(
|
|
"jwt-owner", LiteLLM_UserTable(user_id="jwt-owner", metadata={"scim_active": False})
|
|
)
|
|
table: Final = database.db.litellm_mcpusercredentials
|
|
table.find_unique = AsyncMock(return_value=None)
|
|
table.upsert = AsyncMock()
|
|
monkeypatch.setattr(proxy_server, "prisma_client", database)
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", "oauth-jwt-test-encryption-key")
|
|
clients: Final = LLMClientCache()
|
|
monkeypatch.setattr(litellm, "in_memory_llm_clients_cache", clients)
|
|
async with httpx.AsyncClient(transport=httpx.MockTransport(upstream_response)) as transport:
|
|
upstream: Final = AsyncHTTPHandler()
|
|
await upstream.client.aclose()
|
|
upstream.client = transport
|
|
clients.set_cache("async_httpx_client" + httpxSpecialProvider.Oauth2Check, upstream)
|
|
response: Final = await discoverable_endpoints.exchange_token_with_server(
|
|
request=request,
|
|
mcp_server=server,
|
|
grant_type="authorization_code",
|
|
code="upstream-code",
|
|
redirect_uri="http://localhost/callback",
|
|
client_id="registered-client",
|
|
client_secret=None,
|
|
code_verifier=None,
|
|
)
|
|
assert response.status_code == 200
|
|
assert json.loads(response.body)["access_token"] == "upstream-token"
|
|
users.create.assert_not_awaited()
|
|
if (
|
|
not server_allowed
|
|
or not policy_allowed
|
|
or owner_state in ("inactive", "database_error")
|
|
or (owner_state == "missing" and not admin)
|
|
):
|
|
table.upsert.assert_not_awaited()
|
|
return
|
|
table.upsert.assert_awaited_once()
|
|
stored: Final = table.upsert.call_args.kwargs
|
|
assert stored["where"] == {"user_id_server_id": {"user_id": "jwt-owner", "server_id": server.server_id}}
|
|
credential: Final = stored["data"]["create"]["credential_b64"]
|
|
assert "upstream-token" not in credential
|
|
decoded: Final = decrypt_value_helper(credential, key="mcp_user_credential")
|
|
assert json.loads(decoded)["access_token"] == "upstream-token"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"rejection",
|
|
[
|
|
"expired",
|
|
"audience",
|
|
"issuer",
|
|
"signature",
|
|
"missing_user",
|
|
"unknown_user",
|
|
"disabled",
|
|
"not_premium",
|
|
"scim_inactive",
|
|
"custom_validate",
|
|
"missing_database",
|
|
],
|
|
)
|
|
@pytest.mark.parametrize("credential_write", [False, True])
|
|
async def test_oauth_jwt_identity_rejects_untrusted_or_inactive_owner(
|
|
jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
rejection: str,
|
|
credential_write: bool,
|
|
) -> None:
|
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
|
|
from litellm.models.user import LiteLLM_UserTable
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy.mcp_server import mcp_server_manager
|
|
from litellm.proxy.mcp_server.bridge_token_flow import (
|
|
authorize_oauth_credential_request,
|
|
extract_user_id_from_request,
|
|
)
|
|
|
|
allowed_servers: Final = AsyncMock(return_value=["server-a"])
|
|
monkeypatch.setattr(mcp_server_manager.global_mcp_server_manager, "get_allowed_mcp_servers", allowed_servers)
|
|
handler, signing_key = jwt_oauth_identity
|
|
key: Final = (
|
|
rsa.generate_private_key(public_exponent=65537, key_size=2048) if rejection == "signature" else signing_key
|
|
)
|
|
bearer: Final = _oauth_identity_jwt(
|
|
key,
|
|
expires_in=-60 if rejection == "expired" else 300,
|
|
audience="upstream-only" if rejection == "audience" else "litellm-proxy",
|
|
issuer="https://untrusted.example.test" if rejection == "issuer" else "https://idp.example.test",
|
|
owner=None if rejection == "missing_user" else "unknown" if rejection == "unknown_user" else "jwt-owner",
|
|
)
|
|
if rejection == "disabled":
|
|
monkeypatch.setattr(proxy_server, "general_settings", {"enable_jwt_auth": False})
|
|
if rejection == "not_premium":
|
|
monkeypatch.setattr(proxy_server, "premium_user", False)
|
|
if rejection == "missing_database":
|
|
monkeypatch.setattr(proxy_server, "prisma_client", None)
|
|
if rejection == "scim_inactive":
|
|
handler.user_api_key_cache.set_cache(
|
|
"jwt-owner", LiteLLM_UserTable(user_id="jwt-owner", metadata={"scim_active": False})
|
|
)
|
|
if rejection == "custom_validate":
|
|
handler.litellm_jwtauth.custom_validate = lambda claims: False
|
|
request: Final = _token_request({"Authorization": f"Bearer {bearer}"})
|
|
result: Final = (
|
|
await authorize_oauth_credential_request(request, "server-a")
|
|
if credential_write
|
|
else await extract_user_id_from_request(request)
|
|
)
|
|
assert result is None
|
|
allowed_servers.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("blocked", [False, True])
|
|
async def test_oauth_jwt_cannot_override_explicit_litellm_key(
|
|
jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"],
|
|
blocked: bool,
|
|
) -> None:
|
|
from litellm.proxy._types import UserAPIKeyAuth, hash_token
|
|
from litellm.proxy.mcp_server.bridge_token_flow import extract_user_id_from_request
|
|
|
|
handler, signing_key = jwt_oauth_identity
|
|
key: Final = "sk-explicit-key"
|
|
handler.user_api_key_cache.set_cache(hash_token(key), UserAPIKeyAuth(user_id="key-owner", blocked=blocked))
|
|
request: Final = _token_request(
|
|
{
|
|
"Authorization": f"Bearer {_oauth_identity_jwt(signing_key)}",
|
|
"x-litellm-api-key": key,
|
|
}
|
|
)
|
|
assert await extract_user_id_from_request(request) == (None if blocked else "key-owner")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"mapping", ["active", "blocked", "inactive_owner", "fallback", "pending", "reject", "custom_reject"]
|
|
)
|
|
async def test_oauth_jwt_uses_configured_virtual_key_owner(
|
|
jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"],
|
|
mapping: str,
|
|
) -> None:
|
|
from litellm.models.user import LiteLLM_UserTable
|
|
from litellm.proxy._types import UnregisteredJWTClientBehavior, UserAPIKeyAuth, hash_token
|
|
from litellm.proxy.auth.auth_checks import jwt_key_mapping_cache_key
|
|
from litellm.proxy.mcp_server.bridge_token_flow import extract_user_id_from_request
|
|
|
|
handler, signing_key = jwt_oauth_identity
|
|
handler.litellm_jwtauth.virtual_key_claim_field = "sub"
|
|
if mapping == "custom_reject":
|
|
handler.litellm_jwtauth.custom_validate = lambda claims: False
|
|
handler.litellm_jwtauth.unregistered_jwt_client_behavior = (
|
|
UnregisteredJWTClientBehavior.AUTO_REGISTER
|
|
if mapping == "pending"
|
|
else UnregisteredJWTClientBehavior.REJECT
|
|
if mapping == "reject"
|
|
else UnregisteredJWTClientBehavior.FALLBACK_TEAM_MAPPING
|
|
)
|
|
key_hash: Final = hash_token("sk-mapped-oauth-owner")
|
|
handler.user_api_key_cache.set_cache(
|
|
jwt_key_mapping_cache_key("sub", "not-the-configured-user-id"),
|
|
"__NO_MAPPING__" if mapping in ("fallback", "pending", "reject") else key_hash,
|
|
)
|
|
handler.user_api_key_cache.set_cache(
|
|
key_hash, UserAPIKeyAuth(token=key_hash, user_id="mapped-owner", blocked=mapping == "blocked")
|
|
)
|
|
handler.user_api_key_cache.set_cache(
|
|
"mapped-owner", LiteLLM_UserTable(user_id="mapped-owner", metadata={"scim_active": mapping != "inactive_owner"})
|
|
)
|
|
request: Final = _token_request({"Authorization": f"Bearer {_oauth_identity_jwt(signing_key)}"})
|
|
expected: Final = "jwt-owner" if mapping == "fallback" else "mapped-owner" if mapping == "active" else None
|
|
assert await extract_user_id_from_request(request) == expected
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("allowed_domain", [None, "allowed.example.test"])
|
|
async def test_oauth_jwt_respects_custom_validation_and_email_policy(
|
|
jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"],
|
|
allowed_domain: str | None,
|
|
) -> None:
|
|
from litellm.proxy.mcp_server.bridge_token_flow import extract_user_id_from_request
|
|
|
|
handler, signing_key = jwt_oauth_identity
|
|
handler.litellm_jwtauth.custom_validate = lambda claims: True
|
|
handler.litellm_jwtauth.user_allowed_email_domain = allowed_domain
|
|
request: Final = _token_request({"Authorization": f"Bearer {_oauth_identity_jwt(signing_key)}"})
|
|
assert await extract_user_id_from_request(request) == (None if allowed_domain else "jwt-owner")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("route_allowed", [False, True])
|
|
async def test_oauth_jwt_identity_preserves_separate_mcp_route_authorization(
|
|
jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
route_allowed: bool,
|
|
) -> None:
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy._types import LitellmUserRoles, RoleBasedPermissions, RoleMapping
|
|
from litellm.proxy.auth.handle_jwt import JWTAuthManager
|
|
from litellm.proxy.mcp_server.bridge_token_flow import extract_user_id_from_request
|
|
|
|
handler, signing_key = jwt_oauth_identity
|
|
handler.litellm_jwtauth.user_id_jwt_field = "sub"
|
|
handler.litellm_jwtauth.roles_jwt_field = "aud"
|
|
handler.litellm_jwtauth.object_id_jwt_field = "identity.user_id"
|
|
handler.litellm_jwtauth.role_mappings = [
|
|
RoleMapping(role="litellm-proxy", internal_role=LitellmUserRoles.INTERNAL_USER)
|
|
]
|
|
handler.litellm_jwtauth.enforce_rbac = True
|
|
monkeypatch.setattr(
|
|
proxy_server,
|
|
"general_settings",
|
|
{
|
|
"enable_jwt_auth": True,
|
|
"role_permissions": [
|
|
RoleBasedPermissions(
|
|
role=LitellmUserRoles.INTERNAL_USER,
|
|
routes=["mcp_routes"] if route_allowed else ["/models"],
|
|
)
|
|
],
|
|
},
|
|
)
|
|
bearer: Final = _oauth_identity_jwt(signing_key)
|
|
request: Final = _token_request({"Authorization": f"Bearer {bearer}"}, path="/example/token")
|
|
assert await extract_user_id_from_request(request) == "jwt-owner"
|
|
admission: Final = JWTAuthManager.auth_builder(
|
|
api_key=bearer,
|
|
jwt_handler=handler,
|
|
request_data={},
|
|
general_settings=proxy_server.general_settings,
|
|
route="/mcp/example",
|
|
prisma_client=proxy_server.prisma_client,
|
|
user_api_key_cache=handler.user_api_key_cache,
|
|
parent_otel_span=None,
|
|
proxy_logging_obj=proxy_server.proxy_logging_obj,
|
|
request_method="POST",
|
|
)
|
|
if route_allowed:
|
|
assert (await admission)["user_id"] == "jwt-owner"
|
|
else:
|
|
with pytest.raises(HTTPException) as denial:
|
|
await admission
|
|
assert denial.value.status_code == 403
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("identity", ["sso", "email"])
|
|
@pytest.mark.parametrize("inactive", [False, True])
|
|
@pytest.mark.parametrize("admin", [False, True])
|
|
async def test_oauth_jwt_resolves_canonical_owner_without_cached_identity(
|
|
jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
identity: str,
|
|
inactive: bool,
|
|
admin: bool,
|
|
) -> None:
|
|
from litellm.models.user import LiteLLM_UserTable
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy.auth.handle_jwt import JWTAuthManager
|
|
from litellm.proxy.mcp_server.bridge_token_flow import extract_user_id_from_request
|
|
|
|
handler, signing_key = jwt_oauth_identity
|
|
external_id: Final = f"external-{identity}-{inactive}-{admin}"
|
|
handler.litellm_jwtauth.user_email_jwt_field = "email"
|
|
handler.litellm_jwtauth.admin_allowed_routes = ["mcp_routes"]
|
|
owner: Final = LiteLLM_UserTable(
|
|
user_id="canonical-oauth-owner",
|
|
user_email="owner@example.test",
|
|
metadata={"scim_active": not inactive},
|
|
organization_memberships=[],
|
|
)
|
|
database: Final = MagicMock()
|
|
table: Final = database.db.litellm_usertable
|
|
table.find_unique = AsyncMock(side_effect=[None, owner if identity == "sso" else None])
|
|
table.find_first = AsyncMock(return_value=owner)
|
|
table.update = AsyncMock(return_value=owner)
|
|
monkeypatch.setattr(proxy_server, "prisma_client", database)
|
|
bearer: Final = _oauth_identity_jwt(signing_key, owner=external_id, scope="litellm_proxy_admin" if admin else "")
|
|
request: Final = _token_request({"Authorization": f"Bearer {bearer}"})
|
|
stored_owner: Final = await extract_user_id_from_request(request)
|
|
assert stored_owner == (None if inactive else external_id if admin else "canonical-oauth-owner")
|
|
assert table.find_unique.await_count == 2
|
|
if identity == "email":
|
|
table.find_first.assert_awaited_once()
|
|
if not inactive:
|
|
admission: Final = await JWTAuthManager.auth_builder(
|
|
api_key=bearer,
|
|
jwt_handler=handler,
|
|
request_data={},
|
|
general_settings=proxy_server.general_settings,
|
|
route="/mcp/example",
|
|
prisma_client=database,
|
|
user_api_key_cache=handler.user_api_key_cache,
|
|
parent_otel_span=None,
|
|
proxy_logging_obj=proxy_server.proxy_logging_obj,
|
|
)
|
|
assert stored_owner == admission["user_id"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_oauth_jwt_identity_does_not_provision_or_synchronize_teams(
|
|
jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"],
|
|
) -> None:
|
|
from litellm.models.user import LiteLLM_UserTable
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy.mcp_server.bridge_token_flow import extract_user_id_from_request
|
|
|
|
handler, signing_key = jwt_oauth_identity
|
|
handler.litellm_jwtauth.enforce_team_based_model_access = True
|
|
handler.litellm_jwtauth.team_id_default = "new-team"
|
|
handler.litellm_jwtauth.team_id_upsert = True
|
|
handler.litellm_jwtauth.sync_user_role_and_teams = True
|
|
owner: Final = LiteLLM_UserTable(user_id="jwt-owner", teams=["existing-team"])
|
|
handler.user_api_key_cache.set_cache("jwt-owner", owner)
|
|
request: Final = _token_request(
|
|
{"Authorization": f"Bearer {_oauth_identity_jwt(signing_key)}"}, path="/example/token"
|
|
)
|
|
assert await extract_user_id_from_request(request) == "jwt-owner"
|
|
assert owner.teams == ["existing-team"]
|
|
proxy_server.prisma_client.db.litellm_teamtable.find_unique.assert_not_called()
|
|
proxy_server.prisma_client.db.litellm_teamtable.upsert.assert_not_called()
|
|
proxy_server.prisma_client.db.litellm_usertable.update.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("state", ["active", "inactive", "missing_database"])
|
|
async def test_oauth_refresh_revalidates_the_same_active_user_rule(
|
|
jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
state: str,
|
|
) -> None:
|
|
from litellm.models.user import LiteLLM_UserTable
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy.mcp_server.bridge_token_flow import reload_active_user_by_id
|
|
|
|
handler, _ = jwt_oauth_identity
|
|
user_id: Final = f"jwt-owner-{state}"
|
|
row: Final = LiteLLM_UserTable(user_id=user_id, metadata={"scim_active": state != "inactive"})
|
|
proxy_server.prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=row)
|
|
if state == "missing_database":
|
|
monkeypatch.setattr(proxy_server, "prisma_client", None)
|
|
expected: Final = None if state == "active" else "no_active_key" if state == "inactive" else "unresolvable"
|
|
assert await reload_active_user_by_id(user_id) == expected
|
|
if state != "missing_database":
|
|
cached: Final = handler.user_api_key_cache.get_cache(user_id, model_type=LiteLLM_UserTable)
|
|
assert cached is not None
|
|
assert cached.metadata == row.metadata
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("mapped", [False, True])
|
|
@pytest.mark.parametrize(
|
|
"state", ["allowed", "route_denied", "server_denied", "blocked", "expired", "lookup_error", "cancelled"]
|
|
)
|
|
async def test_oauth_credential_write_keeps_virtual_key_permissions(
|
|
jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
mapped: bool,
|
|
state: str,
|
|
) -> None:
|
|
import asyncio
|
|
|
|
from litellm.proxy._types import UserAPIKeyAuth, hash_token
|
|
from litellm.proxy.auth.auth_checks import OrganizationNotFoundError, jwt_key_mapping_cache_key
|
|
from litellm.proxy.mcp_server import mcp_server_manager
|
|
from litellm.proxy.mcp_server.bridge_token_flow import authorize_oauth_credential_request
|
|
|
|
handler, signing_key = jwt_oauth_identity
|
|
monkeypatch.setattr(
|
|
"litellm.proxy.auth.auth_checks.get_org_object",
|
|
AsyncMock(side_effect=OrganizationNotFoundError("Organization doesn't exist in db.")),
|
|
)
|
|
key: Final = "sk-oauth-permission-test"
|
|
hashed: Final = hash_token(key)
|
|
credential: Final = UserAPIKeyAuth(
|
|
token=hashed,
|
|
user_id="jwt-owner",
|
|
blocked=state == "blocked",
|
|
expires=datetime.now(timezone.utc) - timedelta(seconds=60) if state == "expired" else None,
|
|
allowed_routes=["openai_routes"] if state == "route_denied" else ["mcp_routes"],
|
|
agent_id="agent-scope",
|
|
org_id="org-scope",
|
|
end_user_id="end-user-scope",
|
|
)
|
|
handler.user_api_key_cache.set_cache(hashed, credential)
|
|
if mapped:
|
|
handler.litellm_jwtauth.virtual_key_claim_field = "sub"
|
|
handler.user_api_key_cache.set_cache(jwt_key_mapping_cache_key("sub", "not-the-configured-user-id"), hashed)
|
|
manager: Final = MagicMock()
|
|
manager.get_allowed_mcp_servers = AsyncMock(
|
|
return_value=[] if state == "server_denied" else ["server-a"],
|
|
side_effect=(
|
|
asyncio.CancelledError()
|
|
if state == "cancelled"
|
|
else RuntimeError("permission lookup unavailable")
|
|
if state == "lookup_error"
|
|
else None
|
|
),
|
|
)
|
|
monkeypatch.setattr(mcp_server_manager, "global_mcp_server_manager", manager)
|
|
bearer: Final = _oauth_identity_jwt(signing_key) if mapped else key
|
|
request: Final = _token_request({"Authorization": f"Bearer {bearer}"}, path="/server-a/token")
|
|
if state == "cancelled":
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await authorize_oauth_credential_request(request, "server-a")
|
|
manager.get_allowed_mcp_servers.assert_awaited_once()
|
|
return
|
|
assert await authorize_oauth_credential_request(request, "server-a") == (
|
|
"jwt-owner" if state == "allowed" else None
|
|
)
|
|
if state in ("allowed", "server_denied", "lookup_error"):
|
|
manager.get_allowed_mcp_servers.assert_awaited_once()
|
|
writer: Final = manager.get_allowed_mcp_servers.call_args.args[0]
|
|
assert (writer.user_id, writer.token, writer.org_id, writer.agent_id, writer.end_user_id) == (
|
|
"jwt-owner",
|
|
hashed,
|
|
"org-scope",
|
|
"agent-scope",
|
|
"end-user-scope",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("server_id", ["team-a-server", "team-b-server"])
|
|
async def test_oauth_writer_preserves_claimed_team_instead_of_expanding_user_roster(
|
|
jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
server_id: str,
|
|
) -> None:
|
|
from litellm.models.user import LiteLLM_UserTable
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy._types import LiteLLM_TeamTable, Member
|
|
from litellm.proxy.mcp_server import mcp_server_manager
|
|
from litellm.proxy.mcp_server.bridge_token_flow import authorize_oauth_credential_request
|
|
|
|
handler, signing_key = jwt_oauth_identity
|
|
handler.litellm_jwtauth.team_id_jwt_field = "team"
|
|
handler.litellm_jwtauth.team_id_upsert = True
|
|
handler.litellm_jwtauth.user_id_upsert = True
|
|
handler.litellm_jwtauth.sync_user_role_and_teams = True
|
|
handler.user_api_key_cache.set_cache("jwt-owner", LiteLLM_UserTable(user_id="jwt-owner", teams=["a", "b"]))
|
|
handler.user_api_key_cache.set_cache(
|
|
"team_id:a",
|
|
LiteLLM_TeamTable(team_id="a", models=[], members_with_roles=[Member(user_id="jwt-owner", role="user")]),
|
|
)
|
|
manager: Final = MagicMock()
|
|
manager.get_allowed_mcp_servers = AsyncMock(return_value=["team-a-server"])
|
|
monkeypatch.setattr(mcp_server_manager, "global_mcp_server_manager", manager)
|
|
bearer: Final = _oauth_identity_jwt(signing_key, claims={"team": "a"})
|
|
request: Final = _token_request({"Authorization": f"Bearer {bearer}"}, path=f"/{server_id}/token")
|
|
assert await authorize_oauth_credential_request(request, server_id) == (
|
|
"jwt-owner" if server_id == "team-a-server" else None
|
|
)
|
|
manager.get_allowed_mcp_servers.assert_awaited_once()
|
|
writer: Final = manager.get_allowed_mcp_servers.call_args.args[0]
|
|
assert writer.team_id == "a"
|
|
assert not writer.mcp_admitted_user_subject
|
|
proxy_server.prisma_client.db.litellm_teamtable.create.assert_not_called()
|
|
proxy_server.prisma_client.db.litellm_usertable.create.assert_not_called()
|
|
proxy_server.prisma_client.db.litellm_usertable.update.assert_not_called()
|
|
assert handler.litellm_jwtauth.user_id_upsert and handler.litellm_jwtauth.team_id_upsert
|
|
assert handler.litellm_jwtauth.sync_user_role_and_teams
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_oauth_write_denial_does_not_erase_identity_binding(
|
|
jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server import discoverable_endpoints, mcp_server_manager
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPOAuthIdentityBinding, MCPServer
|
|
|
|
_, signing_key = jwt_oauth_identity
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", "oauth-identity-binding-test-salt")
|
|
manager: Final = MagicMock()
|
|
manager.get_allowed_mcp_servers = AsyncMock(return_value=[])
|
|
monkeypatch.setattr(mcp_server_manager, "global_mcp_server_manager", manager)
|
|
server: Final = MCPServer(
|
|
server_id="bound-server",
|
|
name="bound-server",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
oauth2_flow="authorization_code",
|
|
client_id="client",
|
|
token_url="https://upstream.example.test/token",
|
|
oauth_identity_binding=MCPOAuthIdentityBinding(
|
|
mode="enforce",
|
|
issuer="https://upstream.example.test",
|
|
audiences=["client"],
|
|
),
|
|
)
|
|
request: Final = _token_request({"Authorization": f"Bearer {_oauth_identity_jwt(signing_key)}"})
|
|
code: Final = discoverable_endpoints.seal_bridge_authorization_code(
|
|
"upstream-code",
|
|
"another-owner",
|
|
server.server_id,
|
|
"bound-nonce",
|
|
)
|
|
with pytest.raises(HTTPException) as denied:
|
|
await discoverable_endpoints.exchange_token_with_server(
|
|
request=request,
|
|
mcp_server=server,
|
|
grant_type="authorization_code",
|
|
code=code,
|
|
redirect_uri="http://localhost/callback",
|
|
client_id="client",
|
|
client_secret=None,
|
|
code_verifier="verifier",
|
|
)
|
|
assert denied.value.status_code == 403
|
|
assert denied.value.detail == {"error": "oauth_principal_mismatch"}
|
|
manager.get_allowed_mcp_servers.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("admin_only", [False, True])
|
|
@pytest.mark.parametrize("cimd", [False, True])
|
|
async def test_signed_oauth_callback_honors_credential_write_policy(
|
|
jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
admin_only: bool,
|
|
cimd: bool,
|
|
) -> None:
|
|
import httpx
|
|
|
|
import litellm
|
|
from litellm.caching.llm_caching_handler import LLMClientCache
|
|
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server import discoverable_endpoints, mcp_server_manager
|
|
from litellm.types.llms.custom_http import httpxSpecialProvider
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
server: Final = MCPServer(
|
|
server_id="signed-server",
|
|
name="signed-server",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
oauth2_flow="authorization_code",
|
|
client_id=None if cimd else "client",
|
|
client_id_metadata_document_supported=cimd,
|
|
token_url="https://upstream.example.test/token",
|
|
)
|
|
monkeypatch.setattr(
|
|
proxy_server,
|
|
"general_settings",
|
|
{
|
|
"enable_jwt_auth": True,
|
|
"admin_only_routes": [f"/v1/mcp/server/{server.server_id}/oauth-user-credential"] if admin_only else [],
|
|
},
|
|
)
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", "signed-oauth-test-salt")
|
|
monkeypatch.setenv("PROXY_BASE_URL", "https://gateway.example.com")
|
|
manager: Final = MagicMock()
|
|
manager.get_allowed_mcp_servers = AsyncMock(return_value=[server.server_id])
|
|
manager.invalidate_user_oauth_token_cache = AsyncMock()
|
|
monkeypatch.setattr(mcp_server_manager, "global_mcp_server_manager", manager)
|
|
table: Final = proxy_server.prisma_client.db.litellm_mcpusercredentials
|
|
table.find_unique = AsyncMock(return_value=None)
|
|
table.upsert = AsyncMock()
|
|
clients: Final = LLMClientCache()
|
|
monkeypatch.setattr(litellm, "in_memory_llm_clients_cache", clients)
|
|
|
|
def upstream_response(outbound: httpx.Request) -> httpx.Response:
|
|
assert outbound.url == server.token_url
|
|
assert b"code=upstream-code" in outbound.content
|
|
return httpx.Response(200, json={"access_token": "upstream-token", "token_type": "Bearer"})
|
|
|
|
async with httpx.AsyncClient(transport=httpx.MockTransport(upstream_response)) as transport:
|
|
upstream: Final = AsyncHTTPHandler()
|
|
await upstream.client.aclose()
|
|
upstream.client = transport
|
|
clients.set_cache("async_httpx_client" + httpxSpecialProvider.Oauth2Check, upstream)
|
|
response: Final = await discoverable_endpoints.exchange_token_with_server(
|
|
request=_token_request({}, path="/signed-server/token"),
|
|
mcp_server=server,
|
|
grant_type="authorization_code",
|
|
code=discoverable_endpoints.seal_bridge_authorization_code("upstream-code", "jwt-owner", server.server_id),
|
|
redirect_uri="http://localhost/callback",
|
|
client_id="client",
|
|
client_secret=None,
|
|
code_verifier=None,
|
|
)
|
|
assert response.status_code == 200
|
|
assert json.loads(response.body)["access_token"] == "upstream-token"
|
|
if admin_only:
|
|
table.upsert.assert_not_awaited()
|
|
else:
|
|
table.upsert.assert_awaited_once()
|
|
assert table.upsert.call_args.kwargs["where"]["user_id_server_id"] == {
|
|
"user_id": "jwt-owner",
|
|
"server_id": server.server_id,
|
|
}
|
|
|
|
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper
|
|
|
|
payload: Final = json.loads(
|
|
decrypt_value_helper(table.upsert.call_args.kwargs["data"]["create"]["credential_b64"], "credential_b64")
|
|
)
|
|
if cimd:
|
|
assert payload["cimd_client_id"] == "https://gateway.example.com/oauth/client-metadata.json"
|
|
else:
|
|
assert "cimd_client_id" not in payload
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("allowed", [False, True])
|
|
@pytest.mark.parametrize(
|
|
"credential",
|
|
[
|
|
"jwt",
|
|
"key",
|
|
"expired_jwt",
|
|
"wrong_audience",
|
|
"bad_signature",
|
|
"malformed_jwt",
|
|
"missing_issuer",
|
|
"foreign_explicit",
|
|
"blank_explicit",
|
|
"unknown_key",
|
|
"blocked_key",
|
|
"expired_key",
|
|
"opaque_record",
|
|
"opaque_outage",
|
|
"opaque_oidc",
|
|
"opaque_custom",
|
|
"foreign_unscoped",
|
|
"foreign_configured",
|
|
"encrypted",
|
|
"invalid_encrypted",
|
|
"envelope",
|
|
"master",
|
|
],
|
|
)
|
|
async def test_identity_bound_authorize_preserves_presented_jwt_permissions(
|
|
jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
allowed: bool,
|
|
credential: str,
|
|
) -> None:
|
|
from datetime import datetime, timedelta, timezone
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
import jwt
|
|
|
|
from litellm.models.user import LiteLLM_UserTable
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy._types import JWTIssuerConfig, MCPTransport, UserAPIKeyAuth, hash_token
|
|
from litellm.proxy.auth.auth_checks import ExperimentalUIJWTToken
|
|
from litellm.proxy.mcp_server import discoverable_endpoints, mcp_server_manager
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPOAuthIdentityBinding, MCPServer
|
|
|
|
handler, signing_key = jwt_oauth_identity
|
|
master: Final = "browser-session-test-signing-key-123456789"
|
|
monkeypatch.setattr(proxy_server, "master_key", master)
|
|
monkeypatch.setattr(proxy_server, "user_custom_auth", (lambda: None) if credential == "opaque_custom" else None)
|
|
handler.litellm_jwtauth.oidc_userinfo_enabled = credential == "opaque_oidc"
|
|
if credential == "foreign_unscoped":
|
|
monkeypatch.delenv("JWT_ISSUER")
|
|
if credential == "foreign_configured":
|
|
handler.litellm_jwtauth.issuers = [
|
|
JWTIssuerConfig(
|
|
issuer="https://unrelated.example.test",
|
|
jwks_url="https://idp.example.test/jwks",
|
|
audience="litellm-proxy",
|
|
user_id_jwt_field="identity.user_id",
|
|
)
|
|
]
|
|
proxy_server.prisma_client.get_data = AsyncMock(
|
|
return_value=None,
|
|
side_effect=RuntimeError("database unavailable") if credential == "opaque_outage" else None,
|
|
)
|
|
handler.user_api_key_cache.set_cache("cookie-owner", LiteLLM_UserTable(user_id="cookie-owner"))
|
|
key: Final = "opaque-record" if credential == "opaque_record" else "sk-browser-gateway-key"
|
|
if credential in ("key", "blocked_key", "expired_key", "opaque_record"):
|
|
handler.user_api_key_cache.set_cache(
|
|
hash_token(key),
|
|
UserAPIKeyAuth(
|
|
token=hash_token(key),
|
|
user_id="jwt-owner",
|
|
blocked=credential in ("blocked_key", "opaque_record"),
|
|
expires=datetime.now(timezone.utc) - timedelta(seconds=60) if credential == "expired_key" else None,
|
|
),
|
|
)
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", "authorize-policy-test-salt")
|
|
server: Final = MCPServer(
|
|
server_id="bound-server",
|
|
name="bound-server",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
oauth2_flow="authorization_code",
|
|
client_id="client",
|
|
authorization_url="https://upstream.example.test/authorize",
|
|
token_url="https://upstream.example.test/token",
|
|
oauth_identity_binding=MCPOAuthIdentityBinding(
|
|
mode="enforce",
|
|
issuer="https://upstream.example.test",
|
|
audiences=["client"],
|
|
),
|
|
)
|
|
manager: Final = MagicMock()
|
|
# The full user roster permits the server; the presented JWT may have narrower access.
|
|
manager.get_allowed_mcp_servers = AsyncMock(
|
|
side_effect=lambda auth: [server.server_id] if allowed or auth.mcp_admitted_user_subject else [],
|
|
)
|
|
monkeypatch.setattr(mcp_server_manager, "global_mcp_server_manager", manager)
|
|
bearer: Final = (
|
|
key
|
|
if credential in ("key", "blocked_key", "expired_key", "opaque_record", "unknown_key")
|
|
else "opaque-bearer"
|
|
if credential in ("opaque_outage", "opaque_oidc", "opaque_custom")
|
|
else "not.a.jwt"
|
|
if credential == "malformed_jwt"
|
|
else "llm_env_invalid"
|
|
if credential == "envelope"
|
|
else "v2:gcm:invalid"
|
|
if credential == "invalid_encrypted"
|
|
else master
|
|
if credential == "master"
|
|
else ExperimentalUIJWTToken.get_experimental_ui_login_jwt_auth_token(
|
|
LiteLLM_UserTable(user_id="jwt-owner", user_role="internal_user"),
|
|
)
|
|
if credential == "encrypted"
|
|
else jwt.encode({"iss": "https://idp.example.test"}, "wrong-signing-key-at-least-32-bytes", algorithm="HS256")
|
|
if credential == "bad_signature"
|
|
else jwt.encode({"sub": "jwt-owner"}, signing_key, algorithm="RS256")
|
|
if credential == "missing_issuer"
|
|
else _oauth_identity_jwt(
|
|
signing_key,
|
|
expires_in=-60 if credential == "expired_jwt" else 300,
|
|
audience="another-service" if credential == "wrong_audience" else "litellm-proxy",
|
|
issuer="https://unrelated.example.test"
|
|
if credential.startswith("foreign_") or credential == "blank_explicit"
|
|
else "https://idp.example.test",
|
|
)
|
|
)
|
|
cookie: Final = _browser_session_cookie(monkeypatch, "cookie-owner", signing_key=master)
|
|
response: Final = await discoverable_endpoints.authorize_with_server(
|
|
request=_token_request(
|
|
{
|
|
"Authorization": f"Bearer {bearer}",
|
|
"Cookie": f"token={cookie}",
|
|
**({"x-litellm-api-key": bearer} if credential == "foreign_explicit" else {}),
|
|
**({"x-litellm-api-key": ""} if credential == "blank_explicit" else {}),
|
|
}
|
|
),
|
|
mcp_server=server,
|
|
client_id="client",
|
|
redirect_uri="http://127.0.0.1:6274/callback",
|
|
state="client-state",
|
|
code_challenge="pkce-challenge",
|
|
code_challenge_method="S256",
|
|
)
|
|
redirect: Final = urlparse(response.headers["location"])
|
|
query: Final = parse_qs(redirect.query)
|
|
if allowed and credential in ("jwt", "key", "foreign_unscoped", "foreign_configured"):
|
|
assert redirect.hostname == "upstream.example.test"
|
|
assert query["nonce"] and response.headers.get("set-cookie")
|
|
assert all(call.args[0].user_id == "jwt-owner" for call in manager.get_allowed_mcp_servers.await_args_list)
|
|
else:
|
|
assert redirect.hostname == "127.0.0.1"
|
|
assert query["error"] == ["access_denied"]
|
|
assert query["state"] == ["client-state"]
|
|
assert "set-cookie" not in response.headers
|
|
|
|
proxy_server.prisma_client.db.litellm_mcpusercredentials.upsert.assert_not_called()
|
|
proxy_server.prisma_client.db.litellm_usertable.create.assert_not_called()
|
|
proxy_server.prisma_client.db.litellm_teamtable.create.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("credential", ["none", "opaque", "foreign_jwt"])
|
|
@pytest.mark.parametrize("cookie_state", ["allowed", "server_denied", "expired", "missing", "session_expired"])
|
|
async def test_identity_bound_authorize_unrelated_bearer_uses_browser_session(
|
|
jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
credential: str,
|
|
cookie_state: str,
|
|
) -> None:
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
from litellm.models.user import LiteLLM_UserTable
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server import discoverable_endpoints, mcp_server_manager
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPOAuthIdentityBinding, MCPServer
|
|
|
|
handler, signing_key = jwt_oauth_identity
|
|
master: Final = "browser-session-test-signing-key-123456789"
|
|
monkeypatch.setattr(proxy_server, "master_key", master)
|
|
monkeypatch.setattr(proxy_server, "user_custom_auth", None)
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", "authorize-policy-test-salt")
|
|
handler.user_api_key_cache.set_cache("cookie-owner", LiteLLM_UserTable(user_id="cookie-owner"))
|
|
proxy_server.prisma_client.get_data = AsyncMock(return_value=None)
|
|
server: Final = MCPServer(
|
|
server_id="bound-server",
|
|
name="bound-server",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
oauth2_flow="authorization_code",
|
|
client_id="client",
|
|
authorization_url="https://upstream.example.test/authorize",
|
|
token_url="https://upstream.example.test/token",
|
|
oauth_identity_binding=MCPOAuthIdentityBinding(
|
|
mode="enforce",
|
|
issuer="https://upstream.example.test",
|
|
audiences=["client"],
|
|
),
|
|
)
|
|
manager: Final = MagicMock()
|
|
manager.get_allowed_mcp_servers = AsyncMock(
|
|
return_value=[] if cookie_state == "server_denied" else [server.server_id]
|
|
)
|
|
monkeypatch.setattr(mcp_server_manager, "global_mcp_server_manager", manager)
|
|
bearer: Final = (
|
|
_oauth_identity_jwt(signing_key, issuer="https://unrelated.example.test")
|
|
if credential == "foreign_jwt"
|
|
else "unrelated-upstream-bearer"
|
|
)
|
|
cookie: Final = _browser_session_cookie(
|
|
monkeypatch,
|
|
"cookie-owner",
|
|
signing_key=master,
|
|
expired_cookie=cookie_state == "expired",
|
|
expired_session=cookie_state == "session_expired",
|
|
)
|
|
response: Final = await discoverable_endpoints.authorize_with_server(
|
|
request=_token_request(
|
|
{
|
|
**({"Authorization": f"Bearer {bearer}"} if credential != "none" else {}),
|
|
**({"Cookie": f"token={cookie}"} if cookie_state != "missing" else {}),
|
|
}
|
|
),
|
|
mcp_server=server,
|
|
client_id="client",
|
|
redirect_uri="http://127.0.0.1:6274/callback",
|
|
state="client-state",
|
|
code_challenge="pkce-challenge",
|
|
code_challenge_method="S256",
|
|
)
|
|
redirect: Final = urlparse(response.headers["location"])
|
|
query: Final = parse_qs(redirect.query)
|
|
if cookie_state == "allowed":
|
|
assert redirect.hostname == "upstream.example.test"
|
|
assert query["nonce"] and response.headers.get("set-cookie")
|
|
manager.get_allowed_mcp_servers.assert_awaited_once()
|
|
assert manager.get_allowed_mcp_servers.call_args.args[0].user_id == "cookie-owner"
|
|
elif cookie_state == "server_denied":
|
|
assert query["error"] == ["access_denied"]
|
|
assert query["state"] == ["client-state"]
|
|
else:
|
|
assert redirect.path == "/sso/key/generate"
|
|
manager.get_allowed_mcp_servers.assert_not_awaited()
|
|
proxy_server.prisma_client.db.litellm_mcpusercredentials.upsert.assert_not_called()
|
|
proxy_server.prisma_client.db.litellm_usertable.create.assert_not_called()
|
|
proxy_server.prisma_client.db.litellm_teamtable.create.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("change", ["create", "update", "delete"])
|
|
async def test_authorize_observes_committed_peer_server_changes(monkeypatch, change):
|
|
from datetime import datetime, timedelta, timezone
|
|
from types import SimpleNamespace
|
|
|
|
from fastapi import HTTPException, Request
|
|
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy._types import LiteLLM_MCPServerTable
|
|
from litellm.proxy.mcp_server import discoverable_endpoints
|
|
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
|
|
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", "catalog-consistency-test-key")
|
|
stamp = datetime.now(timezone.utc)
|
|
old_server = _create_id_lookup_oauth2_server()
|
|
old_server.updated_at = stamp
|
|
row = LiteLLM_MCPServerTable(
|
|
server_id=old_server.server_id,
|
|
server_name=old_server.server_name,
|
|
alias=old_server.alias,
|
|
url="https://upstream.example/mcp",
|
|
transport="http",
|
|
auth_type="oauth2",
|
|
authorization_url="https://new-provider.example/authorize",
|
|
token_url="https://new-provider.example/token",
|
|
scopes=["read"],
|
|
credentials={"client_id": "current-client", "client_secret": "current-secret"},
|
|
created_at=stamp,
|
|
updated_at=stamp + timedelta(seconds=1),
|
|
)
|
|
read_rows = AsyncMock(return_value=[] if change == "delete" else [row])
|
|
prisma = SimpleNamespace(
|
|
db=SimpleNamespace(
|
|
litellm_mcpservertable=SimpleNamespace(find_many=read_rows),
|
|
litellm_config=SimpleNamespace(find_unique=AsyncMock(return_value=None)),
|
|
)
|
|
)
|
|
prisma.writer_db = prisma.db
|
|
monkeypatch.setattr(proxy_server, "prisma_client", prisma)
|
|
monkeypatch.setattr(
|
|
global_mcp_server_manager, "registry", {} if change == "create" else {old_server.server_id: old_server}
|
|
)
|
|
monkeypatch.setattr(global_mcp_server_manager, "config_mcp_servers", {})
|
|
request = Request(
|
|
{"type": "http", "scheme": "https", "server": ("gateway.example", 443), "path": "/authorize", "headers": []}
|
|
)
|
|
|
|
if change == "delete":
|
|
with pytest.raises(HTTPException) as exc:
|
|
await discoverable_endpoints.authorize(
|
|
request, "http://localhost/callback", mcp_server_name=old_server.server_id
|
|
)
|
|
assert exc.value.status_code == 404
|
|
else:
|
|
response = await discoverable_endpoints.authorize(
|
|
request, "http://localhost/callback", mcp_server_name=old_server.server_id
|
|
)
|
|
assert response.status_code == 307
|
|
assert response.headers["location"].startswith("https://new-provider.example/authorize?")
|
|
assert "client_id=current-client" in response.headers["location"]
|
|
read_rows.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_server_drops_cached_upstream_oauth_metadata():
|
|
from litellm.proxy._types import LiteLLM_MCPServerTable, MCPTransport
|
|
from litellm.proxy.mcp_server import discoverable_endpoints
|
|
from litellm.proxy.mcp_server.mcp_server_manager import MCPServerManager
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
manager = MCPServerManager()
|
|
server = MCPServer(
|
|
server_id="oauth-cache-server",
|
|
name="oauth_cache_server",
|
|
url="http://old-upstream/mcp",
|
|
transport=MCPTransport.http,
|
|
)
|
|
manager.registry[server.server_id] = server
|
|
stale_key: Final = (server.server_id, server.url)
|
|
other_key: Final = ("other-server", "http://other/mcp")
|
|
discoverable_endpoints._OAUTH_METADATA_CACHE[stale_key] = (time.time() + 300, {"iss": "old-idp"})
|
|
discoverable_endpoints._OAUTH_METADATA_CACHE[other_key] = (time.time() + 300, {"iss": "other"})
|
|
try:
|
|
await manager.update_server(
|
|
LiteLLM_MCPServerTable(
|
|
server_id=server.server_id,
|
|
server_name=server.name,
|
|
url="http://new-upstream/mcp",
|
|
transport=MCPTransport.http,
|
|
)
|
|
)
|
|
assert stale_key not in discoverable_endpoints._OAUTH_METADATA_CACHE
|
|
assert other_key in discoverable_endpoints._OAUTH_METADATA_CACHE
|
|
finally:
|
|
discoverable_endpoints._OAUTH_METADATA_CACHE.pop(stale_key, None)
|
|
discoverable_endpoints._OAUTH_METADATA_CACHE.pop(other_key, None)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_metadata_fetched_before_invalidation_does_not_repopulate_the_cache():
|
|
import asyncio
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server import discoverable_endpoints
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
fetch_upstream_oauth_protected_resource,
|
|
invalidate_oauth_metadata_cache,
|
|
)
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
server = MCPServer(
|
|
server_id="stale-write-server", name="stale_write", url="http://upstream/mcp", transport=MCPTransport.http
|
|
)
|
|
cache_key: Final = (server.server_id, server.url)
|
|
started: Final = asyncio.Event()
|
|
release: Final = asyncio.Event()
|
|
|
|
async def slow_get(url: str, headers: dict[str, str]) -> MagicMock:
|
|
started.set()
|
|
await release.wait()
|
|
return MagicMock(status_code=200, json=MagicMock(return_value={"authorization_servers": ["old-idp"]}))
|
|
|
|
client = MagicMock()
|
|
client.get = slow_get
|
|
discoverable_endpoints._OAUTH_METADATA_CACHE.pop(cache_key, None)
|
|
try:
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=client,
|
|
):
|
|
in_flight: Final = asyncio.create_task(fetch_upstream_oauth_protected_resource(server))
|
|
await started.wait()
|
|
invalidate_oauth_metadata_cache(server.server_id)
|
|
release.set()
|
|
assert await in_flight == {"authorization_servers": ["old-idp"]}
|
|
assert cache_key not in discoverable_endpoints._OAUTH_METADATA_CACHE
|
|
discoverable_endpoints._prune_oauth_metadata_cache()
|
|
assert server.server_id not in discoverable_endpoints._OAUTH_METADATA_GENERATIONS
|
|
finally:
|
|
discoverable_endpoints._OAUTH_METADATA_CACHE.pop(cache_key, None)
|
|
discoverable_endpoints._OAUTH_METADATA_GENERATIONS.pop(server.server_id, None)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_waiting_on_a_lock_handoff_stays_tracked_through_invalidation():
|
|
import asyncio
|
|
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.proxy.mcp_server import discoverable_endpoints
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
fetch_upstream_oauth_protected_resource,
|
|
invalidate_oauth_metadata_cache,
|
|
)
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
server = MCPServer(
|
|
server_id="handoff-server", name="handoff", url="http://upstream/mcp", transport=MCPTransport.http
|
|
)
|
|
cache_key: Final = (server.server_id, server.url)
|
|
started: Final = asyncio.Event()
|
|
release: Final = asyncio.Event()
|
|
|
|
async def slow_get(url: str, headers: dict[str, str]) -> MagicMock:
|
|
started.set()
|
|
await release.wait()
|
|
return MagicMock(status_code=200, json=MagicMock(return_value={"authorization_servers": ["pre-save-idp"]}))
|
|
|
|
client = MagicMock()
|
|
client.get = slow_get
|
|
discoverable_endpoints._OAUTH_METADATA_CACHE.pop(cache_key, None)
|
|
try:
|
|
with patch(
|
|
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
|
return_value=client,
|
|
):
|
|
async with discoverable_endpoints._oauth_metadata_fetch_slot(cache_key):
|
|
shared_lock: Final = discoverable_endpoints._OAUTH_METADATA_FETCH_LOCKS[cache_key]
|
|
waiting: Final = asyncio.create_task(fetch_upstream_oauth_protected_resource(server))
|
|
for _ in range(3):
|
|
await asyncio.sleep(0)
|
|
assert not started.is_set() and not waiting.done()
|
|
invalidate_oauth_metadata_cache(server.server_id)
|
|
assert discoverable_endpoints._OAUTH_METADATA_FETCH_LOCKS.get(cache_key) is shared_lock
|
|
assert discoverable_endpoints._oauth_metadata_fetch_in_flight(server.server_id)
|
|
await started.wait()
|
|
invalidate_oauth_metadata_cache(server.server_id)
|
|
release.set()
|
|
assert await waiting == {"authorization_servers": ["pre-save-idp"]}
|
|
assert cache_key not in discoverable_endpoints._OAUTH_METADATA_CACHE
|
|
assert not discoverable_endpoints._oauth_metadata_fetch_in_flight(server.server_id)
|
|
finally:
|
|
discoverable_endpoints._OAUTH_METADATA_CACHE.pop(cache_key, None)
|
|
discoverable_endpoints._OAUTH_METADATA_FETCH_LOCKS.pop(cache_key, None)
|
|
discoverable_endpoints._OAUTH_METADATA_FETCHERS.pop(cache_key, None)
|
|
discoverable_endpoints._OAUTH_METADATA_GENERATIONS.pop(server.server_id, None)
|
|
|
|
|
|
def test_invalidating_an_idle_server_leaves_no_generation_behind():
|
|
from litellm.proxy.mcp_server import discoverable_endpoints
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import invalidate_oauth_metadata_cache
|
|
|
|
server_ids: Final = tuple(f"churned-server-{i}" for i in range(50))
|
|
try:
|
|
for server_id in server_ids:
|
|
invalidate_oauth_metadata_cache(server_id)
|
|
assert not set(server_ids) & set(discoverable_endpoints._OAUTH_METADATA_GENERATIONS)
|
|
finally:
|
|
for server_id in server_ids:
|
|
discoverable_endpoints._OAUTH_METADATA_GENERATIONS.pop(server_id, None)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_callback_rejects_missing_advertised_issuer(monkeypatch):
|
|
server = _issuer_anchored_oauth_server().model_copy(update={"authorization_response_iss_parameter_supported": True})
|
|
response, _ = await _authorize_then_callback(server, iss=None, monkeypatch=monkeypatch)
|
|
assert response.status_code == 400
|
|
assert "location" not in response.headers
|
|
assert b"upstream-auth-code" not in response.body
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"issuer,url",
|
|
[
|
|
("https://other.example", "https://resource.example/mcp"),
|
|
(None, "https://other.example/mcp"),
|
|
],
|
|
)
|
|
def test_persisted_client_is_not_applied_to_another_upstream(issuer, url):
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
_apply_persisted_dcr_credentials,
|
|
_PersistedDcrCredentials,
|
|
)
|
|
|
|
server = _issuer_anchored_oauth_server(issuer).model_copy(update={"url": url, "client_id": None})
|
|
stored = _PersistedDcrCredentials.model_validate(
|
|
{
|
|
"client_id": "old-client",
|
|
"client_secret": "old-secret",
|
|
"dcr_issuer": "https://idp.example.com",
|
|
"dcr_server_url": "https://resource.example/mcp",
|
|
}
|
|
)
|
|
assert _apply_persisted_dcr_credentials(server, stored) is False
|
|
assert server.client_id is None
|
|
assert server.client_secret is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_registration_does_not_write_client_after_upstream_edit(monkeypatch):
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", "test-mcp-registration-salt")
|
|
from prisma import models
|
|
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import _persist_dcr_client_registration
|
|
|
|
server = _issuer_anchored_oauth_server().model_copy(
|
|
update={"url": "https://resource.example/mcp", "client_id": None}
|
|
)
|
|
current = models.LiteLLM_MCPServerTable.model_construct(
|
|
server_id=server.server_id,
|
|
url="https://other.example/mcp",
|
|
issuer="https://other.example",
|
|
auth_type="oauth2",
|
|
transport="http",
|
|
credentials=None,
|
|
updated_at=datetime.now(timezone.utc),
|
|
)
|
|
prisma = MagicMock()
|
|
table = AsyncMock()
|
|
table.find_unique.return_value = current
|
|
table.update.return_value = current
|
|
prisma.db.litellm_mcpservertable = table
|
|
monkeypatch.setattr(proxy_server, "prisma_client", prisma)
|
|
result = await _persist_dcr_client_registration(
|
|
server, {"client_id": "old-issuer-client"}, "https://proxy.example/callback"
|
|
)
|
|
assert result == "failed"
|
|
table.update.assert_not_called()
|
|
table.update_many.assert_not_called()
|
|
assert server.client_id is None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"response_issuer", ["https://IDP.example.com", "https://idp.example.com/", "https://idp.example.com:443"]
|
|
)
|
|
def test_issuer_validation_uses_exact_identifier(response_issuer):
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import _authorization_response_issuer_is_trusted
|
|
|
|
assert (
|
|
_authorization_response_issuer_is_trusted(response_issuer, {"expected_issuer": "https://idp.example.com"})
|
|
is False
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"issuer,url",
|
|
[
|
|
("https://idp.example.com", "https://resource.example/mcp"),
|
|
("https://idp.example.com", "https://other-resource.example/mcp"),
|
|
],
|
|
)
|
|
def test_persisted_client_remains_usable_for_its_issuer(issuer, url, monkeypatch):
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
_apply_persisted_dcr_credentials,
|
|
_PersistedDcrCredentials,
|
|
)
|
|
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", "test-registration-binding")
|
|
server = _issuer_anchored_oauth_server(issuer).model_copy(update={"url": url, "client_id": None})
|
|
credentials = _PersistedDcrCredentials(
|
|
client_id="registered-client",
|
|
client_secret="registered-secret",
|
|
dcr_issuer=issuer,
|
|
dcr_server_url="https://resource.example/mcp",
|
|
)
|
|
assert _apply_persisted_dcr_credentials(server, credentials) is True
|
|
assert server.client_id == "registered-client"
|
|
assert server.client_secret == "registered-secret"
|
|
assert server.dcr_issuer == issuer
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_callback_does_not_forward_error_from_another_issuer(monkeypatch):
|
|
response, _ = await _authorize_then_callback(
|
|
_issuer_anchored_oauth_server(),
|
|
iss="https://other.example",
|
|
monkeypatch=monkeypatch,
|
|
error="access_denied",
|
|
)
|
|
assert response.status_code == 400
|
|
assert "location" not in response.headers
|
|
assert b"invalid_issuer" in response.body
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("operation", ["authorize", "token"])
|
|
async def test_bound_client_cannot_be_sent_to_a_different_issuer(monkeypatch, operation):
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
server = MCPServer(
|
|
server_id="bound-client",
|
|
name="bound-client",
|
|
transport="http",
|
|
auth_type="oauth2",
|
|
url="https://new.example/mcp",
|
|
issuer="https://new.example",
|
|
client_id="old-client",
|
|
dcr_issuer="https://old.example",
|
|
dcr_server_url="https://old.example/mcp",
|
|
)
|
|
monkeypatch.setattr(endpoints, "_server_with_oauth_endpoints", AsyncMock(return_value=server))
|
|
http_client = MagicMock()
|
|
monkeypatch.setattr(endpoints, "get_async_httpx_client", http_client)
|
|
request = (
|
|
endpoints.authorize_with_server(
|
|
mcp_server=server,
|
|
request=MagicMock(spec=Request),
|
|
redirect_uri="http://localhost/callback",
|
|
client_id="old-client",
|
|
)
|
|
if operation == "authorize"
|
|
else endpoints.exchange_token_with_server(
|
|
mcp_server=server,
|
|
request=MagicMock(spec=Request),
|
|
grant_type="authorization_code",
|
|
code="test-code",
|
|
redirect_uri="http://localhost/callback",
|
|
client_id="old-client",
|
|
client_secret=None,
|
|
code_verifier="verifier",
|
|
)
|
|
)
|
|
with pytest.raises(HTTPException) as error:
|
|
await request
|
|
assert error.value.status_code == 400
|
|
assert "different issuer" in error.value.detail
|
|
http_client.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_failed_registration_preserves_cached_credentials(monkeypatch):
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
|
|
|
|
server = _dcr_redirect_test_server("old-client").model_copy(
|
|
update={
|
|
"url": "https://new.example/mcp",
|
|
"issuer": "https://new.example",
|
|
"dcr_issuer": "https://old.example",
|
|
"client_secret": "old-secret",
|
|
}
|
|
)
|
|
monkeypatch.setattr(endpoints, "_server_with_oauth_endpoints", AsyncMock(return_value=server))
|
|
monkeypatch.setattr(endpoints, "_reuse_persisted_dcr_client_if_available", AsyncMock(return_value=False))
|
|
response = MagicMock()
|
|
response.json.return_value = {"client_id": "new-client"}
|
|
register = AsyncMock(return_value=response)
|
|
monkeypatch.setattr(endpoints, "_post_dcr_registration", register)
|
|
persist = AsyncMock(return_value="failed")
|
|
monkeypatch.setattr(endpoints, "_persist_dcr_client_registration", persist)
|
|
request = MagicMock(spec=Request)
|
|
request.base_url = "https://gateway.example/"
|
|
request.headers = {}
|
|
with pytest.raises(HTTPException) as error:
|
|
await endpoints.register_client_with_server(
|
|
request=request,
|
|
mcp_server=server,
|
|
client_name="app",
|
|
grant_types=["authorization_code"],
|
|
response_types=["code"],
|
|
token_endpoint_auth_method="none",
|
|
replace_stale_client=True,
|
|
)
|
|
assert error.value.status_code == 503
|
|
assert "could not be saved" in error.value.detail
|
|
assert server.client_id == "old-client"
|
|
assert server.client_secret == "old-secret"
|
|
register.assert_awaited_once()
|
|
persist.assert_awaited_once_with(
|
|
server, {"client_id": "new-client"}, "https://gateway.example/callback", replace_stale_client=True
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_registration_without_database_keeps_client_in_temporary_server(monkeypatch):
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
|
|
|
|
monkeypatch.setattr(proxy_server, "prisma_client", None)
|
|
server = _dcr_redirect_test_server(None).model_copy(
|
|
update={"issuer": "https://idp.example", "url": "https://resource.example/mcp"}
|
|
)
|
|
monkeypatch.setattr(endpoints, "_server_with_oauth_endpoints", AsyncMock(return_value=server))
|
|
upstream = MagicMock()
|
|
upstream.json.return_value = {"client_id": "temporary-client", "client_secret": "temporary-secret"}
|
|
monkeypatch.setattr(endpoints, "_post_dcr_registration", AsyncMock(return_value=upstream))
|
|
request = MagicMock(spec=Request)
|
|
request.base_url = "https://gateway.example/"
|
|
request.headers = {}
|
|
response = await endpoints.register_client_with_server(
|
|
request=request,
|
|
mcp_server=server,
|
|
client_name="app",
|
|
grant_types=["authorization_code"],
|
|
response_types=["code"],
|
|
token_endpoint_auth_method="none",
|
|
replace_stale_client=True,
|
|
)
|
|
assert response.status_code == 200
|
|
assert json.loads(response.body)["client_id"] == "temporary-client"
|
|
assert server.client_id == "temporary-client"
|
|
assert server.client_secret == "temporary-secret"
|
|
assert server.dcr_issuer == server.issuer
|
|
assert server.dcr_server_url == server.url
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("config_store", [False, True])
|
|
async def test_registration_does_not_overwrite_credentials_after_failed_identity_read(monkeypatch, config_store):
|
|
from litellm.proxy import utils
|
|
from litellm.proxy.mcp_server import db
|
|
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
|
|
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
|
|
|
|
server = _dcr_redirect_test_server(None)
|
|
monkeypatch.setattr(utils, "get_prisma_client_or_throw", lambda _: MagicMock())
|
|
monkeypatch.setattr(
|
|
db,
|
|
"get_mcp_server",
|
|
AsyncMock(return_value=None, side_effect=None if config_store else RuntimeError("unavailable")),
|
|
)
|
|
monkeypatch.setattr(
|
|
db, "get_mcp_server_oauth_client_credentials", AsyncMock(side_effect=RuntimeError("unavailable"))
|
|
)
|
|
monkeypatch.setattr(global_mcp_server_manager, "is_config_declared_server", lambda _: config_store)
|
|
update = AsyncMock()
|
|
upsert = AsyncMock()
|
|
monkeypatch.setattr(db, "update_mcp_server", update)
|
|
monkeypatch.setattr(db, "upsert_mcp_server_oauth_client_credentials", upsert)
|
|
result = await endpoints._persist_dcr_client_registration(
|
|
server, {"client_id": "new-client"}, "https://gateway.example/callback"
|
|
)
|
|
assert result == "failed"
|
|
assert server.client_id is None
|
|
update.assert_not_awaited()
|
|
upsert.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("winner_available", [False, True])
|
|
async def test_registration_losing_conditional_write_reuses_only_a_matching_winner(monkeypatch, winner_available):
|
|
from litellm.proxy import utils
|
|
from litellm.proxy._types import LiteLLM_MCPServerTable
|
|
from litellm.proxy.mcp_server import db
|
|
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
|
|
|
|
server = _dcr_redirect_test_server(None).model_copy(update={"url": "https://upstream.example/mcp"})
|
|
row = LiteLLM_MCPServerTable.model_validate(server.model_dump(exclude_none=True))
|
|
winner = (
|
|
row.model_copy(
|
|
update={
|
|
"credentials": {
|
|
"client_id": "winner-client",
|
|
"redirect_uris": ["https://gateway.example/callback"],
|
|
"dcr_server_url": server.url,
|
|
}
|
|
}
|
|
)
|
|
if winner_available
|
|
else row
|
|
)
|
|
monkeypatch.setattr(utils, "get_prisma_client_or_throw", lambda _: MagicMock())
|
|
read = AsyncMock(side_effect=[row, winner])
|
|
monkeypatch.setattr(db, "get_mcp_server", read)
|
|
monkeypatch.setattr(endpoints, "_refresh_persisted_dcr_server", AsyncMock())
|
|
update = AsyncMock(return_value=None)
|
|
monkeypatch.setattr(db, "update_mcp_server", update)
|
|
result = await endpoints._persist_dcr_client_registration(
|
|
server, {"client_id": "losing-client"}, "https://gateway.example/callback"
|
|
)
|
|
assert result == ("reused" if winner_available else "failed")
|
|
assert update.await_args.kwargs["expected_updated_at"] == row.updated_at
|
|
assert server.client_id == ("winner-client" if winner_available else None)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("auth_type", (MCPAuth.true_passthrough, MCPAuth.oauth_delegate, MCPAuth.oauth2))
|
|
@pytest.mark.parametrize(
|
|
"metadata", ({"application_type": "native"}, {"application_type": "web"}, {}, {"application_type": None})
|
|
)
|
|
async def test_register_preserves_client_application_type_only_for_bridge_relay(
|
|
auth_type: MCPAuth, metadata: dict[str, object], monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
import httpx
|
|
import respx
|
|
from fastapi import FastAPI
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import router
|
|
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
|
|
|
|
server: Final = _bridge_server(auth_type=auth_type, server_id="application-client", alias="application-client")
|
|
app: Final = FastAPI()
|
|
app.include_router(router)
|
|
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
|
|
monkeypatch.setitem(global_mcp_server_manager.registry, server.server_id, server)
|
|
client_redirect: Final = "http://127.0.0.1:53682/callback"
|
|
with respx.mock as upstream:
|
|
registration: Final = upstream.post(server.registration_url).respond(
|
|
201, json={"client_id": "registered-client"}
|
|
)
|
|
async with httpx.AsyncClient(
|
|
transport=httpx.ASGITransport(app=app), base_url="https://gateway.example"
|
|
) as client:
|
|
response: Final = await client.post(
|
|
f"/{server.server_id}/register",
|
|
json={"client_name": "Test client", "redirect_uris": [client_redirect], **metadata},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["client_id"] == "registered-client"
|
|
assert registration.call_count == 1
|
|
posted: Final = json.loads(registration.calls[0].request.content)
|
|
expected_type: Final = metadata.get("application_type") if auth_type != MCPAuth.oauth2 else None
|
|
if expected_type is None:
|
|
assert "application_type" not in posted
|
|
else:
|
|
assert posted["application_type"] == expected_type
|
|
assert posted["redirect_uris"] == (
|
|
["https://gateway.example/callback"] if auth_type == MCPAuth.oauth2 else [client_redirect]
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("application_type", ("desktop", "", 1, ["native"], {"value": "native"}))
|
|
async def test_register_rejects_invalid_application_type_before_upstream(
|
|
application_type: object, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
import httpx
|
|
import respx
|
|
from fastapi import FastAPI
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import router
|
|
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
|
|
|
|
server: Final = _bridge_server(server_id="invalid-application-client", alias="invalid-application-client")
|
|
app: Final = FastAPI()
|
|
app.include_router(router)
|
|
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
|
|
monkeypatch.setitem(global_mcp_server_manager.registry, server.server_id, server)
|
|
with respx.mock(assert_all_called=False) as upstream:
|
|
registration: Final = upstream.post(server.registration_url).respond(
|
|
201, json={"client_id": "must-not-register"}
|
|
)
|
|
async with httpx.AsyncClient(
|
|
transport=httpx.ASGITransport(app=app), base_url="https://gateway.example"
|
|
) as client:
|
|
response: Final = await client.post(
|
|
f"/{server.server_id}/register",
|
|
json={"redirect_uris": ["http://127.0.0.1:53682/callback"], "application_type": application_type},
|
|
)
|
|
assert response.status_code == 400
|
|
assert "application_type" in response.json()["detail"]
|
|
assert registration.call_count == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("client_id", (None, "preconfigured-client"))
|
|
async def test_register_application_type_keeps_no_registration_endpoint_fallback(
|
|
client_id: str | None, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
import httpx
|
|
import respx
|
|
from fastapi import FastAPI
|
|
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import router
|
|
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
|
|
|
|
server: Final = _bridge_server(
|
|
auth_type=MCPAuth.oauth2,
|
|
server_id="static-client",
|
|
alias="static-client",
|
|
registration_url=None,
|
|
client_id=client_id,
|
|
)
|
|
app: Final = FastAPI()
|
|
app.include_router(router)
|
|
monkeypatch.setitem(global_mcp_server_manager.registry, server.server_id, server)
|
|
with respx.mock as upstream:
|
|
async with httpx.AsyncClient(
|
|
transport=httpx.ASGITransport(app=app), base_url="https://gateway.example"
|
|
) as client:
|
|
response: Final = await client.post(f"/{server.server_id}/register", json={"application_type": "native"})
|
|
assert response.status_code == 200
|
|
assert response.json() == {
|
|
"client_id": server.server_id,
|
|
"client_secret": "dummy",
|
|
"redirect_uris": ["https://gateway.example/callback"],
|
|
}
|
|
assert len(upstream.calls) == 0
|
|
|
|
|
|
def _cimd_oauth_server():
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
return MCPServer.model_validate(
|
|
{
|
|
"server_id": "cimd-server",
|
|
"name": "cimd-server",
|
|
"server_name": "cimd-server",
|
|
"url": "https://mcp.example.com/mcp",
|
|
"transport": "http",
|
|
"auth_type": "oauth2",
|
|
"oauth2_flow": "authorization_code",
|
|
"authorization_url": "https://idp.example.com/authorize",
|
|
"token_url": "https://idp.example.com/token",
|
|
"client_id_metadata_document_supported": True,
|
|
}
|
|
)
|
|
|
|
|
|
def _cimd_request():
|
|
from starlette.requests import Request
|
|
|
|
return Request(
|
|
{
|
|
"type": "http",
|
|
"method": "GET",
|
|
"scheme": "https",
|
|
"path": "/",
|
|
"root_path": "",
|
|
"query_string": b"",
|
|
"headers": [],
|
|
"server": ("gateway.example.com", 443),
|
|
"client": ("127.0.0.1", 10000),
|
|
}
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cimd_registration_returns_https_identity_without_dcr(monkeypatch, respx_mock):
|
|
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
|
|
|
|
monkeypatch.setenv("PROXY_BASE_URL", "https://gateway.example.com")
|
|
response = await endpoints.register_client_with_server(
|
|
_cimd_request(), _cimd_oauth_server(), "Gateway", None, None, None
|
|
)
|
|
body = json.loads(response.body) if hasattr(response, "body") else response
|
|
assert body["client_id"] == "https://gateway.example.com/oauth/client-metadata.json"
|
|
assert "client_secret" not in body
|
|
assert len(respx_mock.calls) == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cimd_authorization_uses_metadata_identity_and_s256(monkeypatch):
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
|
|
|
|
monkeypatch.setenv("PROXY_BASE_URL", "https://gateway.example.com")
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", "cimd-test-state-signing-key")
|
|
response = await endpoints.authorize_with_server(
|
|
_cimd_request(),
|
|
_cimd_oauth_server(),
|
|
"placeholder",
|
|
"https://gateway.example.com/ui/",
|
|
code_challenge="a" * 43,
|
|
code_challenge_method="S256",
|
|
)
|
|
params = parse_qs(urlparse(response.headers["location"]).query)
|
|
assert params["client_id"] == ["https://gateway.example.com/oauth/client-metadata.json"]
|
|
assert params["redirect_uri"] == ["https://gateway.example.com/callback"]
|
|
assert params["code_challenge_method"] == ["S256"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cimd_authorization_rejects_missing_pkce(monkeypatch):
|
|
from fastapi import HTTPException
|
|
|
|
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
|
|
|
|
monkeypatch.setenv("PROXY_BASE_URL", "https://gateway.example.com")
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", "cimd-test-state-signing-key")
|
|
with pytest.raises(HTTPException) as exc:
|
|
await endpoints.authorize_with_server(
|
|
_cimd_request(), _cimd_oauth_server(), "placeholder", "https://gateway.example.com/ui/"
|
|
)
|
|
assert exc.value.status_code == 400
|
|
|
|
|
|
def test_cimd_refresh_request_uses_same_identity_without_caller_secret(monkeypatch):
|
|
from litellm.proxy.mcp_server.oauth_utils import build_upstream_oauth2_token_request
|
|
|
|
monkeypatch.setenv("PROXY_BASE_URL", "https://gateway.example.com")
|
|
request = build_upstream_oauth2_token_request(
|
|
_cimd_oauth_server(), auth_method=None, client_id="placeholder", client_secret="dummy"
|
|
)
|
|
assert request.body["client_id"] == "https://gateway.example.com/oauth/client-metadata.json"
|
|
assert "client_secret" not in request.body
|
|
assert "Authorization" not in request.headers
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"base", [None, "http://gateway.example.com", "invalid", "https://user:secret@gateway.example.com"]
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_cimd_without_stable_https_origin_reports_actionable_error(monkeypatch, base, respx_mock):
|
|
from fastapi import HTTPException
|
|
|
|
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
|
|
|
|
monkeypatch.delenv("PROXY_BASE_URL", raising=False)
|
|
if base is not None:
|
|
monkeypatch.setenv("PROXY_BASE_URL", base)
|
|
with pytest.raises(HTTPException) as exc:
|
|
await endpoints.register_client_with_server(_cimd_request(), _cimd_oauth_server(), "Gateway", None, None, None)
|
|
assert exc.value.status_code == 400
|
|
assert "HTTPS PROXY_BASE_URL" in str(exc.value.detail)
|
|
assert len(respx_mock.calls) == 0
|
|
|
|
|
|
@pytest.mark.parametrize("base", [None, "http://gateway.example.com"])
|
|
@pytest.mark.asyncio
|
|
async def test_cimd_without_https_origin_falls_back_to_available_dcr(monkeypatch, base, respx_mock):
|
|
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
|
|
|
|
monkeypatch.delenv("PROXY_BASE_URL", raising=False)
|
|
if base is not None:
|
|
monkeypatch.setenv("PROXY_BASE_URL", base)
|
|
server = _cimd_oauth_server().model_copy(update={"registration_url": "https://idp.example.com/register"})
|
|
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
|
|
post = respx_mock.post("https://idp.example.com/register").respond(201, json={"client_id": "registered-client"})
|
|
response = await endpoints.register_client_with_server(_cimd_request(), server, "Gateway", None, None, None)
|
|
assert json.loads(response.body)["client_id"] == "registered-client"
|
|
assert post.call_count == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cimd_yields_to_dynamic_registration_when_the_authorization_server_offers_both(monkeypatch, respx_mock):
|
|
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
|
|
from litellm.proxy.mcp_server.oauth_utils import get_cimd_client_id
|
|
|
|
monkeypatch.setenv("PROXY_BASE_URL", "https://gateway.example.com")
|
|
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
|
|
server = _cimd_oauth_server().model_copy(update={"registration_url": "https://idp.example.com/register"})
|
|
post = respx_mock.post("https://idp.example.com/register").respond(201, json={"client_id": "registered-client"})
|
|
response = await endpoints.register_client_with_server(_cimd_request(), server, "Gateway", None, None, None)
|
|
assert json.loads(response.body)["client_id"] == "registered-client"
|
|
assert post.call_count == 1
|
|
assert get_cimd_client_id(server) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cimd_is_preferred_over_dynamic_registration_when_the_deployment_opts_in(monkeypatch, respx_mock):
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
|
|
|
|
monkeypatch.setenv("PROXY_BASE_URL", "https://gateway.example.com")
|
|
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
|
|
monkeypatch.setattr(proxy_server, "general_settings", {"mcp_prefer_client_id_metadata_document": True})
|
|
server = _cimd_oauth_server().model_copy(update={"registration_url": "https://idp.example.com/register"})
|
|
post = respx_mock.post("https://idp.example.com/register").respond(201, json={"client_id": "registered-client"})
|
|
response = await endpoints.register_client_with_server(_cimd_request(), server, "Gateway", None, None, None)
|
|
body = json.loads(response.body) if hasattr(response, "body") else response
|
|
assert body["client_id"] == "https://gateway.example.com/oauth/client-metadata.json"
|
|
assert "client_secret" not in body
|
|
assert post.call_count == 0
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"updates",
|
|
[
|
|
{"client_id": "static-client", "client_secret": "static-secret"},
|
|
{"client_id": "persisted-dcr-client", "dcr_issuer": "https://idp.example.com"},
|
|
{"client_id_metadata_document_supported": False},
|
|
{"auth_type": "oauth_delegate", "dcr_bridge": True},
|
|
{"auth_type": "true_passthrough", "dcr_bridge": True},
|
|
{"delegate_auth_to_upstream": True},
|
|
{"oauth2_flow": "client_credentials"},
|
|
{"client_secret": "configured-secret"},
|
|
{"token_endpoint_auth_method": "client_secret_basic"},
|
|
],
|
|
)
|
|
def test_cimd_preserves_existing_identity_and_other_auth_modes(monkeypatch, updates):
|
|
from litellm.proxy.mcp_server.oauth_utils import get_cimd_client_id
|
|
|
|
monkeypatch.setenv("PROXY_BASE_URL", "https://gateway.example.com")
|
|
assert get_cimd_client_id(_cimd_oauth_server().model_copy(update=updates)) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cimd_document_is_public_and_binds_configured_origin(monkeypatch):
|
|
import httpx
|
|
from fastapi import FastAPI
|
|
|
|
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
|
|
|
|
monkeypatch.setenv("PROXY_BASE_URL", "https://gateway.example.com/proxy")
|
|
app = FastAPI()
|
|
app.include_router(endpoints.router)
|
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="https://attacker.example") as client:
|
|
response = await client.get("/oauth/client-metadata.json", headers={"X-Forwarded-Host": "attacker.example"})
|
|
assert response.status_code == 200
|
|
assert response.json()["client_id"] == "https://gateway.example.com/proxy/oauth/client-metadata.json"
|
|
assert response.json()["redirect_uris"] == ["https://gateway.example.com/proxy/callback"]
|
|
assert response.json()["token_endpoint_auth_method"] == "none"
|
|
assert "client_secret" not in response.json()
|
|
assert response.headers["cache-control"] == "public, max-age=300"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("base", [None, "https://[invalid"])
|
|
async def test_cimd_document_is_unavailable_without_configured_https_origin(monkeypatch, base):
|
|
from fastapi import HTTPException
|
|
|
|
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
|
|
|
|
monkeypatch.delenv("PROXY_BASE_URL", raising=False)
|
|
if base is not None:
|
|
monkeypatch.setenv("PROXY_BASE_URL", base)
|
|
with pytest.raises(HTTPException) as exc:
|
|
await endpoints.oauth_client_metadata()
|
|
assert exc.value.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cimd_upstream_client_rejection_is_gateway_fault(monkeypatch, respx_mock):
|
|
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
|
|
|
|
monkeypatch.setenv("PROXY_BASE_URL", "https://gateway.example.com")
|
|
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
|
|
upstream = respx_mock.post("https://idp.example.com/token").respond(
|
|
401, json={"error": "invalid_client", "error_description": "provider-private-detail"}
|
|
)
|
|
response = await endpoints.exchange_token_with_server(
|
|
request=_cimd_request(),
|
|
mcp_server=_cimd_oauth_server(),
|
|
grant_type="authorization_code",
|
|
code="code",
|
|
redirect_uri="https://gateway.example.com/callback",
|
|
client_id="caller-placeholder",
|
|
client_secret="dummy",
|
|
code_verifier="verifier",
|
|
)
|
|
assert upstream.call_count == 1
|
|
assert response.status_code == 502
|
|
body = json.loads(response.body)
|
|
assert body["error"] == "server_error"
|
|
assert "provider-private-detail" not in body["error_description"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("flow", ["refresh", "authorize"])
|
|
async def test_optional_cimd_discovery_preserves_the_callers_configured_endpoint(
|
|
monkeypatch: pytest.MonkeyPatch, respx_mock: "MockRouter", flow: str
|
|
) -> None:
|
|
from urllib.parse import parse_qs
|
|
|
|
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
|
|
from litellm.proxy.mcp_server import mcp_server_manager as manager_module
|
|
|
|
monkeypatch.setenv("PROXY_BASE_URL", "https://gateway.example.com")
|
|
monkeypatch.setenv("LITELLM_MCP_OAUTH_DISCOVERY_ON_STARTUP", "0")
|
|
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", "cimd-test-state-signing-key")
|
|
token: Final = respx_mock.post("https://idp.example.com/token").respond(
|
|
200, json={"access_token": "upstream-access", "token_type": "Bearer", "expires_in": 3600}
|
|
)
|
|
discovery: Final = respx_mock.route().respond(503)
|
|
manager: Final = manager_module.MCPServerManager()
|
|
monkeypatch.setattr(manager_module, "global_mcp_server_manager", manager)
|
|
await manager.load_servers_from_config(
|
|
{
|
|
"manual": {
|
|
"url": "https://mcp.example.com/mcp",
|
|
"transport": "http",
|
|
"auth_type": "oauth2",
|
|
"oauth2_flow": "authorization_code",
|
|
**(
|
|
{"token_url": "https://idp.example.com/token"}
|
|
if flow == "refresh"
|
|
else {"authorization_url": "https://idp.example.com/authorize"}
|
|
),
|
|
}
|
|
}
|
|
)
|
|
server: Final = next(iter(manager.config_mcp_servers.values()))
|
|
async with manager.catalog.operation():
|
|
if flow == "refresh":
|
|
response: Final = await endpoints.exchange_token_with_server(
|
|
request=_cimd_request(),
|
|
mcp_server=server,
|
|
grant_type="refresh_token",
|
|
refresh_token="existing-refresh",
|
|
client_id="existing-client",
|
|
code=None,
|
|
redirect_uri=None,
|
|
client_secret=None,
|
|
code_verifier=None,
|
|
)
|
|
assert response.status_code == 200
|
|
assert json.loads(response.body)["access_token"] == "upstream-access"
|
|
assert parse_qs(token.calls[0].request.content.decode())["refresh_token"] == ["existing-refresh"]
|
|
else:
|
|
redirect: Final = await endpoints.authorize_with_server(
|
|
_cimd_request(),
|
|
server,
|
|
"existing-client",
|
|
"https://gateway.example.com/ui/",
|
|
code_challenge="a" * 43,
|
|
code_challenge_method="S256",
|
|
)
|
|
assert redirect.status_code == 307
|
|
assert redirect.headers["location"].startswith("https://idp.example.com/authorize?")
|
|
assert discovery.call_count > 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("origin", [None, "https://renamed-gateway.example.com"])
|
|
async def test_token_route_preserves_saved_cimd_grant_after_origin_change(
|
|
jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
respx_mock: "MockRouter",
|
|
origin: str | None,
|
|
) -> None:
|
|
from types import SimpleNamespace
|
|
from urllib.parse import parse_qs
|
|
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy.mcp_server import db, mcp_server_manager
|
|
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
|
|
|
|
monkeypatch.setenv("PROXY_BASE_URL", "https://gateway.example.com")
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", "saved-cimd-route-test-salt")
|
|
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
|
|
server: Final = _cimd_oauth_server()
|
|
mcp_server_manager.global_mcp_server_manager.registry[server.server_id] = server
|
|
identity: Final = "https://gateway.example.com/oauth/client-metadata.json"
|
|
table: Final = proxy_server.prisma_client.db.litellm_mcpusercredentials
|
|
table.find_unique = AsyncMock(return_value=None)
|
|
table.upsert = AsyncMock()
|
|
await endpoints._store_per_user_token_server_side(
|
|
server,
|
|
"jwt-owner",
|
|
{"access_token": "expired", "refresh_token": "saved-refresh", "expires_in": -1},
|
|
cimd_client_id=identity,
|
|
)
|
|
|
|
async def saved_row(**kwargs):
|
|
return SimpleNamespace(credential_b64=table.upsert.call_args.kwargs["data"]["create"]["credential_b64"])
|
|
|
|
table.find_unique.side_effect = saved_row
|
|
if origin is None:
|
|
monkeypatch.delenv("PROXY_BASE_URL")
|
|
else:
|
|
monkeypatch.setenv("PROXY_BASE_URL", origin)
|
|
_, key = jwt_oauth_identity
|
|
upstream: Final = respx_mock.post(server.token_url).respond(
|
|
200, json={"access_token": "fresh", "refresh_token": "rotated", "expires_in": 3600}
|
|
)
|
|
response: Final = await endpoints.exchange_token_with_server(
|
|
_token_request({"Authorization": f"Bearer {_oauth_identity_jwt(key, scope='litellm_proxy_admin')}"}),
|
|
server,
|
|
"refresh_token",
|
|
None,
|
|
None,
|
|
identity,
|
|
None,
|
|
None,
|
|
refresh_token="saved-refresh",
|
|
)
|
|
assert response.status_code == 200
|
|
assert parse_qs(upstream.calls[0].request.content.decode())["client_id"] == [identity]
|
|
persisted: Final = await db.get_user_oauth_credential(proxy_server.prisma_client, "jwt-owner", server.server_id)
|
|
assert persisted is not None and persisted["cimd_client_id"] == identity
|
|
assert persisted["refresh_token"] == "rotated"
|
|
assert table.upsert.await_count == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"state",
|
|
[
|
|
"matching",
|
|
"unicode_grant",
|
|
"foreign_refresh",
|
|
"missing_refresh",
|
|
"missing_grant",
|
|
"database_missing",
|
|
"database_outage",
|
|
"static_client",
|
|
"anonymous",
|
|
],
|
|
)
|
|
async def test_saved_refresh_metadata_is_bound_to_the_callers_stored_grant(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
state: str,
|
|
) -> None:
|
|
from types import SimpleNamespace
|
|
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy.mcp_server import db
|
|
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
|
|
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", "saved-cimd-owner-test-salt")
|
|
server: Final = _cimd_oauth_server()
|
|
identity: Final = "https://original-gateway.example.com/oauth/client-metadata.json"
|
|
grant: Final = "alice-refresh-\u00e9" if state == "unicode_grant" else "alice-refresh"
|
|
database: Final = MagicMock()
|
|
table: Final = database.db.litellm_mcpusercredentials
|
|
table.find_unique = AsyncMock(return_value=None)
|
|
table.upsert = AsyncMock()
|
|
monkeypatch.setattr(proxy_server, "prisma_client", database)
|
|
await db.store_user_oauth_credential(
|
|
database,
|
|
"alice",
|
|
server.server_id,
|
|
"expired",
|
|
refresh_token=None if state == "missing_refresh" else grant,
|
|
cimd_client_id=identity,
|
|
)
|
|
table.find_unique.reset_mock()
|
|
table.find_unique.return_value = (
|
|
None
|
|
if state == "missing_grant"
|
|
else SimpleNamespace(credential_b64=table.upsert.call_args.kwargs["data"]["create"]["credential_b64"])
|
|
)
|
|
if state == "database_missing":
|
|
monkeypatch.setattr(proxy_server, "prisma_client", None)
|
|
if state == "database_outage":
|
|
table.find_unique.side_effect = RuntimeError("database unavailable")
|
|
if state == "static_client":
|
|
server.client_id = "configured-client"
|
|
resolved: Final = await endpoints._saved_refresh_credential(
|
|
server,
|
|
None if state == "anonymous" else "alice",
|
|
"foreign-refresh" if state == "foreign_refresh" else grant,
|
|
)
|
|
if state in ("matching", "unicode_grant", "static_client"):
|
|
assert resolved is not None and resolved["cimd_client_id"] == identity
|
|
assert resolved["refresh_token"] == grant
|
|
else:
|
|
assert resolved is None
|
|
if state in ("anonymous", "database_missing"):
|
|
table.find_unique.assert_not_awaited()
|
|
else:
|
|
table.find_unique.assert_awaited_once_with(
|
|
where={"user_id_server_id": {"user_id": "alice", "server_id": server.server_id}}
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"prior_scopes, expected", [(["tools.read"], "tools.read tools.write"), (None, "tools.write"), ([], "tools.write")]
|
|
)
|
|
async def test_managed_authorize_retains_the_callers_previous_grant(
|
|
jwt_oauth_identity, monkeypatch, prior_scopes, expected
|
|
):
|
|
from types import SimpleNamespace
|
|
from urllib.parse import parse_qs, urlsplit
|
|
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy.mcp_server import db
|
|
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
|
|
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
|
|
|
|
_, signing_key = jwt_oauth_identity
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", "scope-grant-test-salt")
|
|
server = _cimd_oauth_server().model_copy(update={"client_id": "scope-client", "scopes": ["tools.read"]})
|
|
global_mcp_server_manager.registry[server.server_id] = server
|
|
credential = db.encrypt_value_helper(
|
|
json.dumps({"type": "oauth2", "access_token": "old-access", "scopes": prior_scopes})
|
|
)
|
|
table = proxy_server.prisma_client.db.litellm_mcpusercredentials
|
|
table.find_unique = AsyncMock(return_value=SimpleNamespace(credential_b64=credential))
|
|
request = _token_request(
|
|
{"Authorization": f"Bearer {_oauth_identity_jwt(signing_key, scope='litellm_proxy_admin')}"}
|
|
)
|
|
response = await endpoints.authorize_with_server(
|
|
request,
|
|
server,
|
|
"scope-client",
|
|
"http://127.0.0.1:9/cb",
|
|
scope="tools.write",
|
|
code_challenge="challenge",
|
|
code_challenge_method="S256",
|
|
)
|
|
assert parse_qs(urlsplit(response.headers["location"]).query)["scope"] == [expected]
|
|
assert table.find_unique.call_args.kwargs["where"] == {
|
|
"user_id_server_id": {"user_id": "jwt-owner", "server_id": server.server_id}
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"returned_scope, expected", [(None, ["tools.read", "tools.write"]), ("tools.read", ["tools.read"]), ("", [])]
|
|
)
|
|
async def test_managed_code_exchange_preserves_requested_scope_only_when_omitted(
|
|
jwt_oauth_identity,
|
|
monkeypatch,
|
|
respx_mock,
|
|
returned_scope,
|
|
expected,
|
|
):
|
|
from http.cookies import SimpleCookie
|
|
from urllib.parse import parse_qs, urlsplit
|
|
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy.mcp_server import db
|
|
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
|
|
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
|
|
|
|
_, signing_key = jwt_oauth_identity
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", "requested-scope-test-salt")
|
|
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
|
|
server = _cimd_oauth_server().model_copy(update={"client_id": "scope-client"})
|
|
global_mcp_server_manager.registry[server.server_id] = server
|
|
table = proxy_server.prisma_client.db.litellm_mcpusercredentials
|
|
table.find_unique = AsyncMock(return_value=None)
|
|
table.upsert = AsyncMock()
|
|
request = _token_request(
|
|
{"Authorization": f"Bearer {_oauth_identity_jwt(signing_key, scope='litellm_proxy_admin')}"}
|
|
)
|
|
started = await endpoints.authorize_with_server(
|
|
request,
|
|
server,
|
|
"scope-client",
|
|
"http://127.0.0.1:9/cb",
|
|
scope="tools.read tools.write",
|
|
code_challenge="challenge",
|
|
code_challenge_method="S256",
|
|
)
|
|
state = parse_qs(urlsplit(started.headers["location"]).query)["state"][0]
|
|
cookies = SimpleCookie()
|
|
cookies.load(started.headers["set-cookie"])
|
|
callback_request = _token_request({"Cookie": "; ".join(f"{name}={value.value}" for name, value in cookies.items())})
|
|
returned = await endpoints.callback(callback_request, code="provider-code", state=state, error=None, iss=None)
|
|
code = parse_qs(urlsplit(returned.headers["location"]).query)["code"][0]
|
|
upstream = respx_mock.post(server.token_url).respond(
|
|
200,
|
|
json={
|
|
"access_token": "new-access",
|
|
"token_type": "Bearer",
|
|
"refresh_token": "new-refresh",
|
|
**({"scope": returned_scope} if returned_scope is not None else {}),
|
|
},
|
|
)
|
|
exchanged = await endpoints.exchange_token_with_server(
|
|
request,
|
|
server,
|
|
"authorization_code",
|
|
client_id="scope-client",
|
|
code=code,
|
|
code_verifier="verifier",
|
|
redirect_uri="http://127.0.0.1:9/cb",
|
|
client_secret=None,
|
|
)
|
|
assert parse_qs(upstream.calls[0].request.content.decode())["code"] == ["provider-code"]
|
|
saved = db.decode_oauth_payload(table.upsert.call_args.kwargs["data"]["create"]["credential_b64"])
|
|
assert saved is not None and saved.get("scopes") == expected
|
|
assert json.loads(exchanged.body)["scope"] == " ".join(expected or ())
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("advertised, expected", [(True, "tools.read offline_access"), (False, "tools.read")])
|
|
async def test_managed_authorize_requests_only_selected_scope_and_supported_refresh_access(
|
|
monkeypatch, advertised, expected
|
|
):
|
|
from urllib.parse import parse_qs, urlsplit
|
|
|
|
from mcp.shared.auth import OAuthMetadata
|
|
|
|
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
|
|
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", "metadata-scope-test-salt")
|
|
metadata = OAuthMetadata(
|
|
issuer="https://idp.example.com",
|
|
authorization_endpoint="https://idp.example.com/authorize",
|
|
token_endpoint="https://idp.example.com/token",
|
|
response_types_supported=["code"],
|
|
scopes_supported=["tools.read", "admin"] + (["offline_access"] if advertised else []),
|
|
)
|
|
server = _cimd_oauth_server().model_copy(update={"authorization_server_metadata": metadata.model_dump(mode="json")})
|
|
response = await endpoints.authorize_with_server(
|
|
_cimd_request(),
|
|
server,
|
|
"scope-client",
|
|
"http://127.0.0.1:9/cb",
|
|
scope="tools.read",
|
|
code_challenge="challenge",
|
|
code_challenge_method="S256",
|
|
)
|
|
assert parse_qs(urlsplit(response.headers["location"]).query)["scope"] == [expected]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("mismatch", ["server", "issuer", "user", "mode", "tampered", "invalid_payload"])
|
|
async def test_managed_scope_code_cannot_cross_oauth_boundaries(jwt_oauth_identity, monkeypatch, respx_mock, mismatch):
|
|
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
|
|
|
|
_, signing_key = jwt_oauth_identity
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", "scope-boundary-test-salt")
|
|
server = _cimd_oauth_server().model_copy(update={"client_id": "scope-client"})
|
|
payload = endpoints._ManagedAuthorizationCode(
|
|
upstream_code="provider-code",
|
|
mcp_server_id="other-server" if mismatch == "server" else server.server_id,
|
|
issuer="https://other-issuer.test" if mismatch == "issuer" else server.issuer,
|
|
requested_scope="tools.read tools.write",
|
|
scope_user_id="other-user" if mismatch == "user" else "jwt-owner",
|
|
)
|
|
code = endpoints._MANAGED_AUTH_CODE_PREFIX + (
|
|
"invalid"
|
|
if mismatch == "tampered"
|
|
else endpoints.encrypt_value_helper("{}" if mismatch == "invalid_payload" else payload.model_dump_json())
|
|
)
|
|
request = _token_request(
|
|
{"Authorization": f"Bearer {_oauth_identity_jwt(signing_key, scope='litellm_proxy_admin')}"}
|
|
)
|
|
if mismatch == "mode":
|
|
server.auth_type = MCPAuth.true_passthrough
|
|
with pytest.raises(HTTPException) as raised:
|
|
await endpoints.exchange_token_with_server(
|
|
request,
|
|
server,
|
|
"authorization_code",
|
|
code=code,
|
|
redirect_uri="http://127.0.0.1:9/cb",
|
|
client_id="scope-client",
|
|
client_secret=None,
|
|
code_verifier="verifier",
|
|
)
|
|
assert raised.value.status_code == 400
|
|
assert respx_mock.calls.call_count == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"scope, requested_scope, expected",
|
|
[
|
|
(None, None, ["tools.read", "tools.write"]),
|
|
("tools.read", None, ["tools.read"]),
|
|
("", None, []),
|
|
(None, "tools.write", ["tools.write"]),
|
|
("tools.read", "tools.write", ["tools.read"]),
|
|
("", "tools.write", []),
|
|
],
|
|
)
|
|
async def test_managed_refresh_retains_only_the_actual_grant(
|
|
jwt_oauth_identity, monkeypatch, respx_mock, scope, requested_scope, expected
|
|
):
|
|
from types import SimpleNamespace
|
|
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy.mcp_server import db
|
|
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
|
|
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
|
|
|
|
_, signing_key = jwt_oauth_identity
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", "refresh-scope-test-salt")
|
|
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
|
|
server = _cimd_oauth_server().model_copy(update={"client_id": "scope-client"})
|
|
global_mcp_server_manager.registry[server.server_id] = server
|
|
credential = db.encrypt_value_helper(
|
|
json.dumps(
|
|
{
|
|
"type": "oauth2",
|
|
"access_token": "old-access",
|
|
"refresh_token": "owner-refresh",
|
|
"scopes": ["tools.read", "tools.write"],
|
|
}
|
|
)
|
|
)
|
|
table = proxy_server.prisma_client.db.litellm_mcpusercredentials
|
|
table.find_unique = AsyncMock(return_value=SimpleNamespace(credential_b64=credential))
|
|
table.upsert = AsyncMock()
|
|
upstream = respx_mock.post(server.token_url).respond(
|
|
200,
|
|
json={
|
|
"access_token": "new-access",
|
|
"token_type": "Bearer",
|
|
"refresh_token": "new-refresh",
|
|
**({"scope": scope} if scope is not None else {}),
|
|
},
|
|
)
|
|
request = _token_request(
|
|
{"Authorization": f"Bearer {_oauth_identity_jwt(signing_key, scope='litellm_proxy_admin')}"}
|
|
)
|
|
await endpoints.exchange_token_with_server(
|
|
request,
|
|
server,
|
|
"refresh_token",
|
|
code=None,
|
|
redirect_uri=None,
|
|
client_id="scope-client",
|
|
client_secret=None,
|
|
code_verifier=None,
|
|
refresh_token="owner-refresh",
|
|
scope=requested_scope,
|
|
)
|
|
saved = db.decode_oauth_payload(table.upsert.call_args.kwargs["data"]["create"]["credential_b64"])
|
|
assert saved is not None and saved.get("scopes") == expected
|
|
assert upstream.call_count == 1
|
|
from urllib.parse import parse_qs
|
|
|
|
sent = parse_qs(upstream.calls.last.request.content.decode())
|
|
assert sent.get("scope") == ([requested_scope] if requested_scope else None)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("per_server_relay", [False, True])
|
|
async def test_managed_resource_metadata_excludes_refresh_permission(monkeypatch, per_server_relay):
|
|
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
|
|
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
|
|
|
|
server = _cimd_oauth_server().model_copy(
|
|
update={
|
|
"scopes": ["tools.read", "offline_access"],
|
|
"per_server_oauth_discovery": per_server_relay,
|
|
}
|
|
)
|
|
global_mcp_server_manager.registry[server.server_id] = server
|
|
result = await endpoints._build_oauth_protected_resource_response(_cimd_request(), server.server_id, True)
|
|
assert result["scopes_supported"] == ["tools.read"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"granted, required, expected",
|
|
[
|
|
(["tools.read"], ("tools.write",), "absent"),
|
|
(["tools.read", "tools.write"], ("tools.write",), "present"),
|
|
(None, ("tools.write",), "absent"),
|
|
(["tools.read"], (), "present"),
|
|
],
|
|
)
|
|
async def test_connect_requires_the_requested_vendor_grant(monkeypatch, granted, required, expected):
|
|
from litellm.proxy.mcp_server import db
|
|
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
|
|
|
|
credential = {**_stored_grant(), "scopes": granted}
|
|
read = AsyncMock(return_value=credential)
|
|
prisma = MagicMock()
|
|
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", prisma)
|
|
monkeypatch.setattr(db, "get_user_oauth_credential", read)
|
|
assert await endpoints._vendor_credential_state("owner", "server", required) == expected
|
|
read.assert_awaited_once_with(prisma, "owner", "server")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("header", "expected"),
|
|
[
|
|
("alpha", ("litellm:mcp_server:alpha",)),
|
|
("alpha, beta", ("litellm:mcp_server:alpha", "litellm:mcp_server:beta")),
|
|
("alpha,nope", ()),
|
|
("", ()),
|
|
(None, ()),
|
|
],
|
|
)
|
|
def test_aggregate_protected_resource_advertises_header_server_scopes(
|
|
header: str | None, expected: tuple[str, ...]
|
|
) -> None:
|
|
"""A client that re-authenticates from cached discovery never sees the 401 challenge, so the
|
|
aggregate PRM carries the x-mcp-servers selection as scopes_supported instead."""
|
|
from litellm.proxy.mcp_server import mcp_server_manager
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import (
|
|
_build_aggregate_protected_resource_response,
|
|
)
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
servers: Final = {
|
|
name: MCPServer(
|
|
server_id=f"{name}-id",
|
|
name=name,
|
|
server_name=name,
|
|
url=f"http://{name}",
|
|
transport="http",
|
|
auth_type=MCPAuth.none,
|
|
)
|
|
for name in ("alpha", "beta")
|
|
}
|
|
manager: Final = MagicMock()
|
|
manager.get_mcp_server_by_identifier.side_effect = lambda entry, client_ip=None: servers.get(entry)
|
|
headers: Final = [(b"host", b"localhost:4000")] + (
|
|
[(b"x-mcp-servers", header.encode())] if header is not None else []
|
|
)
|
|
request: Final = Request(
|
|
{
|
|
"type": "http",
|
|
"method": "GET",
|
|
"path": "/.well-known/oauth-protected-resource/mcp",
|
|
"headers": headers,
|
|
"scheme": "http",
|
|
"server": ("localhost", 4000),
|
|
"query_string": b"",
|
|
"root_path": "",
|
|
}
|
|
)
|
|
with patch.object(mcp_server_manager, "global_mcp_server_manager", manager):
|
|
assert _build_aggregate_protected_resource_response(request)["scopes_supported"] == expected
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_endpoint_routes_the_jwt_bearer_grant_to_the_aggregate_gateway():
|
|
"""A requesting app presents its identity provider's client_id, never a gateway DCR one, so
|
|
the aggregate dispatch keys on the grant type for RFC 7523 and hands the assertion to the
|
|
ID-JAG redeemer. A per-server token endpoint never serves that grant."""
|
|
from litellm.proxy.mcp_server import discoverable_endpoints
|
|
from litellm.proxy.mcp_server.gateway_dcr_flow import JWT_BEARER_GRANT_TYPE
|
|
from litellm.proxy.mcp_server.id_jag_grant import IdJagPrerequisites
|
|
|
|
request = _mock_callback_request("https://litellm.example.com/")
|
|
with patch("litellm.proxy.proxy_server.master_key", "sk-test-salt-for-lit9670"):
|
|
response = await discoverable_endpoints.token_endpoint(
|
|
request=request,
|
|
grant_type=JWT_BEARER_GRANT_TYPE,
|
|
client_id="0oa-requesting-app",
|
|
client_secret="app-secret",
|
|
assertion="eyJ.assertion.sig",
|
|
resource=None,
|
|
)
|
|
assert response.status_code == 400
|
|
refused = json.loads(bytes(response.body))
|
|
assert refused["error"] == "unsupported_grant_type"
|
|
unmet = IdJagPrerequisites(
|
|
jwt_auth_enabled=False,
|
|
has_database=False,
|
|
licensed=False,
|
|
maps_jwts_to_virtual_keys=False,
|
|
shared_replay_store=False,
|
|
base_url=None,
|
|
requesting_apps=(),
|
|
).refusal()
|
|
assert unmet is not None and refused["error_description"] == unmet.description
|
|
with pytest.raises(HTTPException):
|
|
await discoverable_endpoints.token_endpoint(
|
|
request=request,
|
|
grant_type=JWT_BEARER_GRANT_TYPE,
|
|
client_id="0oa-requesting-app",
|
|
assertion="eyJ.assertion.sig",
|
|
mcp_server_name="no-such-server",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_endpoint_takes_the_client_id_from_http_basic_when_the_form_leaves_it_out():
|
|
"""RFC 6749 section 2.3.1: a confidential app may authenticate with HTTP Basic alone, so the
|
|
aggregate dispatch reads client_id from that header. With neither the form field nor the
|
|
header the request is a 400 invalid_request, not a 422."""
|
|
from base64 import b64encode
|
|
|
|
from litellm.proxy.mcp_server import discoverable_endpoints
|
|
from litellm.proxy.mcp_server.gateway_dcr_flow import JWT_BEARER_GRANT_TYPE
|
|
|
|
request = _mock_callback_request("https://litellm.example.com/")
|
|
request.headers = {"authorization": "Basic " + b64encode(b"0oa-requesting-app:app-secret").decode()}
|
|
with patch("litellm.proxy.proxy_server.master_key", "sk-test-salt-for-lit9670"):
|
|
response = await discoverable_endpoints.token_endpoint(
|
|
request=request,
|
|
grant_type=JWT_BEARER_GRANT_TYPE,
|
|
client_id=None,
|
|
assertion="eyJ.assertion.sig",
|
|
resource=None,
|
|
)
|
|
assert response.status_code == 400
|
|
assert json.loads(bytes(response.body))["error"] == "unsupported_grant_type"
|
|
request.headers = {}
|
|
with pytest.raises(HTTPException) as refused:
|
|
await discoverable_endpoints.token_endpoint(
|
|
request=request, grant_type=JWT_BEARER_GRANT_TYPE, client_id=None, assertion="eyJ.assertion.sig"
|
|
)
|
|
assert refused.value.status_code == 400
|
|
assert refused.value.detail["error"] == "invalid_request"
|
|
|
|
|
|
@pytest.mark.parametrize("available", [True, False])
|
|
def test_root_metadata_is_the_gateways_own_and_lists_the_jwt_bearer_grant_when_it_is_served(available):
|
|
"""With no OAuth2 MCP server resolving at the root, the bare-origin document is the gateway's
|
|
own authorization server metadata: issuer is the origin an ID-JAG names as ``aud``, the token
|
|
endpoint is ``{base}/token``, and the jwt-bearer grant (with HTTP Basic client auth) is listed
|
|
exactly when the gateway serves it, under the same gates as the aggregate ``/mcp`` document."""
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server import discoverable_endpoints
|
|
from litellm.proxy.mcp_server.gateway_dcr_flow import JWT_BEARER_GRANT_TYPE
|
|
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
|
|
|
|
global_mcp_server_manager.registry.clear()
|
|
request = MagicMock(spec=Request)
|
|
request.base_url = "https://llm.example.com/"
|
|
request.headers = {}
|
|
with (
|
|
patch.object(discoverable_endpoints, "jwt_bearer_available", return_value=available), # test-quality-ok: deployment gate seam
|
|
patch.object(discoverable_endpoints, "token_exchange_available", return_value=True), # test-quality-ok: deployment gate seam
|
|
):
|
|
document = discoverable_endpoints._build_oauth_authorization_server_response(request=request, mcp_server_name=None)
|
|
assert document["issuer"] == "https://llm.example.com"
|
|
assert document["token_endpoint"] == "https://llm.example.com/token"
|
|
assert (JWT_BEARER_GRANT_TYPE in document["grant_types_supported"]) is available
|
|
assert ("client_secret_basic" in document["token_endpoint_auth_methods_supported"]) is available
|
|
assert ("none" in document["token_endpoint_auth_methods_supported"]) is available
|
|
assert "urn:ietf:params:oauth:grant-type:token-exchange" in document["grant_types_supported"]
|
|
|
|
|
|
def test_root_metadata_owned_by_a_resolved_oauth2_server_never_lists_the_gateways_grant():
|
|
"""When exactly one OAuth2 MCP server resolves at the root, the root document describes that
|
|
upstream's token endpoint, which cannot redeem an ID-JAG, so the jwt-bearer grant is not
|
|
advertised there even though the gateway serves it; a requesting app uses the aggregate
|
|
``/.well-known/oauth-authorization-server/mcp`` document in that deployment."""
|
|
from fastapi import Request
|
|
|
|
from litellm.proxy.mcp_server import discoverable_endpoints
|
|
from litellm.proxy.mcp_server.gateway_dcr_flow import JWT_BEARER_GRANT_TYPE
|
|
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
|
|
|
|
server = _create_oauth2_server()
|
|
global_mcp_server_manager.registry.clear()
|
|
global_mcp_server_manager.registry[server.server_id] = server
|
|
request = MagicMock(spec=Request)
|
|
request.base_url = "https://llm.example.com/"
|
|
request.headers = {}
|
|
try:
|
|
with (
|
|
patch.object(discoverable_endpoints, "jwt_bearer_available", return_value=True), # test-quality-ok: deployment gate seam
|
|
patch.object(discoverable_endpoints, "token_exchange_available", return_value=True), # test-quality-ok: deployment gate seam
|
|
patch.object(global_mcp_server_manager, "get_mcp_server_by_name", return_value=server), # test-quality-ok: resolver seam
|
|
):
|
|
document = discoverable_endpoints._build_oauth_authorization_server_response(request=request, mcp_server_name=None)
|
|
finally:
|
|
global_mcp_server_manager.registry.clear()
|
|
assert document["token_endpoint"] == "https://llm.example.com/test_oauth/token"
|
|
assert JWT_BEARER_GRANT_TYPE not in document["grant_types_supported"]
|
|
assert document["grant_types_supported"] == ["authorization_code", "refresh_token"]
|
|
assert document["token_endpoint_auth_methods_supported"] == ["client_secret_post"]
|