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>
1879 lines
81 KiB
Python
1879 lines
81 KiB
Python
"""
|
|
Tests for the encrypted-at-rest persistence of MCP user credentials.
|
|
|
|
The ``LiteLLM_MCPUserCredentials.credential_b64`` column previously stored
|
|
both BYOK API keys and OAuth2 access tokens as plain ``urlsafe_b64encode``
|
|
of the raw value, leaving credentials readable from any DB read. The fix
|
|
runs every write through ``encrypt_value_helper`` (nacl SecretBox) and
|
|
keeps a plain-base64 fallback on read so existing rows continue to work.
|
|
"""
|
|
|
|
import base64
|
|
import json
|
|
from datetime import datetime, timedelta, timezone
|
|
from types import SimpleNamespace
|
|
from typing import Final
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
from prisma.models import LiteLLM_MCPServerTable as PrismaMCPServer
|
|
|
|
from litellm.proxy._types import LiteLLM_MCPServerTable, NewMCPServerRequest, UpdateMCPServerRequest
|
|
from litellm.proxy.common_utils.encrypt_decrypt_utils import (
|
|
SecretMapDecodeError,
|
|
decode_secret_map,
|
|
decrypt_value_helper,
|
|
encrypt_secret_map,
|
|
encrypt_value_helper,
|
|
)
|
|
from litellm.proxy.mcp_server.db import (
|
|
_decode_user_credential,
|
|
_prepare_mcp_server_data,
|
|
create_mcp_server,
|
|
decrypt_credentials,
|
|
encrypt_credentials,
|
|
get_all_mcp_servers,
|
|
get_mcp_servers,
|
|
get_mcp_submissions,
|
|
get_user_credential,
|
|
get_user_oauth_credential,
|
|
is_oauth_credential_expired,
|
|
list_user_oauth_credentials,
|
|
resolve_valid_user_oauth_token,
|
|
rotate_mcp_server_credentials_master_key,
|
|
rotate_mcp_user_credentials_master_key,
|
|
rotate_mcp_user_env_vars_master_key,
|
|
store_user_credential,
|
|
store_user_oauth_credential,
|
|
update_mcp_server,
|
|
)
|
|
from litellm.types.mcp import MCPAuth, MCPTransport
|
|
|
|
SALT_KEY = "test-salt-key-for-byok-credential-tests-1234"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _set_salt_key(monkeypatch):
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", SALT_KEY)
|
|
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {"encryption_algorithm": "xsalsa20-poly1305"})
|
|
|
|
|
|
def _make_prisma_with_existing(row):
|
|
"""Build a MagicMock prisma_client whose user-credentials table returns ``row``
|
|
for find_unique and behaves async-correctly for upsert/find_many."""
|
|
prisma = MagicMock()
|
|
prisma.db.litellm_mcpusercredentials.find_unique = AsyncMock(return_value=row)
|
|
prisma.db.litellm_mcpusercredentials.upsert = AsyncMock()
|
|
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[])
|
|
return prisma
|
|
|
|
|
|
def _legacy_row(payload: str):
|
|
"""A row exactly as the pre-fix code would have written it: plain
|
|
``urlsafe_b64encode`` of the raw payload, no encryption."""
|
|
row = MagicMock()
|
|
row.credential_b64 = base64.urlsafe_b64encode(payload.encode()).decode()
|
|
row.user_id = "alice"
|
|
row.server_id = "srv-1"
|
|
return row
|
|
|
|
|
|
def _identity_server(**overrides):
|
|
base = dict(
|
|
url="https://up.example.com/mcp",
|
|
auth_type="oauth2",
|
|
oauth2_flow="authorization_code",
|
|
authorization_url="https://idp.example.com/authorize",
|
|
token_url="https://idp.example.com/token",
|
|
registration_url="https://idp.example.com/register",
|
|
credentials={"client_id": "cid", "client_secret": "csec", "scopes": ["a"]},
|
|
server_name="srv",
|
|
description="d",
|
|
)
|
|
base.update(overrides)
|
|
return SimpleNamespace(**base)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"overrides",
|
|
[
|
|
{"url": "https://other.example.com/mcp"},
|
|
{"spec_path": "https://up.example.com/openapi.json"},
|
|
{"auth_type": "oauth_delegate"},
|
|
{"oauth2_flow": "client_credentials"},
|
|
{"authorization_url": "https://other.example.com/authorize"},
|
|
{"token_url": "https://other.example.com/token"},
|
|
{"registration_url": "https://other.example.com/register"},
|
|
{"credentials": {"client_id": "new", "client_secret": "csec", "scopes": ["a"]}},
|
|
{"credentials": {"client_id": "cid", "client_secret": "rotated", "scopes": ["a"]}},
|
|
{"credentials": {"client_id": "cid", "client_secret": "csec", "scopes": ["b"]}},
|
|
# RFC 8707: upstream_resource is the audience the token is minted for, so changing it
|
|
# alone strands every stored per-user token on the previous audience.
|
|
{"credentials": {"client_id": "cid", "client_secret": "csec", "scopes": ["a"], "upstream_resource": "auto"}},
|
|
{
|
|
"credentials": {
|
|
"client_id": "cid",
|
|
"client_secret": "csec",
|
|
"scopes": ["a"],
|
|
"upstream_resource": "api://new-audience",
|
|
}
|
|
},
|
|
],
|
|
)
|
|
def test_mcp_oauth_token_identity_changes_on_mint_relevant_fields(overrides):
|
|
from litellm.proxy.mcp_server.db import mcp_oauth_token_identity
|
|
|
|
assert mcp_oauth_token_identity(_identity_server()) != mcp_oauth_token_identity(_identity_server(**overrides))
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"overrides",
|
|
[
|
|
{"server_name": "renamed"},
|
|
{"description": "changed"},
|
|
],
|
|
)
|
|
def test_mcp_oauth_token_identity_stable_on_non_mint_fields(overrides):
|
|
from litellm.proxy.mcp_server.db import mcp_oauth_token_identity
|
|
|
|
assert mcp_oauth_token_identity(_identity_server()) == mcp_oauth_token_identity(_identity_server(**overrides))
|
|
|
|
|
|
def _encrypted_creds_json(client_id: str = "cid", client_secret: str = "csec") -> str:
|
|
from litellm.proxy.mcp_server.db import encrypt_credentials
|
|
|
|
encrypted = encrypt_credentials(
|
|
credentials={"client_id": client_id, "client_secret": client_secret, "scopes": ["a"]},
|
|
encryption_key=None,
|
|
)
|
|
return json.dumps(encrypted)
|
|
|
|
|
|
def test_mcp_oauth_token_identity_stable_across_reencryption():
|
|
"""Stored client_id/client_secret are NaCl-encrypted with a fresh nonce on every write, so two
|
|
saves of the SAME plaintext produce different ciphertext. The identity must compare decrypted
|
|
values; comparing ciphertext would flag every routine save as a mint-relevant change and purge
|
|
per-user tokens that are still valid."""
|
|
from litellm.proxy.mcp_server.db import mcp_oauth_token_identity
|
|
|
|
first = _encrypted_creds_json()
|
|
second = _encrypted_creds_json()
|
|
assert first != second
|
|
|
|
assert mcp_oauth_token_identity(_identity_server(credentials=first)) == mcp_oauth_token_identity(
|
|
_identity_server(credentials=second)
|
|
)
|
|
|
|
|
|
def test_mcp_oauth_token_identity_detects_change_under_encryption():
|
|
from litellm.proxy.mcp_server.db import mcp_oauth_token_identity
|
|
|
|
unchanged = _identity_server(credentials=_encrypted_creds_json())
|
|
changed = _identity_server(credentials=_encrypted_creds_json(client_id="other"))
|
|
assert mcp_oauth_token_identity(unchanged) != mcp_oauth_token_identity(changed)
|
|
|
|
|
|
def test_mcp_oauth_token_identity_reads_client_and_scopes_from_json_string_credentials():
|
|
from litellm.proxy.mcp_server.db import mcp_oauth_token_identity
|
|
|
|
credentials: Final = json.dumps(
|
|
{"client_id": "cid", "client_secret": "csec", "scopes": ["a"], "upstream_resource": "api://audience"}
|
|
)
|
|
|
|
assert mcp_oauth_token_identity(_identity_server(credentials=credentials)) == (
|
|
"https://up.example.com/mcp",
|
|
None,
|
|
"oauth2",
|
|
"authorization_code",
|
|
None,
|
|
"https://idp.example.com/authorize",
|
|
"https://idp.example.com/token",
|
|
"https://idp.example.com/register",
|
|
"cid",
|
|
"csec",
|
|
["a"],
|
|
"api://audience",
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("credentials", ["[]", '["client_id"]', '"cid"', "5", "null", "not json"])
|
|
def test_mcp_oauth_token_identity_treats_stored_credentials_that_are_not_a_json_object_as_empty(credentials):
|
|
from litellm.proxy.mcp_server.db import mcp_oauth_token_identity
|
|
|
|
assert mcp_oauth_token_identity(_identity_server(credentials=credentials)) == (
|
|
"https://up.example.com/mcp",
|
|
None,
|
|
"oauth2",
|
|
"authorization_code",
|
|
None,
|
|
"https://idp.example.com/authorize",
|
|
"https://idp.example.com/token",
|
|
"https://idp.example.com/register",
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
|
|
|
|
def _oauth_row(user_id: str, server_id: str = "srv-1"):
|
|
"""A stored per-user OAuth token row (payload tagged type=oauth2, legacy plain-base64 encoding)."""
|
|
row = _legacy_row(json.dumps({"type": "oauth2", "access_token": "tok-" + user_id}))
|
|
row.user_id = user_id
|
|
row.server_id = server_id
|
|
return row
|
|
|
|
|
|
def _byok_row(user_id: str, server_id: str = "srv-1"):
|
|
"""A stored BYOK API key row: the same column, but the payload is a plain string, not OAuth JSON."""
|
|
row = _legacy_row("sk-byok-" + user_id)
|
|
row.user_id = user_id
|
|
row.server_id = server_id
|
|
return row
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_purge_user_oauth_credentials_for_server_invalidates_each_user():
|
|
"""The purge must route each (user, server) row through the invalidator exactly once."""
|
|
from litellm.proxy.mcp_server.db import purge_user_oauth_credentials_for_server
|
|
|
|
prisma = MagicMock()
|
|
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[_oauth_row("alice"), _oauth_row("bob")])
|
|
prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock(return_value=2)
|
|
|
|
invalidations = []
|
|
|
|
async def record_invalidation(user_id: str, server_id: str) -> None:
|
|
invalidations.append((user_id, server_id))
|
|
|
|
purged = await purge_user_oauth_credentials_for_server(prisma, "srv-1", invalidate_token_cache=record_invalidation)
|
|
|
|
assert purged == 2
|
|
prisma.db.litellm_mcpusercredentials.delete_many.assert_awaited_once_with(
|
|
where={"server_id": "srv-1", "user_id": {"in": ["alice", "bob"]}}
|
|
)
|
|
assert set(invalidations) == {("alice", "srv-1"), ("bob", "srv-1")}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_server_user_credentials_types_each_row_without_leaking_the_secret():
|
|
"""The admin view of one server's stored credentials names the user and the kind of
|
|
credential (OAuth2 vs BYOK) and echoes OAuth expiry, but never the token or key itself."""
|
|
from litellm.proxy.mcp_server.db import list_server_user_credentials
|
|
|
|
oauth_row = _legacy_row(
|
|
json.dumps(
|
|
{
|
|
"type": "oauth2",
|
|
"access_token": "tok-alice",
|
|
"expires_at": "2026-12-31T00:00:00+00:00",
|
|
"connected_at": "2026-01-01T00:00:00+00:00",
|
|
}
|
|
)
|
|
)
|
|
oauth_row.user_id = "alice"
|
|
oauth_row.updated_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
|
byok_row = _byok_row("carol")
|
|
byok_row.updated_at = datetime(2026, 2, 1, tzinfo=timezone.utc)
|
|
prisma = MagicMock()
|
|
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[oauth_row, byok_row])
|
|
|
|
items = await list_server_user_credentials(prisma, "srv-1")
|
|
|
|
prisma.db.litellm_mcpusercredentials.find_many.assert_awaited_once_with(where={"server_id": "srv-1"})
|
|
assert [item.model_dump() for item in items] == [
|
|
{
|
|
"user_id": "alice",
|
|
"credential_type": "oauth2",
|
|
"expires_at": "2026-12-31T00:00:00+00:00",
|
|
"connected_at": "2026-01-01T00:00:00+00:00",
|
|
"updated_at": "2026-01-01T00:00:00+00:00",
|
|
},
|
|
{
|
|
"user_id": "carol",
|
|
"credential_type": "byok",
|
|
"expires_at": None,
|
|
"connected_at": None,
|
|
"updated_at": "2026-02-01T00:00:00+00:00",
|
|
},
|
|
]
|
|
serialized = "".join(item.model_dump_json() for item in items)
|
|
assert "tok-alice" not in serialized
|
|
assert "sk-byok-carol" not in serialized
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_purge_user_oauth_credentials_for_server_spares_byok_rows():
|
|
"""Regression: the purge used to delete_many on server_id alone, wiping BYOK API keys that share
|
|
the LiteLLM_MCPUserCredentials table. Only rows holding an OAuth2 payload may be deleted (one
|
|
batched query filtered to their user_ids), and only their users' token caches invalidated."""
|
|
from litellm.proxy.mcp_server.db import purge_user_oauth_credentials_for_server
|
|
|
|
prisma = MagicMock()
|
|
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[_byok_row("carol"), _oauth_row("alice")])
|
|
prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock(return_value=1)
|
|
|
|
invalidations = []
|
|
|
|
async def record_invalidation(user_id: str, server_id: str) -> None:
|
|
invalidations.append((user_id, server_id))
|
|
|
|
purged = await purge_user_oauth_credentials_for_server(prisma, "srv-1", invalidate_token_cache=record_invalidation)
|
|
|
|
assert purged == 1
|
|
prisma.db.litellm_mcpusercredentials.delete_many.assert_awaited_once_with(
|
|
where={"server_id": "srv-1", "user_id": {"in": ["alice"]}}
|
|
)
|
|
assert invalidations == [("alice", "srv-1")]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_purge_user_oauth_credentials_for_server_all_byok_is_noop():
|
|
"""An api_key (BYOK-only) server whose identity tuple changes (e.g. its url) must purge nothing."""
|
|
from litellm.proxy.mcp_server.db import purge_user_oauth_credentials_for_server
|
|
|
|
prisma = MagicMock()
|
|
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[_byok_row("carol"), _byok_row("dave")])
|
|
prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock()
|
|
|
|
purged = await purge_user_oauth_credentials_for_server(prisma, "srv-1")
|
|
|
|
assert purged == 0
|
|
prisma.db.litellm_mcpusercredentials.delete_many.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_purge_user_oauth_credentials_for_server_defaults_to_manager_invalidator(monkeypatch):
|
|
"""When no invalidator is injected, the purge must resolve to the manager's shared
|
|
invalidate_user_oauth_token_cache, the single point covering both the legacy per-user token cache
|
|
and the v2 per-user OAuth token store; a wrong or no-op default silently leaves every cache
|
|
serving tokens minted for the superseded config."""
|
|
from litellm.proxy.mcp_server import mcp_server_manager
|
|
from litellm.proxy.mcp_server.db import purge_user_oauth_credentials_for_server
|
|
|
|
prisma = MagicMock()
|
|
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[_oauth_row("alice")])
|
|
prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock(return_value=1)
|
|
|
|
shared_invalidator = AsyncMock()
|
|
monkeypatch.setattr(
|
|
mcp_server_manager.global_mcp_server_manager,
|
|
"invalidate_user_oauth_token_cache",
|
|
shared_invalidator,
|
|
)
|
|
|
|
purged = await purge_user_oauth_credentials_for_server(prisma, "srv-1")
|
|
|
|
assert purged == 1
|
|
shared_invalidator.assert_awaited_once_with("alice", "srv-1")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_purge_user_oauth_credentials_for_server_logs_raced_rows(monkeypatch):
|
|
from litellm.proxy.mcp_server import db as db_module
|
|
from litellm.proxy.mcp_server.db import purge_user_oauth_credentials_for_server
|
|
|
|
prisma = MagicMock()
|
|
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[_oauth_row("alice")])
|
|
prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock(return_value=0)
|
|
warning = MagicMock()
|
|
monkeypatch.setattr(db_module.verbose_proxy_logger, "warning", warning)
|
|
|
|
purged = await purge_user_oauth_credentials_for_server(prisma, "srv-1", invalidate_token_cache=AsyncMock())
|
|
|
|
assert purged == 0
|
|
warning.assert_called_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_mcp_server_invalidates_cached_tokens_for_enumerated_users():
|
|
"""Deleting a server must invalidate each enumerated user's cached per-user token: the caches are
|
|
keyed by (user_id, server_id), so a re-created server reusing the same server_id would otherwise
|
|
serve tokens minted for the deleted server until TTL."""
|
|
from litellm.proxy.mcp_server.db import delete_mcp_server
|
|
|
|
prisma = MagicMock()
|
|
prisma.db.litellm_mcpservertable.delete = AsyncMock(return_value=MagicMock(server_id="srv-1"))
|
|
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[_oauth_row("alice"), _byok_row("bob")])
|
|
prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock(return_value=2)
|
|
prisma.db.litellm_mcpuserenvvars.delete_many = AsyncMock(return_value=0)
|
|
|
|
invalidations = []
|
|
|
|
async def record_invalidation(user_id: str, server_id: str) -> None:
|
|
invalidations.append((user_id, server_id))
|
|
|
|
deleted = await delete_mcp_server(prisma, "srv-1", invalidate_token_cache=record_invalidation)
|
|
|
|
assert deleted is not None
|
|
assert set(invalidations) == {("alice", "srv-1"), ("bob", "srv-1")}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_mcp_server_returns_none_without_cleanup_when_server_missing():
|
|
from litellm.proxy.mcp_server.db import delete_mcp_server
|
|
|
|
prisma = MagicMock()
|
|
prisma.db.litellm_mcpservertable.delete = AsyncMock(return_value=None)
|
|
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock()
|
|
|
|
deleted = await delete_mcp_server(prisma, "srv-1", invalidate_token_cache=AsyncMock())
|
|
|
|
assert deleted is None
|
|
prisma.db.litellm_mcpusercredentials.find_many.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_purge_user_oauth_credentials_for_server_noop_when_empty():
|
|
from litellm.proxy.mcp_server.db import purge_user_oauth_credentials_for_server
|
|
|
|
prisma = MagicMock()
|
|
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[])
|
|
prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock()
|
|
|
|
purged = await purge_user_oauth_credentials_for_server(prisma, "srv-1")
|
|
|
|
assert purged == 0
|
|
prisma.db.litellm_mcpusercredentials.delete_many.assert_not_awaited()
|
|
|
|
|
|
def _stored_value(prisma) -> str:
|
|
"""Pull the credential_b64 value passed to the most recent upsert call."""
|
|
call = prisma.db.litellm_mcpusercredentials.upsert.call_args
|
|
data = call.kwargs["data"]
|
|
create_value = data["create"]["credential_b64"]
|
|
update_value = data["update"]["credential_b64"]
|
|
assert create_value == update_value, "create/update must agree"
|
|
return create_value
|
|
|
|
|
|
# ── MCP server credentials at rest ──────────────────────────────────────────────
|
|
|
|
|
|
def test_client_private_key_encrypted_at_rest():
|
|
"""An ID-JAG client_private_key is a secret and must be encrypted in the stored
|
|
credentials blob, never persisted in plaintext, and must round-trip back. The
|
|
pre-fix code left client_private_key out of encrypt_credentials, so it was stored
|
|
verbatim."""
|
|
private_key = (
|
|
"-----BEGIN PRIVATE KEY-----\nsensitive-rsa-material\n-----END PRIVATE KEY-----"
|
|
)
|
|
resource_client_secret: Final = "resource-client-secret"
|
|
credentials = {
|
|
"client_secret": "shh",
|
|
"client_private_key": private_key,
|
|
"id_jag_resource_client_secret": resource_client_secret,
|
|
}
|
|
|
|
encrypted = encrypt_credentials(dict(credentials), encryption_key=None)
|
|
assert encrypted["client_private_key"] != private_key
|
|
assert private_key not in encrypted["client_private_key"]
|
|
assert encrypted["client_secret"] != "shh"
|
|
assert encrypted["id_jag_resource_client_secret"] != resource_client_secret
|
|
assert resource_client_secret not in encrypted["id_jag_resource_client_secret"]
|
|
|
|
decrypted = decrypt_credentials(dict(encrypted))
|
|
assert decrypted["client_private_key"] == private_key
|
|
assert decrypted["client_secret"] == "shh"
|
|
assert decrypted["id_jag_resource_client_secret"] == resource_client_secret
|
|
|
|
|
|
@pytest.fixture(params=["xsalsa20-poly1305", "aes-256-gcm"])
|
|
def map_algorithm(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> str:
|
|
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {"encryption_algorithm": request.param})
|
|
return request.param
|
|
|
|
|
|
def _prisma_map_row(data: dict[str, object], quoted: bool = False) -> PrismaMCPServer:
|
|
return PrismaMCPServer.model_validate({
|
|
"transport": "http", "mcp_access_groups": [], "allowed_tools": [], "extra_headers": [], "args": [],
|
|
"allow_all_keys": False, "available_on_public_internet": True, "delegate_auth_to_upstream": False,
|
|
"oauth_passthrough": False, "per_server_oauth_discovery": False, "is_byok": False, "byok_description": [],
|
|
**data,
|
|
**{field: json.dumps(data[field]) for field in ("static_headers", "env") if quoted and data.get(field)},
|
|
})
|
|
|
|
|
|
class _MapTable:
|
|
def __init__(self, *rows: dict[str, object], quoted: bool = False) -> None:
|
|
self.rows = {row["server_id"]: row for row in rows}
|
|
self.quoted = quoted
|
|
|
|
async def create(self, *, data: dict[str, object]) -> PrismaMCPServer:
|
|
self.rows = {**self.rows, data["server_id"]: dict(data)}
|
|
return _prisma_map_row(data, self.quoted)
|
|
|
|
async def update(self, *, where: dict[str, str], data: dict[str, object]) -> PrismaMCPServer:
|
|
return await self.create(data={**self.rows[where["server_id"]], **data})
|
|
|
|
async def find_many(self, where: object = None) -> list[PrismaMCPServer]:
|
|
return [_prisma_map_row(row, self.quoted) for row in self.rows.values()]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("field", ["static_headers", "env"])
|
|
@pytest.mark.parametrize("quoted", [False, True])
|
|
async def test_secret_maps_create_update_round_trip(map_algorithm: str, field: str, quoted: bool) -> None:
|
|
table: Final = _MapTable(quoted=quoted)
|
|
prisma: Final = SimpleNamespace(db=SimpleNamespace(litellm_mcpservertable=table, litellm_config=SimpleNamespace(find_unique=AsyncMock(return_value=None))))
|
|
original: Final = {"TOKEN": " sensitive-secret\n", "PREFIX": "v2:gcm:literal", "TEMPLATE": "Bearer ${TOKEN}"}
|
|
create: Final = NewMCPServerRequest.model_validate({
|
|
"server_id": "srv-map", "transport": "http", "url": "https://up.example.com/mcp", field: original,
|
|
})
|
|
created: Final = await create_mcp_server(prisma, create, touched_by="test")
|
|
first: Final = table.rows["srv-map"][field]
|
|
assert isinstance(first, str) and isinstance(json.loads(first), str)
|
|
assert json.loads(first).startswith("v2:gcm:") is (map_algorithm == "aes-256-gcm")
|
|
assert "sensitive-secret" not in first and "TEMPLATE" not in first
|
|
assert getattr(created, field) == original == getattr(create, field)
|
|
assert decode_secret_map(first, key=field) == original
|
|
replacement: Final = {**original, "TOKEN": "updated-sensitive-secret"}
|
|
update: Final = UpdateMCPServerRequest.model_validate({"server_id": "srv-map", field: replacement})
|
|
updated: Final = await update_mcp_server(prisma, update, touched_by="test")
|
|
second: Final = table.rows["srv-map"][field]
|
|
assert second != first and "updated-sensitive-secret" not in second
|
|
assert decode_secret_map(second, key=field) == replacement
|
|
assert getattr(updated, field) == replacement == getattr(update, field)
|
|
assert original["TOKEN"] == " sensitive-secret\n"
|
|
omitted: Final = await update_mcp_server(prisma, UpdateMCPServerRequest(server_id="srv-map"), touched_by="test")
|
|
assert table.rows["srv-map"][field] == second and getattr(omitted, field) == replacement
|
|
cleared: Final = await update_mcp_server(
|
|
prisma, UpdateMCPServerRequest.model_validate({"server_id": "srv-map", field: {}}), touched_by="test"
|
|
)
|
|
assert table.rows["srv-map"][field] == "{}" and getattr(cleared, field) == {}
|
|
|
|
|
|
@pytest.mark.parametrize("field", ["static_headers", "env"])
|
|
@pytest.mark.parametrize("as_json", [False, True])
|
|
def test_secret_map_legacy_model_read_preserves_exact_values(field: str, as_json: bool) -> None:
|
|
original: Final = {"PREFIX": "v2:gcm:literal", "SPACE": " secret\n", "TEMPLATE": "${TOKEN}", "B64": "YWJjZA=="}
|
|
incoming: Final = {
|
|
"server_id": "srv-map", "transport": "http", field: json.dumps(original) if as_json else original,
|
|
}
|
|
snapshot: Final = json.dumps(incoming)
|
|
parsed: Final = LiteLLM_MCPServerTable.model_validate(incoming)
|
|
assert getattr(parsed, field) == original
|
|
assert json.dumps(incoming) == snapshot
|
|
assert LiteLLM_MCPServerTable.model_validate(parsed.model_dump()).model_dump() == parsed.model_dump()
|
|
empty: Final = LiteLLM_MCPServerTable.model_validate({"server_id": "srv-map", "transport": "http", field: None})
|
|
assert getattr(empty, field) == ({} if field == "env" else None)
|
|
|
|
|
|
@pytest.mark.parametrize("field", ["static_headers", "env"])
|
|
@pytest.mark.parametrize("failure", ["wrong-key", "corrupt", "invalid-values", "invalid-shape", "invalid-json"])
|
|
def test_secret_map_model_read_fails_closed(map_algorithm: str, field: str, failure: str) -> None:
|
|
plaintext: Final = {"invalid-values": '{"TOKEN": ["sensitive-secret"]}', "invalid-shape": '["sensitive-secret"]',
|
|
"invalid-json": "sensitive-secret"}.get(failure, '{"TOKEN": "sensitive-secret"}')
|
|
ciphertext: Final = encrypt_value_helper(
|
|
plaintext, new_encryption_key="wrong-map-key" if failure == "wrong-key" else None
|
|
)
|
|
stored: Final = json.dumps(ciphertext[:-8] if failure == "corrupt" else ciphertext)
|
|
with pytest.raises(SecretMapDecodeError) as exc:
|
|
LiteLLM_MCPServerTable.model_validate({"server_id": "srv-map", "transport": "http", field: stored})
|
|
assert field in str(exc.value) and "LITELLM_SALT_KEY" in str(exc.value)
|
|
assert all(secret not in str(exc.value) for secret in (plaintext, ciphertext, "sensitive-secret"))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("field,other", [("static_headers", "env"), ("env", "static_headers")])
|
|
async def test_secret_map_rotation_migrates_rekeys_and_preserves_corrupt(
|
|
map_algorithm: str, field: str, other: str, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
values: Final = {"TOKEN": "rotation-sensitive-secret", "TEMPLATE": "Bearer ${TOKEN}"}
|
|
old: Final = encrypt_secret_map(values)
|
|
corrupt: Final = json.dumps(json.loads(old)[:-8])
|
|
table: Final = _MapTable(
|
|
{"server_id": "broken", field: corrupt, other: old},
|
|
{"server_id": "legacy", field: json.dumps(values), other: "{}"},
|
|
{"server_id": "encrypted", field: old, other: None},
|
|
)
|
|
prisma: Final = SimpleNamespace(db=SimpleNamespace(
|
|
litellm_mcpservertable=table, litellm_mcpserveroauthclient=SimpleNamespace(find_many=AsyncMock(return_value=[])), litellm_config=SimpleNamespace(find_unique=AsyncMock(return_value=None))
|
|
))
|
|
await rotate_mcp_server_credentials_master_key(prisma, touched_by="test", new_master_key="rotated-map-key")
|
|
assert table.rows["broken"][field] == corrupt
|
|
assert table.rows["legacy"][other] == "{}" and table.rows["encrypted"][other] is None
|
|
for server_id, map_field in (("broken", other), ("legacy", field), ("encrypted", field)):
|
|
stored: Final = table.rows[server_id][map_field]
|
|
assert isinstance(json.loads(stored), str) and stored != old and "rotation-sensitive-secret" not in stored
|
|
with pytest.raises(SecretMapDecodeError):
|
|
decode_secret_map(stored, key=map_field)
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", "rotated-map-key")
|
|
for server_id, map_field in (("broken", other), ("legacy", field), ("encrypted", field)):
|
|
assert decode_secret_map(table.rows[server_id][map_field], key=map_field) == values
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("reader", [get_all_mcp_servers, get_mcp_servers, get_mcp_submissions])
|
|
@pytest.mark.parametrize("field", ["static_headers", "env"])
|
|
async def test_bulk_reads_isolate_corrupt_secret_maps(reader, field, map_algorithm, caplog):
|
|
secret = {"TOKEN": "bulk-sensitive-secret"}
|
|
encrypted = encrypt_secret_map(secret)
|
|
corrupt = encrypt_secret_map(secret, new_encryption_key="wrong-bulk-key")
|
|
rows = [
|
|
_prisma_map_row({"server_id": "broken", field: corrupt, "approval_status": "pending_review"}),
|
|
_prisma_map_row({"server_id": "healthy", field: encrypted, "approval_status": "active"}),
|
|
]
|
|
snapshot = [row.model_dump() for row in rows]
|
|
table = SimpleNamespace(find_many=AsyncMock(return_value=rows))
|
|
prisma = SimpleNamespace(db=SimpleNamespace(litellm_mcpservertable=table, litellm_config=SimpleNamespace(find_unique=AsyncMock(return_value=None))))
|
|
result = await reader(prisma, ["broken", "healthy"]) if reader is get_mcp_servers else await reader(prisma)
|
|
items = result.items if reader is get_mcp_submissions else result
|
|
assert [row.server_id for row in items] == ["healthy"]
|
|
assert getattr(items[0], field) == secret
|
|
assert [row.model_dump() for row in rows] == snapshot
|
|
assert "broken" in caplog.text
|
|
assert all(value not in caplog.text for value in ("bulk-sensitive-secret", corrupt, encrypted))
|
|
if reader is get_mcp_submissions:
|
|
assert (result.total, result.pending_review, result.active, result.rejected) == (1, 0, 1, 0)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("reader", [get_all_mcp_servers, get_mcp_servers, get_mcp_submissions])
|
|
async def test_bulk_reads_do_not_swallow_unrelated_validation_errors(reader):
|
|
from pydantic import ValidationError
|
|
|
|
row = _prisma_map_row({"server_id": "invalid", "transport": "unsupported"})
|
|
table = SimpleNamespace(find_many=AsyncMock(return_value=[row]))
|
|
prisma = SimpleNamespace(db=SimpleNamespace(litellm_mcpservertable=table, litellm_config=SimpleNamespace(find_unique=AsyncMock(return_value=None))))
|
|
request = reader(prisma, ["invalid"]) if reader is get_mcp_servers else reader(prisma)
|
|
with pytest.raises(ValidationError, match="transport"):
|
|
await request
|
|
|
|
|
|
# ── BYOK round-trip ───────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_store_user_credential_does_not_persist_plaintext():
|
|
# Stored bytes must not just be base64 of the secret — that's the regression.
|
|
secret = "sk-proj-very-secret-byok-key"
|
|
prisma = _make_prisma_with_existing(row=None)
|
|
|
|
await store_user_credential(prisma, "alice", "srv-1", secret)
|
|
|
|
stored = _stored_value(prisma)
|
|
plain_b64 = base64.urlsafe_b64encode(secret.encode()).decode()
|
|
assert stored != plain_b64
|
|
# And the secret must not appear anywhere in a plain-b64 decode of the column.
|
|
try:
|
|
decoded_bytes = base64.urlsafe_b64decode(stored)
|
|
except Exception:
|
|
decoded_bytes = b""
|
|
assert secret.encode() not in decoded_bytes
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_byok_round_trip_returns_plaintext():
|
|
secret = "sk-proj-very-secret-byok-key"
|
|
prisma = _make_prisma_with_existing(row=None)
|
|
await store_user_credential(prisma, "alice", "srv-1", secret)
|
|
|
|
stored = _stored_value(prisma)
|
|
row = MagicMock()
|
|
row.credential_b64 = stored
|
|
prisma.db.litellm_mcpusercredentials.find_unique = AsyncMock(return_value=row)
|
|
|
|
result = await get_user_credential(prisma, "alice", "srv-1")
|
|
assert result == secret
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_byok_get_returns_plaintext_for_legacy_row():
|
|
# Backward-compat: rows persisted by the pre-fix code (plain base64) must
|
|
# still decrypt-or-decode cleanly.
|
|
legacy_secret = "legacy-byok-key"
|
|
prisma = _make_prisma_with_existing(row=_legacy_row(legacy_secret))
|
|
|
|
result = await get_user_credential(prisma, "alice", "srv-1")
|
|
assert result == legacy_secret
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_byok_get_returns_none_for_missing_row():
|
|
prisma = _make_prisma_with_existing(row=None)
|
|
result = await get_user_credential(prisma, "alice", "srv-1")
|
|
assert result is None
|
|
|
|
|
|
# ── OAuth2 round-trip ─────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_store_user_oauth_credential_does_not_persist_plaintext():
|
|
access_token = "ya29.a0AfH6SMBverysecretaccesstoken"
|
|
prisma = _make_prisma_with_existing(row=None)
|
|
|
|
await store_user_oauth_credential(prisma, "alice", "srv-1", access_token, refresh_token="rfr-xyz")
|
|
|
|
stored = _stored_value(prisma)
|
|
try:
|
|
decoded_bytes = base64.urlsafe_b64decode(stored)
|
|
except Exception:
|
|
decoded_bytes = b""
|
|
assert access_token.encode() not in decoded_bytes
|
|
assert b"rfr-xyz" not in decoded_bytes
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("cimd_client_id", [None, "https://gateway.example.com/oauth/client-metadata.json"])
|
|
async def test_oauth_round_trip_returns_payload(cimd_client_id):
|
|
access_token = "ya29.a0AfH6SMBverysecretaccesstoken"
|
|
prisma = _make_prisma_with_existing(row=None)
|
|
await store_user_oauth_credential(
|
|
prisma,
|
|
"alice",
|
|
"srv-1",
|
|
access_token,
|
|
refresh_token="rfr-xyz",
|
|
scopes=["a", "b"],
|
|
identity_binding_proof="verified-proof",
|
|
cimd_client_id=cimd_client_id,
|
|
)
|
|
|
|
stored = _stored_value(prisma)
|
|
row = MagicMock()
|
|
row.credential_b64 = stored
|
|
row.server_id = "srv-1"
|
|
prisma.db.litellm_mcpusercredentials.find_unique = AsyncMock(return_value=row)
|
|
|
|
result = await get_user_oauth_credential(prisma, "alice", "srv-1")
|
|
assert result is not None
|
|
assert result["type"] == "oauth2"
|
|
assert result["access_token"] == access_token
|
|
assert result["refresh_token"] == "rfr-xyz"
|
|
assert result["scopes"] == ["a", "b"]
|
|
assert result["identity_binding_proof"] == "verified-proof"
|
|
assert result.get("cimd_client_id") == cimd_client_id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_oauth_get_returns_payload_for_legacy_row():
|
|
payload = {
|
|
"type": "oauth2",
|
|
"access_token": "legacy-token",
|
|
"connected_at": "2024-01-01T00:00:00Z",
|
|
}
|
|
legacy_b64 = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode()
|
|
row = MagicMock()
|
|
row.credential_b64 = legacy_b64
|
|
row.server_id = "srv-1"
|
|
prisma = _make_prisma_with_existing(row=row)
|
|
|
|
result = await get_user_oauth_credential(prisma, "alice", "srv-1")
|
|
assert result is not None
|
|
assert result["access_token"] == "legacy-token"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_oauth_get_returns_none_for_byok_row():
|
|
# A row that holds a BYOK string must not leak as an OAuth payload.
|
|
prisma = _make_prisma_with_existing(row=_legacy_row("plain-byok-not-json"))
|
|
result = await get_user_oauth_credential(prisma, "alice", "srv-1")
|
|
assert result is None
|
|
|
|
|
|
# ── BYOK guard inside store_user_oauth_credential ─────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_byok_guard_rejects_overwriting_legacy_byok():
|
|
prisma = _make_prisma_with_existing(row=_legacy_row("plain-byok-key"))
|
|
with pytest.raises(ValueError, match="could not be verified as an OAuth2"):
|
|
await store_user_oauth_credential(prisma, "alice", "srv-1", "tok")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_byok_guard_rejects_overwriting_encrypted_byok():
|
|
# Simulate a row written by the new (encrypted) code path: write a BYOK,
|
|
# then attempt to overwrite with an OAuth token.
|
|
prisma = _make_prisma_with_existing(row=None)
|
|
await store_user_credential(prisma, "alice", "srv-1", "sk-secret-byok")
|
|
|
|
encrypted_row = MagicMock()
|
|
encrypted_row.credential_b64 = _stored_value(prisma)
|
|
prisma.db.litellm_mcpusercredentials.find_unique = AsyncMock(return_value=encrypted_row)
|
|
|
|
with pytest.raises(ValueError, match="could not be verified as an OAuth2"):
|
|
await store_user_oauth_credential(prisma, "alice", "srv-1", "tok")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_byok_guard_allows_overwriting_existing_oauth():
|
|
# Refresh path: row already holds an OAuth payload, write must succeed.
|
|
prisma = _make_prisma_with_existing(row=None)
|
|
await store_user_oauth_credential(prisma, "alice", "srv-1", "tok-1")
|
|
|
|
oauth_row = MagicMock()
|
|
oauth_row.credential_b64 = _stored_value(prisma)
|
|
prisma.db.litellm_mcpusercredentials.find_unique = AsyncMock(return_value=oauth_row)
|
|
|
|
await store_user_oauth_credential(prisma, "alice", "srv-1", "tok-2")
|
|
# Final upsert wrote a new payload (different from the first)
|
|
assert _stored_value(prisma) != oauth_row.credential_b64
|
|
|
|
|
|
# ── Recovery from an unplanned LITELLM_SALT_KEY change ────────────────────────
|
|
|
|
PREVIOUS_SALT_KEY = "the-salt-key-this-deployment-used-before-9999"
|
|
|
|
|
|
def _row_written_under_previous_salt_key(monkeypatch, payload: str):
|
|
"""A row encrypted under a salt key the proxy no longer holds.
|
|
|
|
Asserts the fixture really is undecryptable under the current key, so a test
|
|
built on it cannot pass by accident.
|
|
"""
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", PREVIOUS_SALT_KEY)
|
|
encrypted = encrypt_value_helper(payload)
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", SALT_KEY)
|
|
assert _decode_user_credential(encrypted) is None, "fixture must not decrypt under the current salt key"
|
|
row = MagicMock()
|
|
row.credential_b64 = encrypted
|
|
row.user_id = "alice"
|
|
row.server_id = "srv-1"
|
|
return row
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reauthorization_replaces_row_written_under_previous_salt_key(monkeypatch):
|
|
# The wedged user: their row cannot be decrypted, so refusing preserves nothing.
|
|
old_payload = json.dumps({"type": "oauth2", "access_token": "tok-written-before-rotation"})
|
|
prisma = _make_prisma_with_existing(row=_row_written_under_previous_salt_key(monkeypatch, old_payload))
|
|
|
|
await store_user_oauth_credential(prisma, "alice", "srv-1", "tok-after-reauthorization")
|
|
|
|
# The replacement must decrypt under the CURRENT key and be the newly authorized token.
|
|
replacement = MagicMock()
|
|
replacement.credential_b64 = _stored_value(prisma)
|
|
replacement.server_id = "srv-1"
|
|
prisma.db.litellm_mcpusercredentials.find_unique = AsyncMock(return_value=replacement)
|
|
stored = await get_user_oauth_credential(prisma, "alice", "srv-1")
|
|
assert stored is not None
|
|
assert stored["access_token"] == "tok-after-reauthorization"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_readable_byok_is_still_refused_after_a_salt_key_change(monkeypatch):
|
|
# A legacy plain-base64 BYOK secret stays readable across a salt-key change, so
|
|
# the recovery path must not use it as an excuse to clobber a live credential.
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", "a-completely-different-salt-key-4321")
|
|
prisma = _make_prisma_with_existing(row=_legacy_row("sk-live-byok-secret"))
|
|
|
|
with pytest.raises(ValueError, match="could not be verified as an OAuth2"):
|
|
await store_user_oauth_credential(prisma, "alice", "srv-1", "tok")
|
|
|
|
prisma.db.litellm_mcpusercredentials.upsert.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_recovery_warns_with_identifiers_and_never_logs_credentials(monkeypatch, caplog):
|
|
import logging
|
|
|
|
old_payload = json.dumps({"type": "oauth2", "access_token": "tok-written-before-rotation"})
|
|
row = _row_written_under_previous_salt_key(monkeypatch, old_payload)
|
|
prisma = _make_prisma_with_existing(row=row)
|
|
|
|
with caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"):
|
|
await store_user_oauth_credential(prisma, "alice", "srv-1", "tok-after-reauthorization")
|
|
|
|
messages = [rec.getMessage() for rec in caplog.records]
|
|
matching = [m for m in messages if "could not be decrypted" in m and "replacing it" in m]
|
|
assert len(matching) == 1, f"expected one recovery warning, got {messages}"
|
|
assert "user=alice" in matching[0] and "server=srv-1" in matching[0]
|
|
for secret in ("tok-after-reauthorization", "tok-written-before-rotation", row.credential_b64):
|
|
assert secret not in matching[0]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_user_oauth_credential_warns_when_row_cannot_be_decrypted(monkeypatch, caplog):
|
|
import logging
|
|
|
|
old_payload = json.dumps({"type": "oauth2", "access_token": "tok-written-before-rotation"})
|
|
prisma = _make_prisma_with_existing(row=_row_written_under_previous_salt_key(monkeypatch, old_payload))
|
|
|
|
with caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"):
|
|
assert await get_user_oauth_credential(prisma, "alice", "srv-1") is None
|
|
|
|
matching = [rec.getMessage() for rec in caplog.records if "could not be decrypted" in rec.getMessage()]
|
|
assert len(matching) == 1, f"expected one read-path warning, got {[r.getMessage() for r in caplog.records]}"
|
|
assert "user=alice" in matching[0] and "server=srv-1" in matching[0]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_user_oauth_credentials_warns_per_row_when_rows_cannot_be_decrypted(monkeypatch, caplog):
|
|
# The bulk prefetch is the other read path, and it is by definition the multi-server case:
|
|
# a warning naming the wrong server sends the operator to the wrong place. Two wedged rows
|
|
# plus one healthy one, so a warning built from a constant or from the first row is caught.
|
|
import logging
|
|
|
|
old_payload = json.dumps({"type": "oauth2", "access_token": "tok-written-before-rotation"})
|
|
wedged_one = _row_written_under_previous_salt_key(monkeypatch, old_payload)
|
|
wedged_two = _row_written_under_previous_salt_key(monkeypatch, old_payload)
|
|
wedged_two.server_id = "srv-2"
|
|
|
|
prisma = _make_prisma_with_existing(row=None)
|
|
await store_user_oauth_credential(prisma, "alice", "srv-3", "tok-healthy")
|
|
healthy = MagicMock()
|
|
healthy.credential_b64 = _stored_value(prisma)
|
|
healthy.server_id = "srv-3"
|
|
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[wedged_one, healthy, wedged_two])
|
|
|
|
with caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"):
|
|
result = await list_user_oauth_credentials(prisma, "alice")
|
|
|
|
assert [cred["server_id"] for cred in result] == ["srv-3"]
|
|
matching = [rec.getMessage() for rec in caplog.records if "could not be decrypted" in rec.getMessage()]
|
|
assert len(matching) == 2, f"expected one warning per wedged row, got {matching}"
|
|
assert all("user=alice" in message for message in matching)
|
|
assert {"srv-1", "srv-2"} == {message.split("server=")[1].split(" ")[0] for message in matching}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skip_byok_guard_does_not_read_the_existing_row(monkeypatch):
|
|
# The refresh paths pass skip_byok_guard=True precisely to save a DB round-trip on the
|
|
# hottest MCP path, so the flag has to actually suppress the lookup, not just the raise.
|
|
prisma = _make_prisma_with_existing(row=_legacy_row("plain-byok-key"))
|
|
|
|
await store_user_oauth_credential(prisma, "alice", "srv-1", "tok", skip_byok_guard=True)
|
|
|
|
prisma.db.litellm_mcpusercredentials.find_unique.assert_not_awaited()
|
|
prisma.db.litellm_mcpusercredentials.upsert.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_blank_credential_row_is_replaced_rather_than_refused():
|
|
# A blank value decodes to "" rather than None, so it is not a decryption failure, but it
|
|
# holds no secret either. Pinned deliberately: the guard exists to protect readable
|
|
# content, and refusing here would wedge the user while preserving nothing.
|
|
blank = MagicMock()
|
|
blank.credential_b64 = ""
|
|
blank.user_id = "alice"
|
|
blank.server_id = "srv-1"
|
|
assert _decode_user_credential(blank.credential_b64) == "", "fixture must decode to empty, not None"
|
|
prisma = _make_prisma_with_existing(row=blank)
|
|
|
|
await store_user_oauth_credential(prisma, "alice", "srv-1", "tok-after-reauthorization")
|
|
|
|
prisma.db.litellm_mcpusercredentials.upsert.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_readable_byok_row_does_not_warn_on_the_read_path(caplog):
|
|
# A BYOK row is not a decryption failure; warning on it would train operators to ignore the log.
|
|
import logging
|
|
|
|
prisma = _make_prisma_with_existing(row=_legacy_row("sk-live-byok-secret"))
|
|
|
|
with caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"):
|
|
assert await get_user_oauth_credential(prisma, "alice", "srv-1") is None
|
|
|
|
assert [rec.getMessage() for rec in caplog.records if "could not be decrypted" in rec.getMessage()] == []
|
|
|
|
|
|
# ── list_user_oauth_credentials ───────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_oauth_credentials_filters_byok_and_returns_payloads():
|
|
# Three rows: one encrypted OAuth, one legacy-plaintext OAuth, one BYOK.
|
|
# Only the two OAuth rows should come back.
|
|
prisma = _make_prisma_with_existing(row=None)
|
|
|
|
await store_user_oauth_credential(prisma, "alice", "srv-encrypted", "tok-enc")
|
|
encrypted_b64 = _stored_value(prisma)
|
|
encrypted_row = MagicMock()
|
|
encrypted_row.credential_b64 = encrypted_b64
|
|
encrypted_row.server_id = "srv-encrypted"
|
|
|
|
legacy_payload = {
|
|
"type": "oauth2",
|
|
"access_token": "tok-legacy",
|
|
"connected_at": "2024-01-01T00:00:00Z",
|
|
}
|
|
legacy_row = MagicMock()
|
|
legacy_row.credential_b64 = base64.urlsafe_b64encode(json.dumps(legacy_payload).encode()).decode()
|
|
legacy_row.server_id = "srv-legacy"
|
|
|
|
byok_row = MagicMock()
|
|
byok_row.credential_b64 = base64.urlsafe_b64encode(b"plain-byok-key").decode()
|
|
byok_row.server_id = "srv-byok"
|
|
|
|
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[encrypted_row, legacy_row, byok_row])
|
|
|
|
results = await list_user_oauth_credentials(prisma, "alice")
|
|
|
|
server_ids = {r["server_id"] for r in results}
|
|
assert server_ids == {"srv-encrypted", "srv-legacy"}
|
|
tokens = {r["access_token"] for r in results}
|
|
assert tokens == {"tok-enc", "tok-legacy"}
|
|
|
|
|
|
# ── _decode_user_credential helper ────────────────────────────────────────────
|
|
|
|
|
|
def test_decode_user_credential_handles_garbage():
|
|
# Malformed input must return None, not raise.
|
|
assert _decode_user_credential("not-base64-and-not-encrypted!!!") is None
|
|
|
|
|
|
def test_decode_user_credential_handles_none():
|
|
# Defensive: a null DB value must return None, not propagate TypeError.
|
|
assert _decode_user_credential(None) is None
|
|
|
|
|
|
def test_decode_user_credential_legacy_path():
|
|
plain = "legacy-secret"
|
|
stored = base64.urlsafe_b64encode(plain.encode()).decode()
|
|
assert _decode_user_credential(stored) == plain
|
|
|
|
|
|
# ── master-key rotation ───────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rotate_re_encrypts_byok_with_new_key(monkeypatch):
|
|
# Encrypt a row under the current salt, then rotate to a new key, then
|
|
# confirm the stored ciphertext decrypts under the NEW key — and not under
|
|
# the old one.
|
|
prisma = _make_prisma_with_existing(row=None)
|
|
secret = "sk-original-byok-key"
|
|
await store_user_credential(prisma, "alice", "srv-1", secret)
|
|
encrypted_old = _stored_value(prisma)
|
|
|
|
row = MagicMock()
|
|
row.user_id = "alice"
|
|
row.server_id = "srv-1"
|
|
row.credential_b64 = encrypted_old
|
|
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[row])
|
|
prisma.db.litellm_mcpusercredentials.update = AsyncMock()
|
|
|
|
new_master_key = "rotated-salt-key-9999-9999-9999-9999"
|
|
await rotate_mcp_user_credentials_master_key(prisma_client=prisma, new_master_key=new_master_key)
|
|
|
|
update_call = prisma.db.litellm_mcpusercredentials.update.call_args
|
|
new_stored = update_call.kwargs["data"]["credential_b64"]
|
|
assert new_stored != encrypted_old, "rotation must produce different ciphertext"
|
|
|
|
# Decrypt the rotated value under the NEW salt key — round-trips to plaintext.
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", new_master_key)
|
|
assert (
|
|
decrypt_value_helper(
|
|
value=new_stored,
|
|
key="mcp_user_credential",
|
|
exception_type="debug",
|
|
return_original_value=False,
|
|
)
|
|
== secret
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rotate_migrates_legacy_plaintext_rows(monkeypatch):
|
|
# A legacy plain-base64 row must also get re-encrypted under the new key.
|
|
prisma = _make_prisma_with_existing(row=None)
|
|
|
|
legacy_row = MagicMock()
|
|
legacy_row.user_id = "alice"
|
|
legacy_row.server_id = "srv-legacy"
|
|
legacy_row.credential_b64 = base64.urlsafe_b64encode(b"legacy-plain").decode()
|
|
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[legacy_row])
|
|
prisma.db.litellm_mcpusercredentials.update = AsyncMock()
|
|
|
|
new_key = "another-rotation-key-aaaa-bbbb-cccc-dddd"
|
|
await rotate_mcp_user_credentials_master_key(prisma_client=prisma, new_master_key=new_key)
|
|
|
|
new_stored = prisma.db.litellm_mcpusercredentials.update.call_args.kwargs["data"]["credential_b64"]
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", new_key)
|
|
assert (
|
|
decrypt_value_helper(
|
|
value=new_stored,
|
|
key="mcp_user_credential",
|
|
exception_type="debug",
|
|
return_original_value=False,
|
|
)
|
|
== "legacy-plain"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rotate_skips_undecodable_rows():
|
|
# One bad row must not abort the rotation for the rest.
|
|
prisma = _make_prisma_with_existing(row=None)
|
|
|
|
bad_row = MagicMock()
|
|
bad_row.user_id = "alice"
|
|
bad_row.server_id = "srv-corrupt"
|
|
bad_row.credential_b64 = "!!! not base64 and not encrypted !!!"
|
|
|
|
good_row = MagicMock()
|
|
good_row.user_id = "bob"
|
|
good_row.server_id = "srv-ok"
|
|
good_row.credential_b64 = base64.urlsafe_b64encode(b"good-byok").decode()
|
|
|
|
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[bad_row, good_row])
|
|
prisma.db.litellm_mcpusercredentials.update = AsyncMock()
|
|
|
|
await rotate_mcp_user_credentials_master_key(prisma_client=prisma, new_master_key="new-key-xxxx")
|
|
|
|
# Only one update call — the good row.
|
|
assert prisma.db.litellm_mcpusercredentials.update.call_count == 1
|
|
where = prisma.db.litellm_mcpusercredentials.update.call_args.kwargs["where"]
|
|
assert where["user_id_server_id"]["server_id"] == "srv-ok"
|
|
|
|
|
|
# ── Expiry buffer + refresh-on-expiry (OBO list-refresh regression) ───────────
|
|
|
|
|
|
def _oauth_cred(access_token="at-live", refresh_token=None, expires_in_seconds=None):
|
|
cred = {"type": "oauth2", "access_token": access_token}
|
|
if refresh_token is not None:
|
|
cred["refresh_token"] = refresh_token
|
|
if expires_in_seconds is not None:
|
|
cred["expires_at"] = (datetime.now(timezone.utc) + timedelta(seconds=expires_in_seconds)).isoformat()
|
|
return cred
|
|
|
|
|
|
def test_expiry_no_buffer_treats_soon_to_expire_as_valid():
|
|
# Without a buffer, a token with 30s of life left is still valid.
|
|
cred = _oauth_cred(expires_in_seconds=30)
|
|
assert is_oauth_credential_expired(cred) is False
|
|
assert is_oauth_credential_expired(cred, buffer_seconds=0) is False
|
|
|
|
|
|
def test_expiry_buffer_treats_soon_to_expire_as_expired():
|
|
# With a 60s buffer, the same 30s-of-life token must be treated as expired
|
|
# so callers refresh before it lapses mid-request.
|
|
cred = _oauth_cred(expires_in_seconds=30)
|
|
assert is_oauth_credential_expired(cred, buffer_seconds=60) is True
|
|
# A token comfortably beyond the buffer stays valid.
|
|
assert is_oauth_credential_expired(_oauth_cred(expires_in_seconds=600), buffer_seconds=60) is False
|
|
|
|
|
|
def test_expiry_past_is_expired_regardless_of_buffer():
|
|
cred = _oauth_cred(expires_in_seconds=-10)
|
|
assert is_oauth_credential_expired(cred) is True
|
|
assert is_oauth_credential_expired(cred, buffer_seconds=60) is True
|
|
|
|
|
|
def test_expiry_missing_expires_at_is_never_expired():
|
|
assert is_oauth_credential_expired(_oauth_cred()) is False
|
|
assert is_oauth_credential_expired(_oauth_cred(), buffer_seconds=60) is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_returns_valid_token_without_refreshing(monkeypatch):
|
|
# A token good for 10 minutes must be returned as-is, with no refresh call.
|
|
import litellm.proxy.mcp_server.db as db_mod
|
|
|
|
refresh = AsyncMock()
|
|
monkeypatch.setattr(db_mod, "refresh_user_oauth_token", refresh)
|
|
|
|
cred = _oauth_cred(access_token="at-live", refresh_token="rt-1", expires_in_seconds=600)
|
|
result = await resolve_valid_user_oauth_token(
|
|
user_id="alice", server=MagicMock(), cred=cred, prisma_client=MagicMock()
|
|
)
|
|
|
|
assert result is cred
|
|
assert result["access_token"] == "at-live"
|
|
refresh.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_refreshes_expired_token_with_refresh_token(monkeypatch):
|
|
# The core regression: an expired OBO cred with a refresh_token must mint a
|
|
# new token rather than returning None (which left the UI tool list empty).
|
|
import litellm.proxy.mcp_server.db as db_mod
|
|
|
|
refreshed = _oauth_cred(access_token="at-fresh", refresh_token="rt-2", expires_in_seconds=3600)
|
|
refresh = AsyncMock(return_value=refreshed)
|
|
monkeypatch.setattr(db_mod, "refresh_user_oauth_token", refresh)
|
|
|
|
expired = _oauth_cred(access_token="at-dead", refresh_token="rt-1", expires_in_seconds=-5)
|
|
result = await resolve_valid_user_oauth_token(
|
|
user_id="alice", server=MagicMock(), cred=expired, prisma_client=MagicMock()
|
|
)
|
|
|
|
refresh.assert_awaited_once()
|
|
assert result["access_token"] == "at-fresh"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_refreshes_token_expiring_within_buffer(monkeypatch):
|
|
# A token still technically valid (30s left) but inside the 60s buffer must
|
|
# be proactively refreshed, not handed back.
|
|
import litellm.proxy.mcp_server.db as db_mod
|
|
|
|
refreshed = _oauth_cred(access_token="at-fresh", expires_in_seconds=3600)
|
|
refresh = AsyncMock(return_value=refreshed)
|
|
monkeypatch.setattr(db_mod, "refresh_user_oauth_token", refresh)
|
|
|
|
soon = _oauth_cred(access_token="at-soon", refresh_token="rt-1", expires_in_seconds=30)
|
|
result = await resolve_valid_user_oauth_token(
|
|
user_id="alice", server=MagicMock(), cred=soon, prisma_client=MagicMock()
|
|
)
|
|
|
|
refresh.assert_awaited_once()
|
|
assert result["access_token"] == "at-fresh"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_returns_none_when_expired_without_refresh_token(monkeypatch):
|
|
# No refresh_token means nothing to refresh with — return None, never call refresh.
|
|
import litellm.proxy.mcp_server.db as db_mod
|
|
|
|
refresh = AsyncMock()
|
|
monkeypatch.setattr(db_mod, "refresh_user_oauth_token", refresh)
|
|
|
|
expired = _oauth_cred(access_token="at-dead", expires_in_seconds=-5)
|
|
result = await resolve_valid_user_oauth_token(
|
|
user_id="alice", server=MagicMock(), cred=expired, prisma_client=MagicMock()
|
|
)
|
|
|
|
assert result is None
|
|
refresh.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_returns_none_when_refresh_fails(monkeypatch):
|
|
# A failed refresh (provider returns nothing usable) must surface as None.
|
|
import litellm.proxy.mcp_server.db as db_mod
|
|
|
|
refresh = AsyncMock(return_value=None)
|
|
monkeypatch.setattr(db_mod, "refresh_user_oauth_token", refresh)
|
|
|
|
expired = _oauth_cred(access_token="at-dead", refresh_token="rt-1", expires_in_seconds=-5)
|
|
result = await resolve_valid_user_oauth_token(
|
|
user_id="alice", server=MagicMock(), cred=expired, prisma_client=MagicMock()
|
|
)
|
|
|
|
refresh.assert_awaited_once()
|
|
assert result is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_returns_none_for_missing_credential(monkeypatch):
|
|
import litellm.proxy.mcp_server.db as db_mod
|
|
|
|
refresh = AsyncMock()
|
|
monkeypatch.setattr(db_mod, "refresh_user_oauth_token", refresh)
|
|
|
|
assert (
|
|
await resolve_valid_user_oauth_token(user_id="alice", server=MagicMock(), cred=None, prisma_client=MagicMock())
|
|
is None
|
|
)
|
|
assert (
|
|
await resolve_valid_user_oauth_token(
|
|
user_id="alice",
|
|
server=MagicMock(),
|
|
cred={"type": "oauth2"},
|
|
prisma_client=MagicMock(),
|
|
)
|
|
is None
|
|
)
|
|
refresh.assert_not_called()
|
|
|
|
|
|
class _RefreshResponse:
|
|
def __init__(self, body):
|
|
self._body = body
|
|
|
|
def raise_for_status(self):
|
|
return None
|
|
|
|
def json(self):
|
|
return self._body
|
|
|
|
|
|
def _refresh_server(**overrides):
|
|
base = dict(
|
|
oauth_identity_binding=None,
|
|
token_url="https://idp.example.com/token",
|
|
server_id="srv-1",
|
|
client_id="cid",
|
|
client_secret="csec",
|
|
token_endpoint_auth_method=None,
|
|
upstream_resource=None,
|
|
url="https://up.example.com/mcp",
|
|
)
|
|
base.update(overrides)
|
|
return SimpleNamespace(**base)
|
|
|
|
|
|
async def _run_refresh(monkeypatch, server, response_body=None):
|
|
import litellm.proxy.mcp_server.db as db_mod
|
|
|
|
captured: dict = {}
|
|
|
|
async def _post(url, headers=None, data=None):
|
|
captured["url"] = url
|
|
captured["headers"] = headers
|
|
captured["data"] = data
|
|
return _RefreshResponse(response_body or {"access_token": "at-new", "expires_in": 3600})
|
|
|
|
monkeypatch.setattr(db_mod, "get_async_httpx_client", lambda **_: SimpleNamespace(post=_post))
|
|
monkeypatch.setattr(db_mod, "store_user_oauth_credential", AsyncMock())
|
|
monkeypatch.setattr(db_mod, "get_user_oauth_credential", AsyncMock(return_value={"access_token": "at-new"}))
|
|
|
|
result = await db_mod.refresh_user_oauth_token(
|
|
prisma_client=MagicMock(),
|
|
user_id="alice",
|
|
server=server,
|
|
cred={"refresh_token": "rt-old", "scopes": ["a"]},
|
|
)
|
|
return result, captured
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_refresh_user_oauth_token_sends_upstream_resource_when_set(monkeypatch):
|
|
"""The server-side silent refresh must carry the same RFC 8707 resource the authorize and initial
|
|
token legs sent; a strict authorization server rejects a refresh whose resource is absent with
|
|
invalid_target, forcing a needless re-auth."""
|
|
result, captured = await _run_refresh(monkeypatch, _refresh_server(upstream_resource="api://audience"))
|
|
assert result is not None
|
|
assert captured["data"]["grant_type"] == "refresh_token"
|
|
assert captured["data"]["resource"] == "api://audience"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_refresh_user_oauth_token_sends_auto_derived_resource(monkeypatch):
|
|
result, captured = await _run_refresh(
|
|
monkeypatch, _refresh_server(upstream_resource="auto", url="https://mcp.example.com/mcp")
|
|
)
|
|
assert result is not None
|
|
assert captured["data"]["resource"] == "https://mcp.example.com/mcp"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_refresh_user_oauth_token_omits_resource_when_unset(monkeypatch):
|
|
result, captured = await _run_refresh(monkeypatch, _refresh_server(upstream_resource=None))
|
|
assert result is not None
|
|
assert "resource" not in captured["data"]
|
|
|
|
|
|
# ── per-user env-var rotation ─────────────────────────────────────────────────
|
|
|
|
|
|
def _env_var_row(values_b64: str, user_id="alice", server_id="srv-1"):
|
|
row = MagicMock()
|
|
row.values_b64 = values_b64
|
|
row.user_id = user_id
|
|
row.server_id = server_id
|
|
return row
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rotate_user_env_vars_re_encrypts_with_new_key(monkeypatch):
|
|
# Encrypt env vars under the current salt, rotate to a new key, then confirm
|
|
# the stored ciphertext round-trips under the NEW key.
|
|
values = {"API_KEY": "sk-secret", "REGION": "us-east-1"}
|
|
encrypted_old = encrypt_value_helper(json.dumps(values))
|
|
|
|
prisma = MagicMock()
|
|
prisma.db.litellm_mcpuserenvvars.find_many = AsyncMock(return_value=[_env_var_row(encrypted_old)])
|
|
prisma.db.litellm_mcpuserenvvars.update = AsyncMock()
|
|
|
|
new_master_key = "rotated-env-key-1111-2222-3333-4444"
|
|
await rotate_mcp_user_env_vars_master_key(prisma_client=prisma, new_master_key=new_master_key)
|
|
|
|
new_stored = prisma.db.litellm_mcpuserenvvars.update.call_args.kwargs["data"]["values_b64"]
|
|
assert new_stored != encrypted_old, "rotation must produce different ciphertext"
|
|
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", new_master_key)
|
|
decrypted = decrypt_value_helper(
|
|
value=new_stored,
|
|
key="mcp_user_env_vars",
|
|
exception_type="debug",
|
|
return_original_value=False,
|
|
)
|
|
assert json.loads(decrypted) == values
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rotate_user_env_vars_skips_undecryptable_rows():
|
|
# A corrupt row must be skipped (not overwritten) so recoverable data is
|
|
# preserved and one bad row does not abort the rest of the rotation.
|
|
good = _env_var_row(encrypt_value_helper(json.dumps({"A": "1"})), server_id="srv-ok")
|
|
bad = _env_var_row("!!! not encrypted !!!", server_id="srv-corrupt")
|
|
|
|
prisma = MagicMock()
|
|
prisma.db.litellm_mcpuserenvvars.find_many = AsyncMock(return_value=[bad, good])
|
|
prisma.db.litellm_mcpuserenvvars.update = AsyncMock()
|
|
|
|
await rotate_mcp_user_env_vars_master_key(prisma_client=prisma, new_master_key="new-key-xxxx")
|
|
|
|
assert prisma.db.litellm_mcpuserenvvars.update.call_count == 1
|
|
where = prisma.db.litellm_mcpuserenvvars.update.call_args.kwargs["where"]
|
|
assert where["user_id_server_id"]["server_id"] == "srv-ok"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_refresh_user_oauth_token_uses_client_secret_basic(monkeypatch):
|
|
"""LIT-4091: a per-user refresh against a server with token_endpoint_auth_method=client_secret_basic
|
|
sends HTTP Basic and keeps the secret out of the body."""
|
|
import litellm.proxy.mcp_server.db as db_mod
|
|
|
|
server = MagicMock(oauth_identity_binding=None)
|
|
server.token_url = "https://idp.example.com/oauth2/token"
|
|
server.server_id = "srv"
|
|
server.client_id = "cid"
|
|
server.client_secret = "sec"
|
|
server.token_endpoint_auth_method = "client_secret_basic"
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {"access_token": "new-at", "expires_in": 3600}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_client = AsyncMock()
|
|
mock_client.post.return_value = mock_response
|
|
|
|
monkeypatch.setattr(db_mod, "get_async_httpx_client", lambda **kwargs: mock_client)
|
|
monkeypatch.setattr(db_mod, "store_user_oauth_credential", AsyncMock())
|
|
monkeypatch.setattr(db_mod, "get_user_oauth_credential", AsyncMock(return_value={"access_token": "new-at"}))
|
|
|
|
result = await db_mod.refresh_user_oauth_token(
|
|
prisma_client=MagicMock(),
|
|
user_id="alice",
|
|
server=server,
|
|
cred={"refresh_token": "rt"},
|
|
)
|
|
|
|
assert result is not None
|
|
_, kwargs = mock_client.post.call_args
|
|
assert kwargs["headers"]["Authorization"] == "Basic " + base64.b64encode(b"cid:sec").decode()
|
|
assert "client_secret" not in kwargs["data"]
|
|
assert "client_id" not in kwargs["data"]
|
|
assert kwargs["data"]["grant_type"] == "refresh_token"
|
|
assert kwargs["data"]["refresh_token"] == "rt"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_refresh_user_oauth_token_defaults_to_client_secret_post(monkeypatch):
|
|
"""Backward compatibility: with no token_endpoint_auth_method the refresh keeps credentials in
|
|
the body (client_secret_post) and sends no Authorization header."""
|
|
import litellm.proxy.mcp_server.db as db_mod
|
|
|
|
server = MagicMock(oauth_identity_binding=None)
|
|
server.token_url = "https://idp.example.com/oauth2/token"
|
|
server.server_id = "srv"
|
|
server.client_id = "cid"
|
|
server.client_secret = "sec"
|
|
server.token_endpoint_auth_method = None
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {"access_token": "new-at", "expires_in": 3600}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_client = AsyncMock()
|
|
mock_client.post.return_value = mock_response
|
|
|
|
monkeypatch.setattr(db_mod, "get_async_httpx_client", lambda **kwargs: mock_client)
|
|
monkeypatch.setattr(db_mod, "store_user_oauth_credential", AsyncMock())
|
|
monkeypatch.setattr(db_mod, "get_user_oauth_credential", AsyncMock(return_value={"access_token": "new-at"}))
|
|
|
|
await db_mod.refresh_user_oauth_token(
|
|
prisma_client=MagicMock(),
|
|
user_id="alice",
|
|
server=server,
|
|
cred={"refresh_token": "rt"},
|
|
)
|
|
|
|
_, kwargs = mock_client.post.call_args
|
|
assert "Authorization" not in kwargs["headers"]
|
|
assert kwargs["data"]["client_id"] == "cid"
|
|
assert kwargs["data"]["client_secret"] == "sec"
|
|
|
|
|
|
def test_prepare_mcp_server_data_create_carries_token_exchange_columns():
|
|
"""The create path (POST /v1/mcp/server) must emit token_exchange_endpoint/audience/
|
|
subject_token_type as top-level column values so an auth_type=oauth2_token_exchange server
|
|
persists via the REST API, not only via config.yaml. Dropping the fields from the request
|
|
model would leave them out of the prepared column data."""
|
|
request = NewMCPServerRequest(
|
|
server_name="te_write",
|
|
url="https://upstream.example.com/mcp",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2_token_exchange,
|
|
token_exchange_endpoint="https://idp.example.com/oauth2/token",
|
|
audience="https://upstream.example.com",
|
|
subject_token_type="urn:ietf:params:oauth:token-type:jwt",
|
|
token_exchange_profile="entra_obo",
|
|
credentials={"client_id": "te-client", "client_secret": "te-secret"},
|
|
)
|
|
|
|
data = _prepare_mcp_server_data(request)
|
|
|
|
assert data["token_exchange_endpoint"] == "https://idp.example.com/oauth2/token"
|
|
assert data["audience"] == "https://upstream.example.com"
|
|
assert data["subject_token_type"] == "urn:ietf:params:oauth:token-type:jwt"
|
|
assert data["token_exchange_profile"] == "entra_obo"
|
|
|
|
|
|
def test_prepare_mcp_server_data_update_carries_token_exchange_columns():
|
|
"""The partial-update path (PUT /v1/mcp/server, exclude_unset) must carry the three
|
|
token-exchange columns when the caller provides them."""
|
|
request = UpdateMCPServerRequest(
|
|
server_id="te-update",
|
|
url="https://upstream.example.com/mcp",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2_token_exchange,
|
|
token_exchange_endpoint="https://idp.example.com/oauth2/token",
|
|
audience="https://upstream.example.com",
|
|
subject_token_type="urn:ietf:params:oauth:token-type:jwt",
|
|
token_exchange_profile="entra_obo",
|
|
)
|
|
|
|
data = _prepare_mcp_server_data(request, exclude_unset=True)
|
|
|
|
assert data["token_exchange_endpoint"] == "https://idp.example.com/oauth2/token"
|
|
assert data["audience"] == "https://upstream.example.com"
|
|
assert data["subject_token_type"] == "urn:ietf:params:oauth:token-type:jwt"
|
|
assert data["token_exchange_profile"] == "entra_obo"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_master_key_rotation_reencrypts_oauth_client_store(monkeypatch):
|
|
"""The server-scoped DCR client store (LiteLLM_MCPServerOAuthClient) is encrypted at rest, so a
|
|
master-key rotation must re-encrypt it alongside the server rows. Skipping it leaves
|
|
config-declared DCR clients under the retired key, where they decrypt back to ciphertext and
|
|
force a full re-authorization."""
|
|
import litellm.proxy.common_utils.encrypt_decrypt_utils as enc
|
|
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
|
|
from litellm.proxy.mcp_server.db import (
|
|
decrypt_credentials,
|
|
encrypt_credentials,
|
|
rotate_mcp_server_credentials_master_key,
|
|
)
|
|
|
|
key_old, key_new = "salt-old-key", "salt-new-key"
|
|
|
|
blob_old = safe_dumps(
|
|
encrypt_credentials(
|
|
credentials={"client_id": "cid-123", "client_secret": "sec-456"},
|
|
encryption_key=key_old,
|
|
)
|
|
)
|
|
|
|
monkeypatch.setattr(enc, "get_salt_key", lambda: key_old)
|
|
|
|
prisma = MagicMock()
|
|
prisma.db.litellm_mcpservertable.find_many = AsyncMock(return_value=[])
|
|
prisma.db.litellm_config.find_unique = AsyncMock(return_value=None)
|
|
prisma.db.litellm_mcpserveroauthclient.find_many = AsyncMock(
|
|
return_value=[SimpleNamespace(server_id="config_faros", credentials=blob_old)]
|
|
)
|
|
store_update = AsyncMock()
|
|
prisma.db.litellm_mcpserveroauthclient.update = store_update
|
|
|
|
await rotate_mcp_server_credentials_master_key(prisma, touched_by="test", new_master_key=key_new)
|
|
|
|
store_update.assert_awaited_once()
|
|
assert store_update.await_args.kwargs["where"] == {"server_id": "config_faros"}
|
|
rotated_blob = store_update.await_args.kwargs["data"]["credentials"]
|
|
|
|
monkeypatch.setattr(enc, "get_salt_key", lambda: key_new)
|
|
recovered = decrypt_credentials(credentials=json.loads(rotated_blob))
|
|
assert recovered["client_id"] == "cid-123"
|
|
assert recovered["client_secret"] == "sec-456"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_mcp_server_cleans_oauth_client_store():
|
|
"""Deleting a server must remove its server-scoped DCR client store entry alongside the per-user
|
|
credential and env-var rows, or a re-created server reusing the same server_id would inherit the
|
|
deleted server's OAuth client."""
|
|
from litellm.proxy.mcp_server.db import delete_mcp_server
|
|
|
|
prisma = MagicMock()
|
|
prisma.db.litellm_mcpservertable.delete = AsyncMock(return_value=SimpleNamespace(server_id="s1"))
|
|
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[])
|
|
prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock()
|
|
prisma.db.litellm_mcpuserenvvars.delete_many = AsyncMock()
|
|
prisma.db.litellm_mcpserveroauthclient.delete_many = AsyncMock()
|
|
|
|
await delete_mcp_server(prisma, "s1", invalidate_token_cache=AsyncMock())
|
|
|
|
prisma.db.litellm_mcpserveroauthclient.delete_many.assert_awaited_once_with(where={"server_id": "s1"})
|
|
|
|
|
|
def test_mcp_oauth_token_identity_changes_when_only_upstream_resource_is_edited():
|
|
"""A resource-only update must purge stored per-user tokens.
|
|
|
|
Changing ``upstream_resource`` changes the audience the next token is minted for, so every
|
|
token already stored for this server was minted for the old (or unbounded) audience. Without
|
|
this field in the identity, an administrator retargeting a server leaves authenticated users
|
|
calling tools with the previous audience's token until it expires, which is the token-reuse
|
|
RFC 8707 exists to stop.
|
|
"""
|
|
from litellm.proxy.mcp_server.db import mcp_oauth_token_identity
|
|
|
|
creds = {"client_id": "cid", "client_secret": "csec", "scopes": ["a"]}
|
|
unset = _identity_server(credentials=dict(creds))
|
|
set_to_auto = _identity_server(credentials={**creds, "upstream_resource": "auto"})
|
|
set_to_explicit = _identity_server(credentials={**creds, "upstream_resource": "api://audience-one"})
|
|
retargeted = _identity_server(credentials={**creds, "upstream_resource": "api://audience-two"})
|
|
|
|
assert mcp_oauth_token_identity(unset) != mcp_oauth_token_identity(set_to_auto)
|
|
assert mcp_oauth_token_identity(unset) != mcp_oauth_token_identity(set_to_explicit)
|
|
assert mcp_oauth_token_identity(set_to_explicit) != mcp_oauth_token_identity(retargeted)
|
|
assert mcp_oauth_token_identity(set_to_explicit) == mcp_oauth_token_identity(
|
|
_identity_server(credentials={**creds, "upstream_resource": "api://audience-one"})
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_refresh_user_oauth_token_uses_admin_entered_token_url_when_issuer_yield_empties_resolved(monkeypatch):
|
|
"""A pinned issuer empties the resolved token_url while configured_token_url keeps the
|
|
admin-entered value; the silent per-user refresh must POST there instead of bailing."""
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.types.mcp import MCPAuth
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
server = MCPServer(
|
|
server_id="srv-1",
|
|
name="test",
|
|
url="https://up.example.com/mcp",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
client_id="cid",
|
|
client_secret="csec",
|
|
token_url=None,
|
|
configured_token_url="https://idp.example.com/token",
|
|
)
|
|
result, captured = await _run_refresh(monkeypatch, server)
|
|
|
|
assert result is not None
|
|
assert captured["url"] == "https://idp.example.com/token"
|
|
|
|
|
|
def test_prepare_mcp_server_data_carries_per_server_oauth_discovery():
|
|
request = NewMCPServerRequest(
|
|
server_name="relay_create",
|
|
url="https://upstream.example.com/mcp",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
oauth2_flow="authorization_code",
|
|
per_server_oauth_discovery=True,
|
|
)
|
|
|
|
data = _prepare_mcp_server_data(request)
|
|
|
|
assert data["per_server_oauth_discovery"] is True
|
|
|
|
|
|
def test_prepare_mcp_server_data_update_carries_per_server_oauth_discovery():
|
|
request = UpdateMCPServerRequest(
|
|
server_id="relay-update",
|
|
url="https://upstream.example.com/mcp",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
oauth2_flow="authorization_code",
|
|
per_server_oauth_discovery=True,
|
|
)
|
|
|
|
data = _prepare_mcp_server_data(request, exclude_unset=True)
|
|
|
|
assert data["per_server_oauth_discovery"] is True
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"request_cls, extra, overrides",
|
|
[
|
|
(NewMCPServerRequest, {"server_name": "relay_create"}, {"auth_type": MCPAuth.oauth_delegate}),
|
|
(NewMCPServerRequest, {"server_name": "relay_create"}, {"oauth2_flow": "client_credentials"}),
|
|
(UpdateMCPServerRequest, {"server_id": "relay-update"}, {"delegate_auth_to_upstream": True}),
|
|
],
|
|
)
|
|
def test_request_models_reject_unsupported_per_server_oauth_discovery(request_cls, extra, overrides):
|
|
payload = {
|
|
"url": "https://upstream.example.com/mcp",
|
|
"transport": MCPTransport.http,
|
|
"auth_type": MCPAuth.oauth2,
|
|
"oauth2_flow": "authorization_code",
|
|
"per_server_oauth_discovery": True,
|
|
**extra,
|
|
**overrides,
|
|
}
|
|
|
|
with pytest.raises(ValueError, match="per_server_oauth_discovery is only supported"):
|
|
request_cls(**payload)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"partial_payload",
|
|
[
|
|
{"oauth2_flow": "client_credentials"},
|
|
{"delegate_auth_to_upstream": True},
|
|
{"auth_type": MCPAuth.api_key},
|
|
],
|
|
)
|
|
def test_partial_update_rejects_ineligible_field_alongside_per_server_oauth_discovery(partial_payload):
|
|
with pytest.raises(ValueError, match="per_server_oauth_discovery is only supported"):
|
|
UpdateMCPServerRequest(server_id="relay-update", per_server_oauth_discovery=True, **partial_payload)
|
|
|
|
|
|
def test_partial_update_defers_omitted_eligibility_fields_to_the_stored_row():
|
|
request = UpdateMCPServerRequest(server_id="relay-update", per_server_oauth_discovery=True)
|
|
|
|
assert request.per_server_oauth_discovery is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_enforcement_rejects_preexisting_unverified_credential():
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPOAuthIdentityBinding, MCPServer
|
|
|
|
server = MCPServer(
|
|
server_id="srv-1", name="srv-1", url="https://mcp.example.com", transport=MCPTransport.http, auth_type=MCPAuth.oauth2,
|
|
oauth_identity_binding=MCPOAuthIdentityBinding(
|
|
mode="enforce", issuer="https://idp.example.com", audiences=["client"],
|
|
),
|
|
)
|
|
result = await resolve_valid_user_oauth_token(
|
|
user_id="alice", server=server,
|
|
cred={"access_token": "belongs-to-bob", "refresh_token": "bobs-refresh-token"},
|
|
)
|
|
assert result is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_refresh_identity_rejection_returns_reauthentication_without_persisting(monkeypatch):
|
|
from fastapi import HTTPException
|
|
|
|
from litellm.proxy.mcp_server import db as module
|
|
|
|
validator = AsyncMock(side_effect=HTTPException(status_code=403, detail="oauth_principal_mismatch"))
|
|
monkeypatch.setattr(module, "enforce_oauth_identity_binding", validator)
|
|
result, captured = await _run_refresh(
|
|
monkeypatch, _refresh_server(), {"access_token": "bob", "refresh_token": "rotated"}
|
|
)
|
|
assert result is None
|
|
assert captured["data"]["grant_type"] == "refresh_token"
|
|
module.store_user_oauth_credential.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_verified_legacy_cache_reads_avoid_database_and_reject_policy_changes(monkeypatch):
|
|
from litellm.caching.caching import DualCache
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy.mcp_server import db as module
|
|
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.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")
|
|
monkeypatch.setattr(proxy_server, "user_api_key_cache", DualCache())
|
|
read = AsyncMock(return_value=None)
|
|
monkeypatch.setattr(module, "get_user_oauth_credential", read)
|
|
monkeypatch.setattr(proxy_server, "prisma_client", MagicMock())
|
|
await mcp_per_user_token_cache.set("alice", "srv", "alice-token", 60, identity_binding_proof=proof)
|
|
assert await module.resolve_user_oauth_access_token("alice", server) == "alice-token"
|
|
assert await module.resolve_user_oauth_access_token("alice", server) == "alice-token"
|
|
read.assert_not_awaited()
|
|
server.oauth_identity_binding = server.oauth_identity_binding.model_copy(update={"audiences": ["changed"]})
|
|
assert await module.resolve_user_oauth_access_token("alice", server) is None
|
|
read.assert_awaited_once()
|
|
assert await mcp_per_user_token_cache.get_token("alice", "srv") is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unverified_legacy_cache_cannot_bypass_enforcement(monkeypatch):
|
|
from litellm.caching.caching import DualCache
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy.mcp_server import db as module
|
|
from litellm.proxy.mcp_server.oauth2_token_cache import mcp_per_user_token_cache
|
|
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"]},
|
|
)
|
|
monkeypatch.setattr(proxy_server, "user_api_key_cache", DualCache())
|
|
monkeypatch.setattr(proxy_server, "prisma_client", MagicMock())
|
|
monkeypatch.setattr(module, "get_user_oauth_credential", AsyncMock(return_value={"access_token": "bob"}))
|
|
await mcp_per_user_token_cache.set("alice", "srv", "bob", 60)
|
|
assert await module.resolve_user_oauth_access_token("alice", server) is None
|
|
assert await mcp_per_user_token_cache.get("alice", "srv") is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("owner", ["native", "legacy"])
|
|
@pytest.mark.parametrize("capability", [None, False])
|
|
async def test_saved_cimd_grant_refreshes_from_encrypted_storage_on_a_fresh_replica(monkeypatch, respx_mock, owner, capability):
|
|
from urllib.parse import parse_qs
|
|
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy.mcp_server import db as db_module
|
|
from litellm.proxy.mcp_server.discoverable_endpoints import _store_per_user_token_server_side
|
|
from litellm.proxy.mcp_server.outbound_credentials.per_user_oauth_store import (
|
|
LazyPerUserOAuthTokenStore,
|
|
)
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
monkeypatch.setenv("PROXY_BASE_URL", "https://gateway.example.com")
|
|
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
|
|
identity = "https://gateway.example.com/oauth/client-metadata.json"
|
|
prisma = _make_prisma_with_existing(row=None)
|
|
monkeypatch.setattr(proxy_server, "prisma_client", prisma)
|
|
server = MCPServer(
|
|
server_id="saved-cimd", name="saved_cimd", transport="http", auth_type="oauth2",
|
|
client_id_metadata_document_supported=capability,
|
|
oauth2_flow="authorization_code", authorization_url="https://idp.example.com/authorize",
|
|
token_url="https://idp.example.com/token",
|
|
)
|
|
await _store_per_user_token_server_side(
|
|
server, "alice", {"access_token": "old", "refresh_token": "old-refresh", "expires_in": -10},
|
|
cimd_client_id=identity,
|
|
)
|
|
|
|
async def read_row(**kwargs):
|
|
return SimpleNamespace(credential_b64=_stored_value(prisma), user_id="alice", server_id=server.server_id)
|
|
|
|
prisma.db.litellm_mcpusercredentials.find_unique.side_effect = read_row
|
|
saved = await get_user_oauth_credential(prisma, "alice", server.server_id)
|
|
assert saved is not None and saved["cimd_client_id"] == identity
|
|
assert identity not in _stored_value(prisma)
|
|
monkeypatch.setenv("PROXY_BASE_URL", "https://renamed-gateway.example.com")
|
|
upstream = respx_mock.post("https://idp.example.com/token").respond(
|
|
200, json={"access_token": "fresh", "refresh_token": "rotated", "expires_in": 3600}
|
|
)
|
|
if owner == "native":
|
|
refreshed = await LazyPerUserOAuthTokenStore(lambda _: server).fetch("alice", server.server_id)
|
|
assert refreshed is not None and refreshed.access_token == "fresh"
|
|
assert refreshed.cimd_client_id == identity
|
|
else:
|
|
legacy = await db_module.refresh_user_oauth_token(prisma, "alice", server, saved)
|
|
assert legacy is not None and legacy["access_token"] == "fresh"
|
|
persisted = await get_user_oauth_credential(prisma, "alice", server.server_id)
|
|
assert persisted is not None
|
|
assert persisted["cimd_client_id"] == identity
|
|
assert persisted["refresh_token"] == "rotated"
|
|
assert upstream.call_count == 1
|
|
assert parse_qs(upstream.calls[0].request.content.decode())["client_id"] == [identity]
|
|
assert "client_secret" not in parse_qs(upstream.calls[0].request.content.decode())
|
|
assert server.client_id is None and server.client_id_metadata_document_supported is capability
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("provider_scope, expected", [(None, ["a"]), ("b", ["b"]), ("", [])])
|
|
async def test_silent_refresh_preserves_omitted_scope_but_accepts_explicit_grant(monkeypatch, provider_scope, expected):
|
|
import litellm.proxy.mcp_server.db as db_mod
|
|
|
|
await _run_refresh(monkeypatch, _refresh_server(), {
|
|
"access_token": "new-access",
|
|
**({"scope": provider_scope} if provider_scope is not None else {}),
|
|
})
|
|
assert db_mod.store_user_oauth_credential.await_args.kwargs["scopes"] == expected
|