litellm/tests/integration/mcp/test_mcp_oauth_flows.py
devin-ai-integration[bot] 27209b92b3
test(integration): cover mcp_runtime endpoint contracts (#44671)
* test(integration): cover mcp_runtime endpoint contracts

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* style(integration): ruff format and import order in mcp runtime tests

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): assert out-of-path calls and untrusted forwarded headers are refused

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): move the untrusted forwarded-header discovery case into its own test

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): hash the token row lookup, widen spend polling, and union open servers after listing

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): tighten mcp runtime contracts per review (sdk client, toolset scope, forwarded trust, callback html, session tools/call)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): assert the sdk unknown-path error without pinning the sdk's exception wrappers

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): cover trailing-path and group csv mcp paths, rfc 9207 iss-less and error-iss callbacks, and id_token exchange

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): cover lite logout spelling, team-mapped and unmapped IdP subjects

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): cover per-server mcp-session resource and root /token spelling

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): unpack the sealed client code in the step-up tests

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: mateo <mateo@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-14 07:00:38 +00:00

2036 lines
94 KiB
Python

import base64
import hashlib
import json
import os
import re
import secrets
import textwrap
import time
import uuid
from collections.abc import Iterator, Mapping
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Final
from urllib.parse import parse_qs, urljoin, urlsplit
import httpx
import jwt
import pytest
import yaml
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, generate_private_key
from integration._support.client import Gateway, eventually, gateway_from_environment
from integration._support.database import read_rows
from integration._support.mcp import (
ENTRY_POINTS,
INITIALIZE,
EntryPoint,
McpCaller,
McpPeer,
Outcome,
ScriptedTool,
_outcome_from_rpc,
call_tool,
mcp_peer,
register_mcp,
scripted_peer,
text_result,
tool_calls,
)
from integration._support.mcp_grants import create_toolset
from integration._support.oauth_server import AuthorizationServer, oauth_server
from integration._support.process import owned_proxy
from integration._support.wire import Reply, Request, Wire, wire_server
from pydantic import TypeAdapter
ADD: Final = {"a": 2, "b": 3}
CLIENT_REDIRECT: Final = "http://127.0.0.1:9/cb"
ACCEPT: Final = {"Accept": "application/json, text/event-stream"}
def _base(gateway: Gateway) -> str:
return str(gateway.client.base_url).rstrip("/")
def _response_object(response: httpx.Response) -> dict[str, object]:
return TypeAdapter(dict[str, object]).validate_python(response.json())
def _open_aliases() -> frozenset[str]:
rows: Final = read_rows('SELECT alias FROM "LiteLLM_MCPServerTable" WHERE allow_all_keys', ())
return frozenset(str(row["alias"]) for row in rows)
def _without_foreign_open_servers(tools: tuple[str, ...], open_aliases: frozenset[str]) -> set[str]:
prefixes: Final = tuple(f"{alias}-" for alias in open_aliases)
return {tool for tool in tools if not tool.startswith(prefixes)}
def _assert_upstream_call(call: Mapping[str, object], name: str, arguments: Mapping[str, object]) -> None:
body: Final = call["body"]
assert isinstance(body, Mapping), call
params: Final = body["params"]
assert isinstance(params, Mapping), call
assert set(params) == {"name", "arguments", "_meta"}, call
assert params["name"] == name, call
assert params["arguments"] == arguments, call
metadata: Final = params["_meta"]
assert isinstance(metadata, Mapping), call
assert set(metadata) == {"progressToken"}, call
def _authorizations(peer: McpPeer) -> tuple[bytes | None, ...]:
return tuple(
value if isinstance(value := call["headers"].get(b"authorization"), bytes) else None
for call in tool_calls(peer.drain())
if isinstance(call["headers"], dict)
)
def _issued_token(issued: dict[str, object]) -> str:
token: Final = issued["access_token"]
assert isinstance(token, str)
return token
def _register_oauth(scenario, peer: McpPeer, auth: AuthorizationServer, alias: str, **fields: object) -> str:
return register_mcp(
scenario,
peer,
alias,
issuer=auth.issuer,
authorization_url=auth.issuer + "/authorize",
token_url=auth.issuer + "/token",
registration_url=auth.issuer + "/register",
**fields,
)
def _plaintext_credential_rows(identity: str, secret: str) -> list[dict[str, object]]:
return read_rows(
'SELECT server_id FROM "LiteLLM_MCPServerTable" WHERE server_id = %s AND credentials::text LIKE %s',
(identity, f"%{secret}%"),
)
def test_client_credentials_token_is_minted_once_and_sent_as_bearer(gateway: Gateway) -> None:
with mcp_peer() as peer, oauth_server() as auth, gateway.scenario() as scenario:
alias: Final = "cc" + uuid.uuid4().hex[:8]
secret: Final = "cc-secret-" + uuid.uuid4().hex
identity: Final = _register_oauth(
scenario,
peer,
auth,
alias,
auth_type="oauth2",
oauth2_flow="client_credentials",
credentials={"client_id": "cc-client", "client_secret": secret, "scopes": ["tools.call"]},
)
key: Final = scenario.key(object_permission={"mcp_servers": [identity]})
peer.drain()
for _ in range(2):
response: Final = call_tool(gateway, key, identity, f"{alias}-add", ADD)
assert response.status_code == 200, response.text
minted: Final = auth.token_requests()
assert [request["grant_type"] for request in minted] == ["client_credentials"], minted
assert minted[0]["client_id"] == "cc-client" and minted[0]["client_secret"] == secret
assert minted[0]["scope"] == "tools.call"
seen: Final = _authorizations(peer)
assert len(seen) == 2 and len(set(seen)) == 1, seen
assert seen[0] is not None and auth.is_live(seen[0].decode().removeprefix("Bearer ")), seen
assert secret.encode() not in (seen[0] or b""), "client secret forwarded to the peer"
assert _plaintext_credential_rows(identity, secret) == []
def test_rotating_the_client_secret_forces_a_fresh_token(gateway: Gateway) -> None:
with mcp_peer() as peer, oauth_server() as auth, gateway.scenario() as scenario:
alias: Final = "cc" + uuid.uuid4().hex[:8]
identity: Final = _register_oauth(
scenario,
peer,
auth,
alias,
auth_type="oauth2",
oauth2_flow="client_credentials",
credentials={"client_id": "cc-client", "client_secret": "first-" + uuid.uuid4().hex},
)
key: Final = scenario.key(object_permission={"mcp_servers": [identity]})
assert call_tool(gateway, key, identity, f"{alias}-add", ADD).status_code == 200
before: Final = _authorizations(peer)
auth.drain()
rotated: Final = "second-" + uuid.uuid4().hex
edited: Final = gateway.request(
"PUT",
"/v1/mcp/server",
{"server_id": identity, "credentials": {"client_id": "cc-client", "client_secret": rotated}},
)
assert edited.status_code == 202, edited.text
after: Final = eventually(
lambda: (call_tool(gateway, key, identity, f"{alias}-add", ADD).status_code, _authorizations(peer)),
lambda value: value[0] == 200 and value[1] != () and value[1][-1] not in before,
)
assert [request["client_secret"] for request in auth.token_requests()][-1] == rotated
assert after[1][-1] not in before
def test_token_exchange_swaps_the_callers_subject_token_and_never_forwards_it(gateway: Gateway) -> None:
with mcp_peer() as peer, oauth_server() as auth, gateway.scenario() as scenario:
alias: Final = "te" + uuid.uuid4().hex[:8]
identity: Final = register_mcp(
scenario,
peer,
alias,
auth_type="oauth2_token_exchange",
token_exchange_endpoint=auth.issuer + "/token",
audience="urn:integration:peer",
credentials={"client_id": "te-client", "client_secret": "te-secret"},
)
key: Final = scenario.key(object_permission={"mcp_servers": [identity]})
subject: Final = "subject-" + uuid.uuid4().hex
peer.drain()
auth.drain()
response: Final = gateway.client.post(
"/mcp-rest/tools/call",
headers={"x-litellm-api-key": key, "Authorization": f"Bearer {subject}"},
json={"name": f"{alias}-add", "arguments": ADD, "server_id": identity},
)
assert response.status_code == 200, response.text
exchanged: Final = auth.token_requests()
assert len(exchanged) == 1, exchanged
assert exchanged[0]["grant_type"] == "urn:ietf:params:oauth:grant-type:token-exchange"
assert exchanged[0]["subject_token"] == subject
assert exchanged[0]["audience"] == "urn:integration:peer"
seen: Final = _authorizations(peer)
assert len(seen) == 1 and seen[0] is not None and subject.encode() not in seen[0], seen
assert seen[0].startswith(b"Bearer ") and auth.is_live(seen[0].decode().removeprefix("Bearer "))
def test_token_exchange_without_a_subject_token_is_rejected_before_any_upstream_request(gateway: Gateway) -> None:
with mcp_peer() as peer, oauth_server() as auth, gateway.scenario() as scenario:
alias: Final = "te" + uuid.uuid4().hex[:8]
identity: Final = register_mcp(
scenario,
peer,
alias,
auth_type="oauth2_token_exchange",
token_exchange_endpoint=auth.issuer + "/token",
credentials={"client_id": "te-client", "client_secret": "te-secret"},
)
key: Final = scenario.key(object_permission={"mcp_servers": [identity]})
peer.drain()
auth.drain()
cold: Final = call_tool(gateway, key, identity, f"{alias}-add", ADD)
assert tool_calls(peer.drain()) == ()
assert auth.token_requests() == ()
_assert_subject_token_challenge(cold, alias)
warmed: Final = gateway.client.post(
"/mcp-rest/tools/call",
headers={"x-litellm-api-key": key, "Authorization": "Bearer subject-" + uuid.uuid4().hex},
json={"name": f"{alias}-add", "arguments": ADD, "server_id": identity},
)
assert warmed.status_code == 200, warmed.text
assert len(tool_calls(peer.drain())) == 1 and len(auth.token_requests()) == 1
auth.drain()
warm: Final = call_tool(gateway, key, identity, f"{alias}-add", ADD)
assert tool_calls(peer.drain()) == ()
assert auth.token_requests() == ()
_assert_subject_token_challenge(warm, alias)
as_subject: Final = gateway.client.post(
"/mcp-rest/tools/call",
headers={"x-litellm-api-key": key, "Authorization": f"Bearer {key}"},
json={"name": f"{alias}-add", "arguments": ADD, "server_id": identity},
)
assert tool_calls(peer.drain()) == ()
assert auth.token_requests() == ()
_assert_subject_token_challenge(as_subject, alias)
def _assert_subject_token_challenge(response: httpx.Response, alias: str) -> None:
assert response.status_code == 401, response.text
challenge: Final = response.headers["www-authenticate"]
assert challenge.startswith("Bearer ") and 'error="invalid_token"' in challenge, challenge
assert f'resource_metadata="/.well-known/oauth-protected-resource/mcp/{alias}"' in challenge, challenge
@pytest.mark.parametrize("entry", ENTRY_POINTS)
def test_delegated_auth_forwards_the_callers_bearer_untouched(gateway: Gateway, entry: EntryPoint) -> None:
with mcp_peer() as peer, gateway.scenario() as scenario:
alias: Final = "dl" + uuid.uuid4().hex[:8]
identity: Final = register_mcp(scenario, peer, alias, auth_type="oauth_delegate")
key: Final = scenario.key(object_permission={"mcp_servers": [identity]})
token: Final = "user-" + uuid.uuid4().hex
caller: Final = McpCaller(gateway, key, entry, alias, headers={"Authorization": f"Bearer {token}"})
peer.drain()
outcome: Final = caller.call(f"{alias}-add", ADD, identity if entry in ("mcp", "root", "sse", "rest") else None)
assert outcome.ok, outcome.raw
assert _authorizations(peer) == (f"Bearer {token}".encode(),)
@dataclass(frozen=True, slots=True)
class _Pkce:
verifier: str
@property
def challenge(self) -> str:
digest: Final = hashlib.sha256(self.verifier.encode()).digest()
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
def _authorize_through_gateway(
gateway: Gateway,
auth: AuthorizationServer,
alias: str,
key: str,
client_id: str,
pkce: _Pkce,
scope: str = "tools.call",
) -> tuple[str, str]:
started: Final = gateway.client.get(
f"/{alias}/authorize",
params={
"client_id": client_id,
"redirect_uri": CLIENT_REDIRECT,
"response_type": "code",
"state": "client-state",
"code_challenge": pkce.challenge,
"code_challenge_method": "S256",
"scope": scope,
},
headers={"x-litellm-api-key": key},
)
assert started.status_code in (302, 307), started.text
upstream: Final = started.headers["location"]
assert upstream.startswith(auth.issuer + "/authorize"), upstream
upstream_query: Final = parse_qs(urlsplit(upstream).query)
assert upstream_query["code_challenge_method"] == ["S256"]
assert upstream_query["redirect_uri"] != [CLIENT_REDIRECT], "client redirect relayed upstream"
consent: Final = httpx.get(upstream, follow_redirects=False)
assert consent.status_code == 302, consent.text
callback: Final = consent.headers["location"]
assert callback.startswith(_base(gateway)), callback
returned: Final = gateway.client.get(
callback.removeprefix(_base(gateway)), headers={"x-litellm-api-key": key}, cookies=started.cookies
)
assert returned.status_code == 302, returned.text
final: Final = parse_qs(urlsplit(returned.headers["location"]).query)
assert returned.headers["location"].startswith(CLIENT_REDIRECT)
assert final["state"] == ["client-state"], final
upstream_code: Final = parse_qs(urlsplit(callback).query)["code"][0]
return final["code"][0], upstream_code
def _redeem(gateway: Gateway, alias: str, key: str, client_id: str, code: str, pkce: _Pkce) -> httpx.Response:
return gateway.client.post(
f"/{alias}/token",
headers={"x-litellm-api-key": key},
data={
"grant_type": "authorization_code",
"code": code,
"code_verifier": pkce.verifier,
"client_id": client_id,
"redirect_uri": CLIENT_REDIRECT,
},
)
def _expected_authorization_header(auth_method: str, secret: str) -> str | None:
if auth_method == "client_secret_post":
return None
return "Basic " + base64.b64encode(f"ac-client:{secret}".encode()).decode()
def _assert_upstream_token_request(
request: Request, auth_method: str, secret: str, expected_form: Mapping[str, str]
) -> None:
"""One scripted-IdP token request must carry the gateway's exact form and, for
client_secret_basic, the stored credentials in a Basic header, and never the
caller's placeholder secret."""
form: Final = {name: values[0] for name, values in parse_qs(request.body.decode()).items()}
assert form == dict(expected_form), form
assert request.headers.get("authorization") == _expected_authorization_header(auth_method, secret), request.headers
assert "dummy" not in request.body.decode() and "dummy" not in repr(request.headers), request
def _upstream_token_requests(auth: AuthorizationServer) -> tuple[Request, ...]:
return tuple(item for item in auth.drain() if item.target.startswith("/token"))
@pytest.mark.parametrize("auth_method", ("client_secret_post", "client_secret_basic"))
def test_upstream_token_requests_carry_the_gateway_callback_and_server_credentials(
gateway: Gateway, auth_method: str
) -> None:
with mcp_peer() as peer, oauth_server() as auth, gateway.scenario() as scenario:
alias: Final = "rt3" + uuid.uuid4().hex[:10]
secret: Final = "rt3-secret-" + uuid.uuid4().hex
identity: Final = _register_oauth(
scenario,
peer,
auth,
alias,
auth_type="oauth2",
oauth2_flow="authorization_code",
credentials={
"client_id": "ac-client",
"client_secret": secret,
"scopes": ["tools.call"],
"token_endpoint_auth_method": auth_method,
},
)
# A second oauth2 server keeps the root /token path honest: with two registered
# oauth2 servers the single-server root fallback cannot resolve, so the root
# spelling works only when client_id itself names the server.
_register_oauth(
scenario,
peer,
auth,
"rt3o" + uuid.uuid4().hex[:9],
auth_type="oauth2",
oauth2_flow="authorization_code",
credentials={"client_id": "ac-other", "client_secret": "other-secret", "scopes": ["tools.call"]},
)
persisted: Final = gateway.request("GET", f"/v1/mcp/server/{identity}")
assert persisted.status_code == 200, persisted.text
persisted_body: Final = _response_object(persisted)
assert persisted_body["credentials"] == {"scopes": ["tools.call"]}, persisted.text
owner: Final = scenario.key(object_permission={"mcp_servers": [identity]})
registered: Final = gateway.client.post(
f"/{alias}/register", json={"redirect_uris": [CLIENT_REDIRECT], "client_name": "integration"}
)
assert registered.status_code in (200, 201), registered.text
client_id: Final = str(_response_object(registered)["client_id"])
def expected_code_form(code: str, verifier: str) -> dict[str, str]:
return {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": f"{_base(gateway)}/callback",
"code_verifier": verifier,
**({"client_id": "ac-client", "client_secret": secret} if auth_method == "client_secret_post" else {}),
}
def expected_refresh_form(refresh_token: str) -> dict[str, str]:
return {
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"scope": "tools.call",
**({"client_id": "ac-client", "client_secret": secret} if auth_method == "client_secret_post" else {}),
}
pkce: Final = _Pkce(secrets.token_urlsafe(32))
code, upstream_code = _authorize_through_gateway(gateway, auth, alias, owner, client_id, pkce)
authorize_requests: Final = tuple(item for item in auth.drain() if item.target.startswith("/authorize"))
assert len(authorize_requests) == 1, authorize_requests
authorize_query: Final = parse_qs(urlsplit(authorize_requests[0].target).query)
assert authorize_query["redirect_uri"] == [f"{_base(gateway)}/callback"], authorize_query
redeemed: Final = gateway.client.post(
f"/{alias}/token",
headers={"x-litellm-api-key": owner},
data={
"grant_type": "authorization_code",
"code": code,
"code_verifier": pkce.verifier,
"client_id": client_id,
"client_secret": "dummy",
"redirect_uri": CLIENT_REDIRECT,
},
)
assert redeemed.status_code == 200, redeemed.text
issued: Final = _response_object(redeemed)
initial_token_requests: Final = _upstream_token_requests(auth)
assert len(initial_token_requests) == 1, initial_token_requests
_assert_upstream_token_request(
initial_token_requests[0], auth_method, secret, expected_code_form(upstream_code, pkce.verifier)
)
upstream_refresh_token: Final = str(issued["refresh_token"])
refreshed: Final = gateway.client.post(
f"/{alias}/token",
headers={"x-litellm-api-key": owner},
data={
"grant_type": "refresh_token",
"refresh_token": upstream_refresh_token,
"client_id": client_id,
"client_secret": "dummy",
"scope": "tools.call",
},
)
assert refreshed.status_code == 200, refreshed.text
assert _response_object(refreshed)["access_token"] != issued["access_token"], refreshed.text
refresh_requests: Final = _upstream_token_requests(auth)
assert len(refresh_requests) == 1, refresh_requests
_assert_upstream_token_request(
refresh_requests[0], auth_method, secret, expected_refresh_form(upstream_refresh_token)
)
pkce_root: Final = _Pkce(secrets.token_urlsafe(32))
root_code, root_upstream_code = _authorize_through_gateway(gateway, auth, alias, owner, client_id, pkce_root)
root_authorize_requests: Final = tuple(item for item in auth.drain() if item.target.startswith("/authorize"))
assert len(root_authorize_requests) == 1, root_authorize_requests
root_authorize_query: Final = parse_qs(urlsplit(root_authorize_requests[0].target).query)
assert root_authorize_query["redirect_uri"] == [f"{_base(gateway)}/callback"], root_authorize_query
root_redeemed: Final = gateway.client.post(
"/token",
headers={"x-litellm-api-key": owner},
data={
"grant_type": "authorization_code",
"code": root_code,
"code_verifier": pkce_root.verifier,
"client_id": alias,
"client_secret": "dummy",
"redirect_uri": CLIENT_REDIRECT,
},
)
assert root_redeemed.status_code == 200, root_redeemed.text
root_issued: Final = _response_object(root_redeemed)
root_token_requests: Final = _upstream_token_requests(auth)
assert len(root_token_requests) == 1, root_token_requests
_assert_upstream_token_request(
root_token_requests[0],
auth_method,
secret,
expected_code_form(root_upstream_code, pkce_root.verifier),
)
root_refresh_token: Final = str(root_issued["refresh_token"])
root_refreshed: Final = gateway.client.post(
"/token",
headers={"x-litellm-api-key": owner},
data={
"grant_type": "refresh_token",
"refresh_token": root_refresh_token,
"client_id": alias,
"client_secret": "dummy",
"scope": "tools.call",
},
)
assert root_refreshed.status_code == 200, root_refreshed.text
assert _response_object(root_refreshed)["access_token"] != root_issued["access_token"], root_refreshed.text
root_refresh_requests: Final = _upstream_token_requests(auth)
assert len(root_refresh_requests) == 1, root_refresh_requests
_assert_upstream_token_request(
root_refresh_requests[0], auth_method, secret, expected_refresh_form(root_refresh_token)
)
def test_per_user_authorization_code_with_pkce_binds_the_token_to_the_authorizing_user(gateway: Gateway) -> None:
with mcp_peer() as peer, oauth_server() as auth, gateway.scenario() as scenario:
alias: Final = "ac" + uuid.uuid4().hex[:8]
identity: Final = _register_oauth(
scenario,
peer,
auth,
alias,
auth_type="oauth2",
oauth2_flow="authorization_code",
credentials={"client_id": "ac-client", "client_secret": "ac-secret", "scopes": ["tools.call"]},
)
owner: Final = scenario.key(user_id=scenario.user(), object_permission={"mcp_servers": [identity]})
stranger: Final = scenario.key(user_id=scenario.user(), object_permission={"mcp_servers": [identity]})
anonymous: Final = gateway.client.post(
f"/{alias}/mcp", headers=ACCEPT, json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}
)
assert anonymous.status_code == 401, anonymous.text
metadata_url: Final = anonymous.headers["www-authenticate"].split('resource_metadata="')[1].rstrip('"')
metadata: Final = httpx.get(metadata_url)
assert metadata.status_code == 200 and metadata.json()["resource"] == f"{_base(gateway)}/{alias}/mcp"
registered: Final = gateway.client.post(
f"/{alias}/register", json={"redirect_uris": [CLIENT_REDIRECT], "client_name": "integration"}
)
assert registered.status_code in (200, 201), registered.text
client_id: Final = str(_response_object(registered)["client_id"])
pkce: Final = _Pkce(secrets.token_urlsafe(32))
code, _ = _authorize_through_gateway(gateway, auth, alias, owner, client_id, pkce)
wrong_verifier: Final = _redeem(gateway, alias, owner, client_id, code, _Pkce("wrong-" + pkce.verifier))
assert wrong_verifier.status_code == 400, wrong_verifier.text
assert tool_calls(peer.drain()) == ()
code2, _ = _authorize_through_gateway(gateway, auth, alias, owner, client_id, pkce)
redeemed: Final = _redeem(gateway, alias, owner, client_id, code2, pkce)
assert redeemed.status_code == 200, redeemed.text
issued: Final = redeemed.json()
assert auth.is_live(_issued_token(issued))
reused: Final = _redeem(gateway, alias, owner, client_id, code2, pkce)
assert reused.status_code == 400, reused.text
peer.drain()
as_owner: Final = call_tool(gateway, owner, identity, f"{alias}-add", ADD)
assert as_owner.status_code == 200, as_owner.text
assert _authorizations(peer) == (f"Bearer {_issued_token(issued)}".encode(),)
as_stranger: Final = call_tool(gateway, stranger, identity, f"{alias}-add", ADD)
assert as_stranger.status_code == 401, as_stranger.text
assert tool_calls(peer.drain()) == ()
upstream_only: Final = gateway.client.post(
f"/{alias}/mcp",
headers={**ACCEPT, "Authorization": f"Bearer {_issued_token(issued)}"},
json={"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}},
)
assert upstream_only.status_code == 401, upstream_only.text
assert tool_calls(peer.drain()) == ()
refreshed: Final = gateway.client.post(
f"/{alias}/token",
headers={"x-litellm-api-key": owner},
data={"grant_type": "refresh_token", "refresh_token": issued["refresh_token"], "client_id": client_id},
)
assert refreshed.status_code == 200, refreshed.text
assert refreshed.json()["access_token"] != issued["access_token"]
assert (
read_rows(
'SELECT 1 FROM "LiteLLM_MCPServerTable" WHERE server_id = %s AND credentials::text LIKE %s',
(identity, "%ac-secret%"),
)
== []
)
def test_authorization_request_without_pkce_is_refused_before_reaching_the_authorization_server(
gateway: Gateway,
) -> None:
with mcp_peer() as peer, oauth_server() as auth, gateway.scenario() as scenario:
alias: Final = "br" + uuid.uuid4().hex[:8]
identity: Final = _register_oauth(scenario, peer, auth, alias, auth_type="oauth_delegate", dcr_bridge=True)
key: Final = scenario.key(user_id=scenario.user(), object_permission={"mcp_servers": [identity]})
auth.drain()
refused: Final = gateway.client.get(
f"/{alias}/authorize",
params={"client_id": "c", "redirect_uri": CLIENT_REDIRECT, "response_type": "code", "state": "s"},
headers={"x-litellm-api-key": key},
)
assert refused.status_code == 400, refused.text
assert "PKCE" in refused.text
assert auth.drain() == ()
def test_dcr_bridge_relays_client_registration_and_advertises_gateway_endpoints(gateway: Gateway) -> None:
with mcp_peer() as peer, oauth_server() as auth, gateway.scenario() as scenario:
alias: Final = "dcr" + uuid.uuid4().hex[:8]
_register_oauth(scenario, peer, auth, alias, auth_type="oauth_delegate", dcr_bridge=True)
auth.drain()
registered: Final = gateway.client.post(
f"/{alias}/register", json={"redirect_uris": [CLIENT_REDIRECT], "client_name": "integration"}
)
assert registered.status_code in (200, 201), registered.text
assert registered.json()["client_id"].startswith("dcr-"), registered.text
assert [(request.method, urlsplit(request.target).path) for request in auth.drain()] == [("POST", "/register")]
resource: Final = gateway.client.get(f"/.well-known/oauth-protected-resource/{alias}/mcp")
assert resource.status_code == 200, resource.text
assert resource.json()["authorization_servers"] == [f"{_base(gateway)}/{alias}"]
issuer: Final = gateway.client.get(f"/.well-known/oauth-authorization-server/{alias}/mcp")
assert issuer.status_code == 200, issuer.text
assert issuer.json()["authorization_endpoint"] == f"{_base(gateway)}/{alias}/authorize"
assert issuer.json()["token_endpoint"] == f"{_base(gateway)}/{alias}/token"
assert "S256" in issuer.json()["code_challenge_methods_supported"]
def _ui_session_cookie(gateway: Gateway, user_id: str) -> dict[str, str]:
claims: Final = {"user_id": user_id, "login_method": "username_password", "exp": int(time.time()) + 600}
return {"token": jwt.encode(claims, gateway.key, algorithm="HS256")}
def _gateway_session_sign_in(
gateway: Gateway, user_id: str, resource: str | None = None
) -> tuple[dict[str, object], str]:
registered: Final = gateway.client.post(
"/register", json={"redirect_uris": [CLIENT_REDIRECT], "client_name": "integration"}
)
assert registered.status_code in (200, 201), registered.text
client_id: Final = str(_response_object(registered)["client_id"])
pkce: Final = _Pkce(secrets.token_urlsafe(48))
cookies: Final = _ui_session_cookie(gateway, user_id)
started: Final = gateway.client.get(
"/authorize",
params={
"client_id": client_id,
"redirect_uri": CLIENT_REDIRECT,
"response_type": "code",
"state": "lit6029",
"code_challenge": pkce.challenge,
"code_challenge_method": "S256",
**({} if resource is None else {"resource": resource}),
},
cookies=cookies,
)
assert started.status_code == 303, started.text
handle: Final = parse_qs(urlsplit(started.headers["location"]).query)["connect_flow"][0]
completed: Final = gateway.client.post(
"/authorize/complete", data={"flow": handle}, cookies={**cookies, **dict(started.cookies)}
)
assert completed.status_code == 303, completed.text
callback: Final = parse_qs(urlsplit(completed.headers["location"]).query)
assert "code" in callback, completed.headers["location"]
issued: Final = gateway.client.post(
"/token",
data={
"grant_type": "authorization_code",
"code": callback["code"][0],
"redirect_uri": CLIENT_REDIRECT,
"client_id": client_id,
"code_verifier": pkce.verifier,
},
)
assert issued.status_code == 200, issued.text
return _response_object(issued), client_id
def _gateway_session_bearer(gateway: Gateway, user_id: str, resource: str | None = None) -> str:
issued, _ = _gateway_session_sign_in(gateway, user_id, resource)
return _issued_token(issued)
def test_spec_client_discovers_the_aggregate_authorization_server_and_signs_in_to_a_session_bearer(
gateway: Gateway,
) -> None:
base: Final = _base(gateway)
protected: Final = gateway.client.get("/.well-known/oauth-protected-resource/mcp")
assert protected.status_code == 200, protected.text
assert _response_object(protected) == {
"authorization_servers": [f"{base}/mcp"],
"resource": f"{base}/mcp",
"scopes_supported": [],
}, protected.text
authorization_server: Final = gateway.client.get("/.well-known/oauth-authorization-server/mcp")
assert authorization_server.status_code == 200, authorization_server.text
authorization_metadata: Final = _response_object(authorization_server)
assert authorization_metadata == {
"issuer": f"{base}/mcp",
"authorization_endpoint": f"{base}/authorize/mcp-session",
"token_endpoint": f"{base}/token",
"introspection_endpoint": f"{base}/introspect",
"registration_endpoint": f"{base}/register",
"response_types_supported": ["code"],
"scopes_supported": [],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none", "client_secret_post"],
}, authorization_server.text
registration_endpoint: Final = urlsplit(str(authorization_metadata["registration_endpoint"])).path
authorization_endpoint: Final = urlsplit(str(authorization_metadata["authorization_endpoint"])).path
token_endpoint: Final = urlsplit(str(authorization_metadata["token_endpoint"])).path
with mcp_peer() as peer, gateway.scenario() as scenario:
alias: Final = "rt5" + uuid.uuid4().hex[:10]
identity: Final = register_mcp(scenario, peer, alias)
member: Final = scenario.member(scenario.team(object_permission={"mcp_servers": [identity]}))
registered: Final = gateway.client.post(
registration_endpoint, json={"redirect_uris": [CLIENT_REDIRECT], "client_name": "integration"}
)
assert registered.status_code in (200, 201), registered.text
client_id: Final = str(_response_object(registered)["client_id"])
pkce: Final = _Pkce(secrets.token_urlsafe(48))
cookies: Final = _ui_session_cookie(gateway, member)
started: Final = gateway.client.get(
authorization_endpoint,
params={
"client_id": client_id,
"redirect_uri": CLIENT_REDIRECT,
"response_type": "code",
"state": "rt5-state",
"code_challenge": pkce.challenge,
"code_challenge_method": "S256",
"resource": f"{base}/mcp",
},
cookies=cookies,
)
assert started.status_code == 303, started.text
connect_location: Final = urlsplit(started.headers["location"])
assert (
connect_location.scheme + "://" + connect_location.netloc + connect_location.path == f"{base}/ui/connect"
), started.headers["location"]
connect_flow: Final = parse_qs(connect_location.query)["connect_flow"][0]
flow_cookie: Final = f"mcp_connect_flow_{connect_flow}"
assert flow_cookie in started.cookies, started.headers
flow_cookies: Final = {**cookies, flow_cookie: started.cookies[flow_cookie]}
described: Final = gateway.client.get("/authorize/flow", params={"flow": connect_flow}, cookies=flow_cookies)
assert described.status_code == 200, described.text
assert _response_object(described) == {
"state": "unscoped",
"client_origin": "http://127.0.0.1:9",
"server_id": None,
"server_name": None,
"connected": None,
}, described.text
completed: Final = gateway.client.post("/authorize/complete", data={"flow": connect_flow}, cookies=flow_cookies)
assert completed.status_code == 303, completed.text
callback: Final = parse_qs(urlsplit(completed.headers["location"]).query)
assert callback["state"] == ["rt5-state"], completed.headers["location"]
assert set(callback) == {"code", "state"}, callback
token: Final = gateway.client.post(
token_endpoint,
data={
"grant_type": "authorization_code",
"code": callback["code"][0],
"redirect_uri": CLIENT_REDIRECT,
"client_id": client_id,
"code_verifier": pkce.verifier,
},
)
assert token.status_code == 200, token.text
token_body: Final = _response_object(token)
assert set(token_body) == {"access_token", "token_type", "expires_in", "refresh_token"}, token.text
assert token_body["token_type"] == "Bearer", token.text
assert isinstance(token_body["expires_in"], int) and token_body["expires_in"] > 0, token.text
assert str(token_body["access_token"]).startswith("llm_session_"), token.text
assert str(token_body["refresh_token"]).startswith("llm_srefresh_"), token.text
bearer: Final = str(token_body["access_token"])
initialized: Final = gateway.client.post(
"/mcp",
headers={"Authorization": f"Bearer {bearer}", **ACCEPT},
json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": dict(INITIALIZE)},
)
assert initialized.status_code == 200, initialized.text
open_before: Final = _open_aliases()
listed_response: Final = gateway.client.post(
"/mcp",
headers={"Authorization": f"Bearer {bearer}", **ACCEPT},
json={"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}},
)
assert listed_response.status_code == 200, listed_response.text
listed: Final = _outcome_from_rpc(listed_response)
assert listed.ok, listed.raw
open_after: Final = open_before | _open_aliases()
assert _without_foreign_open_servers(listed.tools, open_after) == {
f"{alias}-add",
f"{alias}-multiply",
f"{alias}-fail",
}, listed.tools
peer.drain()
called_response: Final = gateway.client.post(
"/mcp",
headers={"Authorization": f"Bearer {bearer}", **ACCEPT},
json={
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {"name": f"{alias}-add", "arguments": {"a": 20, "b": 22}},
},
)
assert called_response.status_code == 200, called_response.text
called: Final = _outcome_from_rpc(called_response)
assert called.ok and called.text == "42", called.raw
calls: Final = tool_calls(peer.drain())
assert len(calls) == 1, calls
_assert_upstream_call(calls[0], "add", {"a": 20, "b": 22})
def test_per_server_resource_scopes_the_session_bearer_to_the_named_server(gateway: Gateway) -> None:
base: Final = _base(gateway)
with mcp_peer() as peer_a, mcp_peer() as peer_b, gateway.scenario() as scenario:
alias_a: Final = "sca" + uuid.uuid4().hex[:10]
alias_b: Final = "scb" + uuid.uuid4().hex[:10]
server_a: Final = register_mcp(scenario, peer_a, alias_a)
server_b: Final = register_mcp(scenario, peer_b, alias_b)
member: Final = scenario.member(scenario.team(object_permission={"mcp_servers": [server_a, server_b]}))
registered: Final = gateway.client.post(
"/register", json={"redirect_uris": [CLIENT_REDIRECT], "client_name": "integration"}
)
assert registered.status_code in (200, 201), registered.text
client_id: Final = str(_response_object(registered)["client_id"])
pkce: Final = _Pkce(secrets.token_urlsafe(48))
cookies: Final = _ui_session_cookie(gateway, member)
started: Final = gateway.client.get(
"/authorize/mcp-session",
params={
"client_id": client_id,
"redirect_uri": CLIENT_REDIRECT,
"response_type": "code",
"state": "sca-state",
"code_challenge": pkce.challenge,
"code_challenge_method": "S256",
"resource": f"{base}/mcp/{alias_a}",
},
cookies=cookies,
)
assert started.status_code == 303, started.text
connect_location: Final = urlsplit(started.headers["location"])
assert (
connect_location.scheme + "://" + connect_location.netloc + connect_location.path == f"{base}/ui/connect"
), started.headers["location"]
connect_flow: Final = parse_qs(connect_location.query)["connect_flow"][0]
flow_cookie: Final = f"mcp_connect_flow_{connect_flow}"
assert flow_cookie in started.cookies, started.headers
flow_cookies: Final = {**cookies, flow_cookie: started.cookies[flow_cookie]}
described: Final = gateway.client.get("/authorize/flow", params={"flow": connect_flow}, cookies=flow_cookies)
assert described.status_code == 200, described.text
assert _response_object(described) == {
"state": "m2m",
"client_origin": "http://127.0.0.1:9",
"server_id": server_a,
"server_name": alias_a,
"connected": True,
}, described.text
completed: Final = gateway.client.post("/authorize/complete", data={"flow": connect_flow}, cookies=flow_cookies)
assert completed.status_code == 303, completed.text
callback: Final = parse_qs(urlsplit(completed.headers["location"]).query)
assert callback["state"] == ["sca-state"], completed.headers["location"]
assert set(callback) == {"code", "state"}, callback
token: Final = gateway.client.post(
"/token",
data={
"grant_type": "authorization_code",
"code": callback["code"][0],
"redirect_uri": CLIENT_REDIRECT,
"client_id": client_id,
"code_verifier": pkce.verifier,
},
)
assert token.status_code == 200, token.text
token_body: Final = _response_object(token)
assert set(token_body) == {"access_token", "token_type", "expires_in", "refresh_token"}, token.text
bearer: Final = str(token_body["access_token"])
assert bearer.startswith("llm_session_"), token.text
initialized: Final = gateway.client.post(
"/mcp",
headers={"Authorization": f"Bearer {bearer}", **ACCEPT},
json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": dict(INITIALIZE)},
)
assert initialized.status_code == 200, initialized.text
open_before: Final = _open_aliases()
listed_response: Final = gateway.client.post(
"/mcp",
headers={"Authorization": f"Bearer {bearer}", **ACCEPT},
json={"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}},
)
assert listed_response.status_code == 200, listed_response.text
listed: Final = _outcome_from_rpc(listed_response)
assert listed.ok, listed.raw
open_after: Final = open_before | _open_aliases()
assert _without_foreign_open_servers(listed.tools, open_after) == {
f"{alias_a}-add",
f"{alias_a}-multiply",
f"{alias_a}-fail",
}, listed.tools
peer_a.drain()
peer_b.drain()
called_response: Final = gateway.client.post(
"/mcp",
headers={"Authorization": f"Bearer {bearer}", **ACCEPT},
json={
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {"name": f"{alias_a}-add", "arguments": {"a": 20, "b": 22}},
},
)
assert called_response.status_code == 200, called_response.text
called: Final = _outcome_from_rpc(called_response)
assert called.ok and called.text == "42", called.raw
calls: Final = tool_calls(peer_a.drain())
assert len(calls) == 1, calls
_assert_upstream_call(calls[0], "add", {"a": 20, "b": 22})
assert tool_calls(peer_b.drain()) == ()
refused_response: Final = gateway.client.post(
"/mcp",
headers={"Authorization": f"Bearer {bearer}", **ACCEPT},
json={
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {"name": f"{alias_b}-add", "arguments": ADD},
},
)
refused: Final = _outcome_from_rpc(refused_response)
assert not refused.ok, refused.raw
assert tool_calls(peer_b.drain()) == ()
def test_rotated_session_refresh_token_cannot_be_replayed_after_revocation(gateway: Gateway) -> None:
with mcp_peer() as peer, gateway.scenario() as scenario:
alias: Final = "rt6" + uuid.uuid4().hex[:10]
identity: Final = register_mcp(scenario, peer, alias)
member: Final = scenario.member(scenario.team(object_permission={"mcp_servers": [identity]}))
issued, client_id = _gateway_session_sign_in(gateway, member)
r1: Final = str(issued["refresh_token"])
a1: Final = str(issued["access_token"])
assert r1.startswith("llm_srefresh_"), r1
assert a1.startswith("llm_session_"), a1
rotated: Final = gateway.client.post(
"/token",
data={"grant_type": "refresh_token", "refresh_token": r1, "client_id": client_id},
)
assert rotated.status_code == 200, rotated.text
rotated_body: Final = _response_object(rotated)
assert set(rotated_body) == {"access_token", "token_type", "expires_in", "refresh_token"}, rotated.text
r2: Final = str(rotated_body["refresh_token"])
a2: Final = str(rotated_body["access_token"])
assert r2 != r1 and r2.startswith("llm_srefresh_"), rotated.text
assert a2 != a1 and a2.startswith("llm_session_"), rotated.text
replayed: Final = gateway.client.post(
"/token",
data={"grant_type": "refresh_token", "refresh_token": r1, "client_id": client_id},
)
assert replayed.status_code == 400, replayed.text
assert _response_object(replayed) == {
"error": "invalid_grant",
"error_description": "the refresh token was already used",
}, replayed.text
revoked: Final = gateway.client.post("/revoke", data={"token": r2, "client_id": client_id})
assert revoked.status_code == 200, revoked.text
assert _response_object(revoked) == {}, revoked.text
revoked_again: Final = gateway.client.post("/revoke", data={"token": r2, "client_id": client_id})
assert revoked_again.status_code == 200, revoked_again.text
assert _response_object(revoked_again) == {}, revoked_again.text
after_revoke: Final = gateway.client.post(
"/token",
data={"grant_type": "refresh_token", "refresh_token": r2, "client_id": client_id},
)
assert after_revoke.status_code == 400, after_revoke.text
assert _response_object(after_revoke) == {
"error": "invalid_grant",
"error_description": "the refresh token was revoked",
}, after_revoke.text
unknown_client: Final = gateway.client.post(
"/revoke", data={"token": r2, "client_id": "dcr-unknown-" + uuid.uuid4().hex}
)
assert unknown_client.status_code == 401, unknown_client.text
assert _response_object(unknown_client) == {
"error": "invalid_client",
"error_description": "unknown or malformed client_id",
}, unknown_client.text
open_before: Final = _open_aliases()
listed_response: Final = gateway.client.post(
"/mcp",
headers={"Authorization": f"Bearer {a2}", **ACCEPT},
json={"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}},
)
assert listed_response.status_code == 200, listed_response.text
listed: Final = _outcome_from_rpc(listed_response)
assert listed.ok, listed.raw
open_after: Final = open_before | _open_aliases()
assert _without_foreign_open_servers(listed.tools, open_after) == {
f"{alias}-add",
f"{alias}-multiply",
f"{alias}-fail",
}, listed.tools
def _consent_form_fields(page: str) -> dict[str, str]:
return dict(re.findall(r'<input type="hidden" name="([^"]+)" value="([^"]*)">', page))
def test_lite_logout_revokes_the_cli_refresh_token_sent_the_way_the_cli_sends_it(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
model: Final = scenario.model()
team_id: Final = scenario.team()
member: Final = scenario.member(team_id)
discovered: Final = gateway.client.get("/.well-known/litellm-cli-auth")
assert discovered.status_code == 200, discovered.text
contract: Final = _response_object(discovered)
base: Final = _base(gateway)
assert (contract["resource"], contract["token_endpoint"], contract["revocation_endpoint"]) == (
base,
f"{base}/token",
f"{base}/revoke",
), discovered.text
resource: Final = str(contract["resource"])
registered: Final = gateway.client.post(
str(contract["registration_endpoint"]),
json={
"client_name": "LiteLLM CLI",
"redirect_uris": [CLIENT_REDIRECT],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
},
)
assert registered.status_code in (200, 201), registered.text
client_id: Final = str(_response_object(registered)["client_id"])
pkce: Final = _Pkce(secrets.token_urlsafe(64))
cookies: Final = _ui_session_cookie(gateway, member)
consent: Final = gateway.client.get(
str(contract["authorization_endpoint"]),
params={
"response_type": "code",
"client_id": client_id,
"redirect_uri": CLIENT_REDIRECT,
"state": "lite-login-state",
"code_challenge": pkce.challenge,
"code_challenge_method": "S256",
"resource": resource,
},
cookies=cookies,
)
assert consent.status_code == 200, consent.text
consent_fields: Final = _consent_form_fields(consent.text)
assert set(consent_fields) == {"flow", "team_id"} and consent_fields["team_id"] == team_id, consent.text
approved: Final = gateway.client.post(
"/authorize/complete",
data={**consent_fields, "decision": "approve"},
cookies={**cookies, **dict(consent.cookies)},
)
assert approved.status_code == 303, approved.text
callback: Final = parse_qs(urlsplit(approved.headers["location"]).query)
assert callback["state"] == ["lite-login-state"], approved.headers["location"]
issued: Final = gateway.client.post(
str(contract["token_endpoint"]),
data={
"grant_type": "authorization_code",
"code": callback["code"][0],
"redirect_uri": CLIENT_REDIRECT,
"client_id": client_id,
"code_verifier": pkce.verifier,
"resource": resource,
},
)
assert issued.status_code == 200, issued.text
issued_body: Final = _response_object(issued)
assert (issued_body["user_id"], issued_body["team_id"]) == (member, team_id), issued.text
r1: Final = str(issued_body["refresh_token"])
assert r1.startswith("llm_srefresh_"), issued.text
def refresh(refresh_token: str) -> httpx.Response:
return gateway.client.post(
str(contract["token_endpoint"]),
data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": client_id,
"resource": resource,
},
)
def revoke(refresh_token: str) -> httpx.Response:
return gateway.client.post(
str(contract["revocation_endpoint"]),
data={"token": refresh_token, "token_type_hint": "refresh_token", "client_id": client_id},
)
rotated: Final = refresh(r1)
assert rotated.status_code == 200, rotated.text
rotated_body: Final = _response_object(rotated)
r2: Final = str(rotated_body["refresh_token"])
assert r2 != r1 and r2.startswith("llm_srefresh_"), rotated.text
assert (rotated_body["user_id"], rotated_body["team_id"]) == (member, team_id), rotated.text
replayed: Final = refresh(r1)
assert replayed.status_code == 400, replayed.text
assert _response_object(replayed) == {
"error": "invalid_grant",
"error_description": "the refresh token was already used",
}, replayed.text
logged_out: Final = revoke(r2)
assert logged_out.status_code == 200, logged_out.text
assert _response_object(logged_out) == {}, logged_out.text
logged_out_again: Final = revoke(r2)
assert logged_out_again.status_code == 200, logged_out_again.text
assert _response_object(logged_out_again) == {}, logged_out_again.text
after_logout: Final = refresh(r2)
assert after_logout.status_code == 400, after_logout.text
assert _response_object(after_logout) == {
"error": "invalid_grant",
"error_description": "the refresh token was revoked",
}, after_logout.text
completion: Final = gateway.request(
"POST",
"/v1/chat/completions",
{"model": model, "messages": [{"role": "user", "content": "lite logout access token"}]},
key=str(rotated_body["access_token"]),
)
assert completion.status_code == 200, completion.text
def _toolset_rpc(gateway: Gateway, bearer: str, name: str, method: str, params: dict[str, object]) -> Outcome:
def post(rpc_method: str, rpc_params: dict[str, object]) -> httpx.Response:
return gateway.client.post(
f"/toolset/{name}/mcp",
json={"jsonrpc": "2.0", "id": 1, "method": rpc_method, "params": rpc_params},
headers={"Authorization": f"Bearer {bearer}", "Accept": "application/json, text/event-stream"},
)
initialized: Final = _outcome_from_rpc(post("initialize", dict(INITIALIZE)))
if not initialized.ok:
return initialized
return _outcome_from_rpc(post(method, params))
def test_gateway_session_bearer_of_a_team_member_is_served_the_team_toolset_on_its_route(gateway: Gateway) -> None:
with mcp_peer() as peer, gateway.scenario() as scenario:
alias: Final = "lit6029sess" + uuid.uuid4().hex[:6]
server_id: Final = register_mcp(scenario, peer, alias)
granted_name: Final = "lit6029g" + uuid.uuid4().hex[:8]
withheld_name: Final = "lit6029w" + uuid.uuid4().hex[:8]
granted_id: Final = create_toolset(scenario, ((server_id, "add"),), toolset_name=granted_name)
create_toolset(scenario, ((server_id, "multiply"),), toolset_name=withheld_name)
member: Final = scenario.member(scenario.team(object_permission={"mcp_toolsets": [granted_id]}))
bearer: Final = _gateway_session_bearer(gateway, member)
assert bearer.startswith("llm_session_"), bearer[:16]
listed: Final = _toolset_rpc(gateway, bearer, granted_name, "tools/list", {})
assert listed.tools == (f"{alias}-add",), listed.raw
peer.drain()
called: Final = _toolset_rpc(
gateway, bearer, granted_name, "tools/call", {"name": f"{alias}-add", "arguments": {"a": 4, "b": 5}}
)
assert called.ok and called.text == "9", called.raw
assert len(tool_calls(peer.drain())) == 1
denied: Final = _toolset_rpc(gateway, bearer, withheld_name, "tools/list", {})
assert denied.status == 403, denied.raw
assert tool_calls(peer.drain()) == ()
def test_resource_scoped_session_bearer_opens_a_team_toolset_inside_its_server_and_none_outside(
gateway: Gateway,
) -> None:
with mcp_peer() as peer, gateway.scenario() as scenario:
inside: Final = "lit6029in" + uuid.uuid4().hex[:6]
outside: Final = "lit6029out" + uuid.uuid4().hex[:6]
inside_server: Final = register_mcp(scenario, peer, inside)
outside_server: Final = register_mcp(scenario, peer, outside)
inside_name: Final = "lit6029i" + uuid.uuid4().hex[:8]
outside_name: Final = "lit6029o" + uuid.uuid4().hex[:8]
inside_id: Final = create_toolset(scenario, ((inside_server, "add"),), toolset_name=inside_name)
outside_id: Final = create_toolset(scenario, ((outside_server, "add"),), toolset_name=outside_name)
member: Final = scenario.member(scenario.team(object_permission={"mcp_toolsets": [inside_id, outside_id]}))
bearer: Final = _gateway_session_bearer(gateway, member, resource=f"{_base(gateway)}/{inside}/mcp")
assert bearer.startswith("llm_session_"), bearer[:16]
listed: Final = _toolset_rpc(gateway, bearer, inside_name, "tools/list", {})
assert listed.tools == (f"{inside}-add",), listed.raw
peer.drain()
called: Final = _toolset_rpc(
gateway, bearer, inside_name, "tools/call", {"name": f"{inside}-add", "arguments": {"a": 4, "b": 5}}
)
assert called.ok and called.text == "9", called.raw
assert len(tool_calls(peer.drain())) == 1
refused: Final = _toolset_rpc(gateway, bearer, outside_name, "tools/list", {})
assert refused.status == 403, refused.raw
assert tool_calls(peer.drain()) == ()
_PROBE: Final = "catalog-probe"
_ECHO: Final = "catalog-echo"
_UNLISTED: Final = ""
_GUARDRAIL_CODE: Final = (
"def apply_guardrail(inputs, request_data, input_type):\n"
f' if "{_PROBE}" not in list(inputs.get("texts") or []):\n'
" return allow()\n"
' function = inputs.get("tools", [{}])[0].get("function", {})\n'
f' return block("{_ECHO}[" + function.get("description") + "]")\n'
)
_ECHO_GUARDRAIL_YAML: Final = (
"guardrails:\n"
" - guardrail_name: catalog-echo\n"
" litellm_params:\n"
" guardrail: custom_code\n"
" mode: pre_mcp_call\n"
" default_on: true\n"
" custom_code: |\n" + textwrap.indent(_GUARDRAIL_CODE, 8 * " ")
)
@pytest.fixture(scope="module")
def echo_rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Gateway]:
directory: Final = tmp_path_factory.mktemp("catalog-echo")
path: Final = directory / "catalog_echo.yaml"
path.write_text((Path(__file__).resolve().parents[1] / "proxy_config.yaml").read_text() + _ECHO_GUARDRAIL_YAML)
with gateway_from_environment() as gateway, owned_proxy(gateway, directory, {}, config=path, workers=2) as rig:
yield rig
def _echoed_description(outcome: Outcome) -> str:
found: Final = re.search(rf"{_ECHO}\[(.*?)\]", outcome.raw)
assert found is not None, outcome.raw
return found.group(1)
def test_token_exchange_callers_with_different_subject_tokens_own_separate_listings(echo_rig: Gateway) -> None:
with mcp_peer() as peer, oauth_server() as auth, echo_rig.scenario() as scenario:
alias: Final = "te" + uuid.uuid4().hex[:8]
identity: Final = register_mcp(
scenario,
peer,
alias,
auth_type="oauth2_token_exchange",
token_exchange_endpoint=auth.issuer + "/token",
credentials={"client_id": "te-client", "client_secret": "te-secret"},
)
key: Final = scenario.key(object_permission={"mcp_servers": [identity]})
first_subject: Final = "subject-" + uuid.uuid4().hex
first: Final = McpCaller(echo_rig, key, "mcp", alias, {"Authorization": f"Bearer {first_subject}"})
second: Final = McpCaller(echo_rig, key, "mcp", alias, {"Authorization": "Bearer subject-" + uuid.uuid4().hex})
auth.drain()
assert first.list_tools().ok
assert [request["subject_token"] for request in auth.token_requests()] == [first_subject]
probe: Final = {"probe": _PROBE}
own: Final = _echoed_description(first.call(f"{alias}-add", probe))
other: Final = _echoed_description(second.call(f"{alias}-add", probe))
assert (own, other) == ("Add two integers", _UNLISTED), (
"the caller bearer is part of the identity on a token-exchange server: one subject, one slot"
)
assert second.list_tools().ok
assert _echoed_description(second.call(f"{alias}-add", probe)) == "Add two integers"
assert tool_calls(peer.drain()) == (), "a blocked probe reached the peer"
@dataclass(frozen=True, slots=True)
class _IdpRig:
gateway: Gateway
private_key: RSAPrivateKey
other_private_key: RSAPrivateKey
kid: str
dead_jwks: Wire
def _proxy_config_with_general_settings(directory: Path, name: str, overrides: Mapping[str, object]) -> Path:
raw_config: Final[object] = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
config: Final = TypeAdapter(dict[str, object]).validate_python(raw_config)
general_settings: Final = TypeAdapter(dict[str, object]).validate_python(config["general_settings"])
merged: Final = {**config, "general_settings": {**general_settings, **overrides}}
config_path: Final = directory / name
config_path.write_text(yaml.safe_dump(merged))
return config_path
def _idp_proxy_config(directory: Path, team_id_jwt_field: str | None = None) -> Path:
return _proxy_config_with_general_settings(
directory,
"mcp_idp.yaml",
{
"enable_jwt_auth": True,
"litellm_jwtauth": {
"user_id_jwt_field": "sub",
"user_id_upsert": True,
**({} if team_id_jwt_field is None else {"team_id_jwt_field": team_id_jwt_field}),
},
"use_x_forwarded_for": True,
"mcp_trusted_proxy_ranges": ["127.0.0.1/32"],
},
)
def _signed_subject_token(private_key: RSAPrivateKey, claims: Mapping[str, str], kid: str) -> str:
now: Final = int(time.time())
return jwt.encode({**claims, "iat": now, "exp": now + 300}, private_key, algorithm="RS256", headers={"kid": kid})
def _token_exchange_form(client_id: str, subject_token: str) -> dict[str, str]:
return {
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"client_id": client_id,
"subject_token": subject_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
}
def _registered_dcr_client(gateway: Gateway) -> str:
registered: Final = gateway.client.post(
"/register", json={"redirect_uris": [CLIENT_REDIRECT], "client_name": "integration"}
)
assert registered.status_code in (200, 201), registered.text
return str(_response_object(registered)["client_id"])
@contextmanager
def _idp_gateway(directory: Path, team_id_jwt_field: str | None, workers: int) -> Iterator[_IdpRig]:
assert os.environ.get("LITELLM_LICENSE"), (
"enable_jwt_auth is enterprise-only: these tests need LITELLM_LICENSE (CI forwards it); "
"without it /token answers 400 'JWT auth is an enterprise only feature; no license is set' "
"and the failure reads as a regression instead of a missing license"
)
private_key: Final = generate_private_key(public_exponent=65537, key_size=2048)
other_private_key: Final = generate_private_key(public_exponent=65537, key_size=2048)
kid: Final = "kid-a"
public_jwk: Final = TypeAdapter(dict[str, object]).validate_python(
json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(private_key.public_key()))
)
def respond_good(request: Request) -> Reply:
assert request.method == "GET", request
return Reply(body=json.dumps({"keys": [{**public_jwk, "kid": kid}]}).encode())
def respond_dead(request: Request) -> Reply:
assert request.method == "GET", request
return Reply(drop_connection=True)
config_path: Final = _idp_proxy_config(directory, team_id_jwt_field)
with (
wire_server(respond_good) as good_jwks,
wire_server(respond_dead) as dead_jwks,
gateway_from_environment() as gateway,
owned_proxy(
gateway,
directory,
{"JWT_PUBLIC_KEY_URL": f"{good_jwks.url},{dead_jwks.url}"},
config=config_path,
remove_environment=("PROXY_BASE_URL",),
workers=workers,
) as candidate,
):
yield _IdpRig(candidate, private_key, other_private_key, kid, dead_jwks)
@pytest.fixture(scope="module")
def idp_rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[_IdpRig]:
with _idp_gateway(tmp_path_factory.mktemp("mcp-idp"), None, workers=2) as rig:
yield rig
@pytest.fixture(scope="module")
def idp_team_rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[_IdpRig]:
with _idp_gateway(tmp_path_factory.mktemp("mcp-idp-team"), "team_id", workers=1) as rig:
yield rig
@pytest.fixture(scope="module")
def forwarded_gateway(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Gateway]:
directory: Final = tmp_path_factory.mktemp("mcp-forwarded")
config: Final = _proxy_config_with_general_settings(
directory,
"mcp_forwarded.yaml",
{"use_x_forwarded_for": True, "mcp_trusted_proxy_ranges": ["127.0.0.1/32"]},
)
with (
gateway_from_environment() as gateway,
owned_proxy(
gateway,
directory,
{"FORWARDED_ALLOW_IPS": "192.0.2.1"},
config=config,
remove_environment=("PROXY_BASE_URL",),
workers=1,
) as candidate,
):
yield candidate
@pytest.fixture(
scope="module",
params=(
{},
{"use_x_forwarded_for": True, "mcp_trusted_proxy_ranges": ["192.0.2.0/24"]},
),
ids=("xff-off", "outside-trusted-range"),
)
def untrusted_gateway(request: pytest.FixtureRequest, tmp_path_factory: pytest.TempPathFactory) -> Iterator[Gateway]:
directory: Final = tmp_path_factory.mktemp("mcp-untrusted-forwarded")
config: Final = _proxy_config_with_general_settings(
directory,
"mcp_untrusted.yaml",
TypeAdapter(dict[str, object]).validate_python(request.param),
)
with (
gateway_from_environment() as gateway,
owned_proxy(
gateway,
directory,
{"FORWARDED_ALLOW_IPS": "192.0.2.1"},
config=config,
remove_environment=("PROXY_BASE_URL",),
workers=1,
) as candidate,
):
yield candidate
def test_idp_subject_token_exchange_mints_a_credential_for_the_mapped_user_and_refuses_bad_subjects(
idp_rig: _IdpRig,
) -> None:
registered: Final = idp_rig.gateway.client.post(
"/register", json={"redirect_uris": [CLIENT_REDIRECT], "client_name": "integration"}
)
assert registered.status_code in (200, 201), registered.text
client_id: Final = str(_response_object(registered)["client_id"])
subject: Final = "mcp-idp-user-" + uuid.uuid4().hex
def token(private_key: RSAPrivateKey, user_id: str, kid: str) -> str:
now: Final = int(time.time())
return jwt.encode(
{"sub": user_id, "iat": now, "exp": now + 300},
private_key,
algorithm="RS256",
headers={"kid": kid},
)
valid_subject: Final = token(idp_rig.private_key, subject, idp_rig.kid)
exchange_form: Final = {
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"client_id": client_id,
"subject_token": valid_subject,
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
}
id_token_exchange: Final = idp_rig.gateway.client.post(
"/token",
data={
**exchange_form,
"subject_token_type": "urn:ietf:params:oauth:token-type:id_token",
},
)
assert id_token_exchange.status_code == 200, id_token_exchange.text
id_token_exchange_body: Final = _response_object(id_token_exchange)
assert set(id_token_exchange_body) == {
"access_token",
"token_type",
"expires_in",
"refresh_token",
"user_id",
"team_id",
"issued_token_type",
}, id_token_exchange.text
assert id_token_exchange_body["token_type"] == "Bearer", id_token_exchange.text
assert id_token_exchange_body["issued_token_type"] == "urn:ietf:params:oauth:token-type:access_token", (
id_token_exchange.text
)
assert id_token_exchange_body["user_id"] == subject, id_token_exchange.text
assert id_token_exchange_body["team_id"] is None, id_token_exchange.text
assert isinstance(id_token_exchange_body["expires_in"], int) and id_token_exchange_body["expires_in"] > 0, (
id_token_exchange.text
)
assert str(id_token_exchange_body["refresh_token"]).startswith("llm_srefresh_"), id_token_exchange.text
exchanged: Final = idp_rig.gateway.client.post("/token", data=exchange_form)
assert exchanged.status_code == 200, exchanged.text
outage_form: Final = {
**exchange_form,
"subject_token": token(idp_rig.other_private_key, subject, "kid-b"),
}
unavailable: Final = idp_rig.gateway.client.post("/token", data=outage_form)
dead_requests: Final = idp_rig.dead_jwks.drain()
assert unavailable.status_code == 503, unavailable.text
assert _response_object(unavailable) == {
"error": "temporarily_unavailable",
"error_description": (
"the gateway could not verify subject_token because its identity provider or database is unavailable; retry"
),
}, unavailable.text
assert tuple(request.method for request in dead_requests) == ("GET", "GET", "GET"), dead_requests
exchanged_body: Final = _response_object(exchanged)
assert set(exchanged_body) == {
"access_token",
"token_type",
"expires_in",
"refresh_token",
"user_id",
"team_id",
"issued_token_type",
}, exchanged.text
assert exchanged_body["token_type"] == "Bearer", exchanged.text
assert exchanged_body["issued_token_type"] == "urn:ietf:params:oauth:token-type:access_token", exchanged.text
assert exchanged_body["user_id"] == subject, exchanged.text
assert exchanged_body["team_id"] is None, exchanged.text
assert isinstance(exchanged_body["expires_in"], int) and exchanged_body["expires_in"] > 0, exchanged.text
assert str(exchanged_body["refresh_token"]).startswith("llm_srefresh_"), exchanged.text
access_token: Final = str(exchanged_body["access_token"])
id_token_access_token: Final = str(id_token_exchange_body["access_token"])
with idp_rig.gateway.scenario() as scenario:
model: Final = scenario.model()
completion: Final = idp_rig.gateway.request(
"POST",
"/v1/chat/completions",
{"model": model, "messages": [{"role": "user", "content": "mcp idp spend"}]},
key=access_token,
)
assert completion.status_code == 200, completion.text
request_id: Final = str(_response_object(completion)["id"])
id_token_completion: Final = idp_rig.gateway.request(
"POST",
"/v1/chat/completions",
{"model": model, "messages": [{"role": "user", "content": "mcp idp id token spend"}]},
key=id_token_access_token,
)
assert id_token_completion.status_code == 200, id_token_completion.text
id_token_request_id: Final = str(_response_object(id_token_completion)["id"])
request_ids: Final = tuple(sorted((request_id, id_token_request_id)))
spend: Final = eventually(
lambda: read_rows(
"SELECT request_id, metadata->>'user_api_key_user_id' AS user_api_key_user_id "
'FROM "LiteLLM_SpendLogs" WHERE request_id IN (%s, %s) ORDER BY request_id',
request_ids,
),
lambda rows: len(rows) == 2,
seconds=70,
)
assert spend == [
{"request_id": request_ids[0], "user_api_key_user_id": subject},
{"request_id": request_ids[1], "user_api_key_user_id": subject},
], spend
user: Final = eventually(
lambda: read_rows('SELECT user_id, user_role, teams FROM "LiteLLM_UserTable" WHERE user_id = %s', (subject,)),
lambda rows: len(rows) == 1,
seconds=70,
)
assert user == [{"user_id": subject, "user_role": None, "teams": []}], user
access_token_hash: Final = hashlib.sha256(access_token.encode()).hexdigest()
verification_tokens: Final = read_rows(
'SELECT token, user_id FROM "LiteLLM_VerificationToken" WHERE token = %s',
(access_token_hash,),
)
assert verification_tokens == [], verification_tokens
missing_subject: Final = idp_rig.gateway.client.post(
"/token",
data={key: value for key, value in exchange_form.items() if key != "subject_token"},
)
assert missing_subject.status_code == 400, missing_subject.text
assert _response_object(missing_subject) == {
"error": "invalid_request",
"error_description": "subject_token and subject_token_type are required",
}, missing_subject.text
rejected_subject: Final = idp_rig.gateway.client.post(
"/token", data={**exchange_form, "subject_token": token(idp_rig.other_private_key, subject, idp_rig.kid)}
)
assert rejected_subject.status_code == 400, rejected_subject.text
assert _response_object(rejected_subject) == {
"error": "invalid_request",
"error_description": "subject_token was rejected by the gateway's JWT auth",
}, rejected_subject.text
non_jwt: Final = idp_rig.gateway.client.post("/token", data={**exchange_form, "subject_token": "not-a-jwt"})
assert non_jwt.status_code == 400, non_jwt.text
assert _response_object(non_jwt) == {
"error": "invalid_request",
"error_description": "subject_token is not a JWT",
}, non_jwt.text
def test_idp_subject_token_exchange_binds_the_team_claim_and_refuses_a_signed_subject_with_no_user(
idp_team_rig: _IdpRig,
) -> None:
client_id: Final = _registered_dcr_client(idp_team_rig.gateway)
subject: Final = "mcp-idp-team-user-" + uuid.uuid4().hex
with idp_team_rig.gateway.scenario() as scenario:
model: Final = scenario.model()
team_id: Final = scenario.team()
exchanged: Final = idp_team_rig.gateway.client.post(
"/token",
data=_token_exchange_form(
client_id,
_signed_subject_token(idp_team_rig.private_key, {"sub": subject, "team_id": team_id}, idp_team_rig.kid),
),
)
assert exchanged.status_code == 200, exchanged.text
body: Final = _response_object(exchanged)
assert (body["user_id"], body["team_id"], body["issued_token_type"]) == (
subject,
team_id,
"urn:ietf:params:oauth:token-type:access_token",
), exchanged.text
completion: Final = idp_team_rig.gateway.request(
"POST",
"/v1/chat/completions",
{"model": model, "messages": [{"role": "user", "content": "mcp idp team spend"}]},
key=str(body["access_token"]),
)
assert completion.status_code == 200, completion.text
request_id: Final = str(_response_object(completion)["id"])
spend: Final = eventually(
lambda: read_rows(
"SELECT team_id, metadata->>'user_api_key_user_id' AS user_api_key_user_id, "
"metadata->>'user_api_key_team_id' AS user_api_key_team_id "
'FROM "LiteLLM_SpendLogs" WHERE request_id = %s',
(request_id,),
),
lambda rows: len(rows) == 1,
seconds=70,
)
assert spend == [{"team_id": team_id, "user_api_key_user_id": subject, "user_api_key_team_id": team_id}], spend
no_user: Final = idp_team_rig.gateway.client.post(
"/token",
data=_token_exchange_form(
client_id, _signed_subject_token(idp_team_rig.private_key, {"team_id": team_id}, idp_team_rig.kid)
),
)
assert no_user.status_code == 400, no_user.text
assert _response_object(no_user) == {
"error": "invalid_request",
"error_description": "subject_token names no user the gateway knows",
}, no_user.text
def test_idp_jwks_server_error_answers_temporarily_unavailable(tmp_path: Path) -> None:
pytest.skip(
"BUG: an IdP JWKS answering HTTP 503 makes the gateway /token token-exchange answer 400 invalid_request "
"(subject_token rejected) instead of 503 temporarily_unavailable"
)
assert os.environ.get("LITELLM_LICENSE"), (
"enable_jwt_auth is enterprise-only: these tests need LITELLM_LICENSE (CI forwards it); "
"without it /token answers 400 'JWT auth is an enterprise only feature; no license is set' "
"and the failure reads as a regression instead of a missing license"
)
private_key: Final = generate_private_key(public_exponent=65537, key_size=2048)
kid: Final = "mcp-runtime-503"
def respond(request: Request) -> Reply:
assert request.method == "GET", request
return Reply(status=503, body=b'{"error":"jwks_down"}')
config: Final = _idp_proxy_config(tmp_path)
with (
wire_server(respond) as jwks,
gateway_from_environment() as gateway,
owned_proxy(
gateway,
tmp_path,
{"JWT_PUBLIC_KEY_URL": jwks.url},
config=config,
remove_environment=("PROXY_BASE_URL",),
workers=2,
) as idp_gateway,
):
registered: Final = idp_gateway.client.post(
"/register", json={"redirect_uris": [CLIENT_REDIRECT], "client_name": "integration"}
)
assert registered.status_code in (200, 201), registered.text
client_id: Final = str(_response_object(registered)["client_id"])
now: Final = int(time.time())
subject_token: Final = jwt.encode(
{"sub": "mcp-idp-http-503-" + uuid.uuid4().hex, "iat": now, "exp": now + 300},
private_key,
algorithm="RS256",
headers={"kid": kid},
)
exchange_form: Final = {
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"client_id": client_id,
"subject_token": subject_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
}
response: Final = idp_gateway.client.post("/token", data=exchange_form)
jwks_requests: Final = jwks.drain()
assert response.status_code == 503, (
f"request={exchange_form!r}, jwks_requests={jwks_requests!r}, "
f"response_status={response.status_code}, response={response.text}"
)
assert _response_object(response) == {
"error": "temporarily_unavailable",
"error_description": (
"the gateway could not verify subject_token because its identity provider or database is unavailable; "
"retry"
),
}, response.text
def test_standard_pattern_discovery_names_the_per_server_issuer_and_follows_forwarded_headers(
gateway: Gateway, forwarded_gateway: Gateway
) -> None:
with mcp_peer() as peer, gateway.scenario() as scenario:
alias: Final = "rt8" + uuid.uuid4().hex[:10]
register_mcp(
scenario,
peer,
alias,
auth_type="oauth2",
oauth2_flow="authorization_code",
credentials={
"client_id": "rt8-client",
"client_secret": "rt8-secret",
"scopes": ["tools.call"],
},
)
base: Final = _base(gateway)
protected: Final = gateway.client.get(f"/.well-known/oauth-protected-resource/mcp/{alias}")
assert protected.status_code == 200, protected.text
aggregate_issuer: Final = f"{base}/mcp"
expected_protected: Final = {
"authorization_servers": [aggregate_issuer],
"resource": f"{base}/mcp/{alias}",
"scopes_supported": ["tools.call"],
}
assert _response_object(protected) == expected_protected, protected.text
aggregate_authorization: Final = gateway.client.get("/.well-known/oauth-authorization-server/mcp")
assert aggregate_authorization.status_code == 200, aggregate_authorization.text
assert _response_object(aggregate_authorization)["issuer"] == aggregate_issuer, aggregate_authorization.text
authorization: Final = gateway.client.get(f"/.well-known/oauth-authorization-server/mcp/{alias}")
assert authorization.status_code == 200, authorization.text
expected_authorization: Final = {
"issuer": f"{base}/mcp/{alias}",
"authorization_endpoint": f"{base}/{alias}/authorize",
"token_endpoint": f"{base}/{alias}/token",
"response_types_supported": ["code"],
"scopes_supported": ["tools.call"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["client_secret_post"],
"registration_endpoint": f"{base}/{alias}/register",
}
assert _response_object(authorization) == expected_authorization, authorization.text
legacy_authorization: Final = gateway.client.get(f"/.well-known/oauth-authorization-server/{alias}")
assert legacy_authorization.status_code == 200, legacy_authorization.text
assert _response_object(legacy_authorization) == {**expected_authorization, "issuer": f"{base}/{alias}"}, (
legacy_authorization.text
)
legacy_protected: Final = gateway.client.get(f"/.well-known/oauth-protected-resource/{alias}/mcp")
assert legacy_protected.status_code == 200, legacy_protected.text
assert _response_object(legacy_protected) == {
"authorization_servers": [f"{base}/mcp"],
"resource": f"{base}/{alias}/mcp",
"scopes_supported": ["tools.call"],
}, legacy_protected.text
root_protected: Final = gateway.client.get("/.well-known/oauth-protected-resource")
assert root_protected.status_code == 200, root_protected.text
assert _response_object(root_protected) == {
"authorization_servers": [f"{base}/mcp"],
"resource": base,
"scopes_supported": [],
}, root_protected.text
anonymous: Final = gateway.client.post(
f"/mcp/{alias}",
headers=ACCEPT,
json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": dict(INITIALIZE)},
)
assert anonymous.status_code == 401, anonymous.text
challenge: Final = anonymous.headers["www-authenticate"]
metadata_ref: Final = re.search(r'resource_metadata="([^"]+)"', challenge)
assert metadata_ref is not None, challenge
metadata: Final = gateway.client.get(urljoin(base + "/", metadata_ref.group(1)))
assert metadata.status_code == 200, metadata.text
assert _response_object(metadata) == expected_protected, metadata.text
with mcp_peer() as peer, forwarded_gateway.scenario() as scenario:
alias: Final = "rt8f" + uuid.uuid4().hex[:9]
register_mcp(
scenario,
peer,
alias,
auth_type="oauth2",
oauth2_flow="authorization_code",
credentials={"client_id": "rt8f-client", "client_secret": "rt8f-secret"},
)
forwarded_base: Final = "https://gw.example.test"
forwarded_headers: Final = {
"X-Forwarded-Proto": "https",
"X-Forwarded-Host": "gw.example.test",
}
forwarded_prm: Final = forwarded_gateway.client.get(
f"/.well-known/oauth-protected-resource/mcp/{alias}", headers=forwarded_headers
)
assert forwarded_prm.status_code == 200, forwarded_prm.text
assert _response_object(forwarded_prm) == {
"authorization_servers": [f"{forwarded_base}/mcp"],
"resource": f"{forwarded_base}/mcp/{alias}",
"scopes_supported": [],
}, forwarded_prm.text
forwarded_as: Final = forwarded_gateway.client.get(
f"/.well-known/oauth-authorization-server/mcp/{alias}", headers=forwarded_headers
)
assert forwarded_as.status_code == 200, forwarded_as.text
assert _response_object(forwarded_as) == {
"issuer": f"{forwarded_base}/mcp/{alias}",
"authorization_endpoint": f"{forwarded_base}/{alias}/authorize",
"token_endpoint": f"{forwarded_base}/{alias}/token",
"response_types_supported": ["code"],
"scopes_supported": [],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["client_secret_post"],
"registration_endpoint": f"{forwarded_base}/{alias}/register",
}, forwarded_as.text
local_base: Final = _base(forwarded_gateway)
local_as: Final = forwarded_gateway.client.get(f"/.well-known/oauth-authorization-server/mcp/{alias}")
assert local_as.status_code == 200, local_as.text
assert _response_object(local_as) == {
"issuer": f"{local_base}/mcp/{alias}",
"authorization_endpoint": f"{local_base}/{alias}/authorize",
"token_endpoint": f"{local_base}/{alias}/token",
"response_types_supported": ["code"],
"scopes_supported": [],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["client_secret_post"],
"registration_endpoint": f"{local_base}/{alias}/register",
}, local_as.text
def test_standard_pattern_discovery_ignores_forwarded_headers_from_an_untrusted_peer(
untrusted_gateway: Gateway,
) -> None:
with mcp_peer() as peer, untrusted_gateway.scenario() as scenario:
alias: Final = "rt8u" + uuid.uuid4().hex[:9]
register_mcp(
scenario,
peer,
alias,
auth_type="oauth2",
oauth2_flow="authorization_code",
credentials={
"client_id": "rt8u-client",
"client_secret": "rt8u-secret",
"scopes": ["tools.call"],
},
)
base: Final = _base(untrusted_gateway)
untrusted_headers: Final = {
"X-Forwarded-Proto": "https",
"X-Forwarded-Host": "evil.example",
}
authorization: Final = untrusted_gateway.client.get(
f"/.well-known/oauth-authorization-server/mcp/{alias}", headers=untrusted_headers
)
assert authorization.status_code == 200, authorization.text
assert _response_object(authorization) == {
"issuer": f"{base}/mcp/{alias}",
"authorization_endpoint": f"{base}/{alias}/authorize",
"token_endpoint": f"{base}/{alias}/token",
"response_types_supported": ["code"],
"scopes_supported": ["tools.call"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["client_secret_post"],
"registration_endpoint": f"{base}/{alias}/register",
}, authorization.text
assert "evil.example" not in authorization.text, authorization.text
protected: Final = untrusted_gateway.client.get(
f"/.well-known/oauth-protected-resource/mcp/{alias}", headers=untrusted_headers
)
assert protected.status_code == 200, protected.text
assert _response_object(protected) == {
"authorization_servers": [f"{base}/mcp"],
"resource": f"{base}/mcp/{alias}",
"scopes_supported": ["tools.call"],
}, protected.text
assert "evil.example" not in protected.text, protected.text
@contextmanager
def _scope_enforcing_peer(auth: AuthorizationServer) -> Iterator[tuple[McpPeer, Wire]]:
with scripted_peer(
ScriptedTool("read_op", lambda _: text_result("read completed")),
ScriptedTool("write_op", lambda _: text_result("write completed")),
ScriptedTool("forbid_op", lambda _: text_result("must not execute")),
) as inner:
def enforce(request: Request) -> Reply:
body: Final = json.loads(request.body) if request.body else {}
if body.get("method") == "tools/call":
name: Final = body["params"]["name"]
if name == "forbid_op":
return Reply(status=403, body=b"denied")
required: Final = "tools.write" if name == "write_op" else "tools.read"
token: Final = request.headers.get("authorization", "").removeprefix("Bearer ")
with auth.lock:
granted: Final = auth.access_tokens.get(token, {}).get("scope", "")
if required not in granted.split():
return Reply(
status=403,
headers={"WWW-Authenticate": f'Bearer error="insufficient_scope", scope="{required}"'},
)
forwarded: Final = httpx.request(
request.method, inner.url, content=request.body, headers=dict(request.headers)
)
return Reply(
status=forwarded.status_code,
body=forwarded.content,
content_type=forwarded.headers.get("content-type", "application/json"),
)
with wire_server(enforce) as wire:
yield McpPeer(wire.url + "/mcp", inner.calls), wire
@pytest.mark.parametrize("entry", ["rest", "server_mcp"])
def test_managed_scope_step_up_preserves_read_and_write_without_retry(gateway: Gateway, entry: EntryPoint) -> None:
with (
oauth_server(scopes=("tools.read", "tools.write")) as auth,
_scope_enforcing_peer(auth) as (peer, wire),
gateway.scenario() as scenario,
):
alias: Final = "scope" + uuid.uuid4().hex[:8]
identity: Final = _register_oauth(
scenario,
peer,
auth,
alias,
auth_type="oauth2",
oauth2_flow="authorization_code",
credentials={"client_id": "scope-client", "client_secret": "scope-secret", "scopes": ["tools.read"]},
)
owner: Final = scenario.key(user_id=scenario.user(), object_permission={"mcp_servers": [identity]})
stranger: Final = scenario.key(user_id=scenario.user(), object_permission={"mcp_servers": [identity]})
pkce: Final = _Pkce(secrets.token_urlsafe(32))
code, _ = _authorize_through_gateway(gateway, auth, alias, owner, "scope-client", pkce, "tools.read")
first: Final = _redeem(gateway, alias, owner, "scope-client", code, pkce)
assert first.status_code == 200, first.text
caller: Final = McpCaller(gateway, owner, entry, alias)
read: Final = caller.call(f"{alias}-read_op", {}, identity)
assert read.ok and read.text == "read completed", read.raw
peer.drain()
wire.drain()
denied: Final = (
call_tool(gateway, owner, identity, f"{alias}-write_op", {})
if entry == "rest"
else caller.rpc("tools/call", {"name": f"{alias}-write_op", "arguments": {}})
)
assert denied.status_code == 403, denied.text
challenge: Final = denied.headers["www-authenticate"]
assert 'error="insufficient_scope"' in challenge and 'scope="tools.write"' in challenge
assert f'resource_metadata="{_base(gateway)}/.well-known/oauth-protected-resource/' in challenge
attempts: Final = tuple(
request
for request in wire.drain()
if request.body and json.loads(request.body).get("method") == "tools/call"
)
assert len(attempts) == 1 and tool_calls(peer.drain()) == (), "denied operation was retried or executed"
second_code, _ = _authorize_through_gateway(gateway, auth, alias, owner, "scope-client", pkce, "tools.write")
second: Final = _redeem(gateway, alias, owner, "scope-client", second_code, pkce)
assert second.status_code == 200, second.text
with auth.lock:
granted: Final = auth.access_tokens[_issued_token(second.json())]["scope"]
assert granted.split() == ["tools.read", "tools.write"]
for operation in ("read", "write"):
result: Final = caller.call(f"{alias}-{operation}_op", {}, identity)
assert result.ok and result.text == f"{operation} completed", result.raw
forbidden: Final = caller.call(f"{alias}-forbid_op", {}, identity)
assert forbidden.status == 200 and not forbidden.ok, forbidden.raw
unauthorized: Final = McpCaller(gateway, stranger, entry, alias).call(f"{alias}-read_op", {}, identity)
assert unauthorized.status == 401, unauthorized.raw
@pytest.mark.parametrize("route", ["/mcp", "/toolset/{toolset}/mcp"])
def test_managed_step_up_advertises_sdk_compatible_metadata_and_bound_permissions(gateway: Gateway, route: str) -> None:
from mcp.shared.auth_utils import check_resource_allowed
with (
oauth_server(scopes=("tools.read", "tools.write")) as auth,
_scope_enforcing_peer(auth) as (peer, wire),
gateway.scenario() as scenario,
):
alias: Final = "scope" + uuid.uuid4().hex[:8]
identity: Final = _register_oauth(
scenario,
peer,
auth,
alias,
auth_type="oauth2",
oauth2_flow="authorization_code",
credentials={"client_id": "scope-client", "client_secret": "scope-secret", "scopes": ["tools.read"]},
)
toolset_name: Final = "scope" + uuid.uuid4().hex[:8]
toolset_id: Final = create_toolset(
scenario, ((identity, "read_op"), (identity, "write_op")), toolset_name=toolset_name
)
owner: Final = scenario.key(
user_id=scenario.user(), object_permission={"mcp_servers": [identity], "mcp_toolsets": [toolset_id]}
)
pkce: Final = _Pkce(secrets.token_urlsafe(32))
code, _ = _authorize_through_gateway(gateway, auth, alias, owner, "scope-client", pkce, "tools.read")
first: Final = _redeem(gateway, alias, owner, "scope-client", code, pkce)
assert first.status_code == 200, first.text
path: Final = route.format(toolset=toolset_name)
headers: Final = {**ACCEPT, "Authorization": f"Bearer {owner}"}
initialized: Final = gateway.client.post(
path, headers=headers, json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": dict(INITIALIZE)}
)
assert initialized.status_code == 200, initialized.text
peer.drain()
wire.drain()
response: Final = gateway.client.post(
path,
headers=headers,
json={
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {"name": f"{alias}-write_op", "arguments": {}},
},
)
assert response.status_code == 403, response.text
challenge: Final = response.headers["www-authenticate"]
metadata_url: Final = challenge.split('resource_metadata="', 1)[1].split('"', 1)[0]
metadata: Final = gateway.client.get(metadata_url)
assert metadata.status_code == 200, metadata.text
assert check_resource_allowed(
requested_resource=_base(gateway) + path, configured_resource=metadata.json()["resource"]
)
encoded_scope: Final = challenge.split('scope="', 1)[1].split('"', 1)[0]
assert encoded_scope == f"litellm:mcp_scope:{identity.encode().hex()}:{b'tools.write'.hex()}"
assert tool_calls(peer.drain()) == (), "denied operation executed upstream"
attempts: Final = tuple(
request
for request in wire.drain()
if request.body and json.loads(request.body).get("method") == "tools/call"
)
assert len(attempts) == 1, "denied operation was retried"