mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-29 01:42:19 +00:00
feat(mcp): add dedicated oauth_passthrough flag for non-oauth2 pass-through
Previously is_oauth_passthrough reused delegate_auth_to_upstream — a flag scoped to oauth2 servers (PKCE bypass) — to gate OAuth pass-through for auth_type=none servers. Overloading it risked regressing existing deployments that set delegate_auth_to_upstream, since the same flag would silently start driving pass-through (discovery proxying, 401 challenges, upstream 401/403 propagation) on non-oauth2 servers. Introduce a separate oauth_passthrough opt-in so the two behaviors never imply each other: - MCPServer.is_oauth_passthrough now requires oauth_passthrough (not delegate_auth_to_upstream). - Persist oauth_passthrough on LiteLLM_MCPServerTable (new column + migration) and wire it through config/DB load and API responses. - UI splits the single toggle into two: "Delegate auth to upstream (PKCE passthrough)" for oauth2 and "OAuth pass-through" for auth_type=none servers forwarding Authorization. Adds backend tests (property, round-trip, and a regression guard that delegate_auth_to_upstream alone never enables pass-through) and UI tests for the toggle split.
This commit is contained in:
parent
ccea19d66f
commit
172c11b352
20 changed files with 312 additions and 85 deletions
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN IF NOT EXISTS "oauth_passthrough" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
|
@ -325,6 +325,7 @@ model LiteLLM_MCPServerTable {
|
|||
allow_all_keys Boolean @default(false)
|
||||
available_on_public_internet Boolean @default(true)
|
||||
delegate_auth_to_upstream Boolean @default(false)
|
||||
oauth_passthrough Boolean @default(false)
|
||||
is_byok Boolean @default(false)
|
||||
byok_description String[] @default([])
|
||||
byok_api_key_help_url String?
|
||||
|
|
|
|||
|
|
@ -581,6 +581,7 @@ class MCPServerManager:
|
|||
delegate_auth_to_upstream=bool(
|
||||
server_config.get("delegate_auth_to_upstream", False)
|
||||
),
|
||||
oauth_passthrough=bool(server_config.get("oauth_passthrough", False)),
|
||||
# AWS SigV4 fields
|
||||
aws_access_key_id=server_config.get("aws_access_key_id", None),
|
||||
aws_secret_access_key=server_config.get("aws_secret_access_key", None),
|
||||
|
|
@ -979,6 +980,7 @@ class MCPServerManager:
|
|||
delegate_auth_to_upstream=bool(
|
||||
getattr(mcp_server, "delegate_auth_to_upstream", False)
|
||||
),
|
||||
oauth_passthrough=bool(getattr(mcp_server, "oauth_passthrough", False)),
|
||||
created_at=getattr(mcp_server, "created_at", None),
|
||||
updated_at=getattr(mcp_server, "updated_at", None),
|
||||
tool_name_to_display_name=_deserialize_json_dict(
|
||||
|
|
@ -3901,6 +3903,7 @@ class MCPServerManager:
|
|||
allow_all_keys=server.allow_all_keys,
|
||||
available_on_public_internet=server.available_on_public_internet,
|
||||
delegate_auth_to_upstream=server.delegate_auth_to_upstream,
|
||||
oauth_passthrough=getattr(server, "oauth_passthrough", False),
|
||||
is_byok=server.is_byok,
|
||||
byok_description=server.byok_description,
|
||||
byok_api_key_help_url=server.byok_api_key_help_url,
|
||||
|
|
|
|||
|
|
@ -1282,6 +1282,7 @@ class NewMCPServerRequest(LiteLLMPydanticObjectBase):
|
|||
allow_all_keys: bool = False
|
||||
available_on_public_internet: bool = True
|
||||
delegate_auth_to_upstream: bool = False
|
||||
oauth_passthrough: bool = False
|
||||
is_byok: bool = False
|
||||
byok_description: List[str] = Field(default_factory=list)
|
||||
byok_api_key_help_url: Optional[str] = None
|
||||
|
|
@ -1365,6 +1366,7 @@ class UpdateMCPServerRequest(LiteLLMPydanticObjectBase):
|
|||
allow_all_keys: bool = False
|
||||
available_on_public_internet: bool = True
|
||||
delegate_auth_to_upstream: bool = False
|
||||
oauth_passthrough: bool = False
|
||||
is_byok: bool = False
|
||||
byok_description: List[str] = Field(default_factory=list)
|
||||
byok_api_key_help_url: Optional[str] = None
|
||||
|
|
@ -1437,6 +1439,7 @@ class LiteLLM_MCPServerTable(LiteLLMPydanticObjectBase):
|
|||
allow_all_keys: bool = False
|
||||
available_on_public_internet: bool = True
|
||||
delegate_auth_to_upstream: bool = False
|
||||
oauth_passthrough: bool = False
|
||||
is_byok: bool = False
|
||||
byok_description: List[str] = Field(default_factory=list)
|
||||
byok_api_key_help_url: Optional[str] = None
|
||||
|
|
|
|||
|
|
@ -325,6 +325,7 @@ model LiteLLM_MCPServerTable {
|
|||
allow_all_keys Boolean @default(false)
|
||||
available_on_public_internet Boolean @default(true)
|
||||
delegate_auth_to_upstream Boolean @default(false)
|
||||
oauth_passthrough Boolean @default(false)
|
||||
is_byok Boolean @default(false)
|
||||
byok_description String[] @default([])
|
||||
byok_api_key_help_url String?
|
||||
|
|
|
|||
|
|
@ -68,24 +68,29 @@ class MCPServer(BaseModel):
|
|||
access_groups: Optional[List[str]] = None
|
||||
allow_all_keys: bool = False
|
||||
available_on_public_internet: bool = True
|
||||
# Explicit opt-in to upstream-delegated authentication. Two distinct modes
|
||||
# depending on ``auth_type``:
|
||||
# Explicit opt-in to upstream-delegated authentication for ``oauth2``
|
||||
# servers. When ``auth_type == oauth2`` and this is ``True``, MCP requests
|
||||
# bypass LiteLLM API-key/SSO auth (and the pre-emptive 401) so the client
|
||||
# completes PKCE directly with the upstream MCP server. See
|
||||
# ``MCPRequestHandler._target_servers_delegate_auth_to_upstream``.
|
||||
#
|
||||
# * ``auth_type == oauth2``: MCP requests bypass LiteLLM API-key/SSO
|
||||
# auth (and the pre-emptive 401) so the client completes PKCE
|
||||
# directly with the upstream MCP server. See
|
||||
# ``MCPRequestHandler._target_servers_delegate_auth_to_upstream``.
|
||||
# * ``auth_type in (None, MCPAuth.none)`` AND ``extra_headers`` contains
|
||||
# ``Authorization``: enables OAuth pass-through (see
|
||||
# ``is_oauth_passthrough``). The gateway proxies upstream
|
||||
# ``/.well-known/oauth-protected-resource`` metadata, emits
|
||||
# spec-compliant 401 challenges when no bearer is supplied, and
|
||||
# propagates upstream 401/403 responses instead of swallowing them.
|
||||
#
|
||||
# Ignored for any other ``auth_type``. The flag must be set explicitly to
|
||||
# avoid silently changing behavior for servers that forward
|
||||
# ``Authorization`` for non-OAuth reasons (e.g. static bearer tokens).
|
||||
# Honored only for ``auth_type == oauth2``; ignored for any other
|
||||
# ``auth_type``. OAuth pass-through for non-oauth2 servers
|
||||
# (``auth_type in (None, MCPAuth.none)``) is a separate, explicit opt-in —
|
||||
# see ``oauth_passthrough`` / ``is_oauth_passthrough``.
|
||||
delegate_auth_to_upstream: bool = False
|
||||
# Explicit opt-in to OAuth pass-through for non-oauth2 servers. When this
|
||||
# is ``True`` AND ``auth_type in (None, MCPAuth.none)`` AND ``extra_headers``
|
||||
# contains ``Authorization``, the gateway proxies upstream
|
||||
# ``/.well-known/oauth-protected-resource`` metadata, emits spec-compliant
|
||||
# 401 challenges when no bearer is supplied, and propagates upstream
|
||||
# 401/403 responses instead of swallowing them. See ``is_oauth_passthrough``.
|
||||
#
|
||||
# Intentionally distinct from ``delegate_auth_to_upstream`` (oauth2-only):
|
||||
# reusing that flag would silently change behavior for servers that forward
|
||||
# ``Authorization`` for non-OAuth reasons (e.g. static bearer tokens). Must
|
||||
# be set explicitly to avoid regressing servers that did not opt in.
|
||||
oauth_passthrough: bool = False
|
||||
is_byok: bool = False
|
||||
byok_description: List[str] = []
|
||||
byok_api_key_help_url: Optional[str] = None
|
||||
|
|
@ -163,12 +168,15 @@ class MCPServer(BaseModel):
|
|||
2. ``extra_headers`` includes ``Authorization`` — the admin has
|
||||
opted this server into forwarding the client's bearer token
|
||||
straight to the upstream MCP server.
|
||||
3. ``delegate_auth_to_upstream`` is ``True`` — the admin has
|
||||
3. ``oauth_passthrough`` is ``True`` — the admin has
|
||||
explicitly opted into upstream-delegated OAuth semantics for
|
||||
this server. This is the explicit detection flag: without it,
|
||||
a server that merely forwards ``Authorization`` (e.g. for
|
||||
static bearer tokens or custom auth schemes) keeps the
|
||||
pre-PR behavior and is not treated as OAuth pass-through.
|
||||
This is deliberately a separate flag from
|
||||
``delegate_auth_to_upstream`` (which is oauth2-only) so enabling
|
||||
pass-through here never changes behavior for oauth2 servers.
|
||||
|
||||
This is intentionally narrower than ``requires_per_user_auth``,
|
||||
which also covers PATs (``x-api-key``, ``api-key``, ``apikey``).
|
||||
|
|
@ -179,7 +187,7 @@ class MCPServer(BaseModel):
|
|||
return False
|
||||
if not self.extra_headers:
|
||||
return False
|
||||
if self.delegate_auth_to_upstream is not True:
|
||||
if self.oauth_passthrough is not True:
|
||||
return False
|
||||
return any(h.lower() == "authorization" for h in self.extra_headers)
|
||||
|
||||
|
|
|
|||
|
|
@ -325,6 +325,7 @@ model LiteLLM_MCPServerTable {
|
|||
allow_all_keys Boolean @default(false)
|
||||
available_on_public_internet Boolean @default(true)
|
||||
delegate_auth_to_upstream Boolean @default(false)
|
||||
oauth_passthrough Boolean @default(false)
|
||||
is_byok Boolean @default(false)
|
||||
byok_description String[] @default([])
|
||||
byok_api_key_help_url String?
|
||||
|
|
|
|||
|
|
@ -876,7 +876,7 @@ class TestMCPPublicRouteGuard:
|
|||
with patch(
|
||||
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth",
|
||||
) as mock_auth:
|
||||
(auth_result, *_rest) = await MCPRequestHandler.process_mcp_request(scope)
|
||||
auth_result, *_rest = await MCPRequestHandler.process_mcp_request(scope)
|
||||
mock_auth.assert_not_called()
|
||||
assert isinstance(auth_result, UserAPIKeyAuth)
|
||||
|
||||
|
|
@ -1114,7 +1114,7 @@ class TestMCPPassthroughColdStartAdmission:
|
|||
mock_mgr.get_mcp_server_by_name.return_value = (
|
||||
TestMCPPassthroughColdStartAdmission._make_passthrough_server()
|
||||
)
|
||||
(auth_result, *_rest) = await MCPRequestHandler.process_mcp_request(scope)
|
||||
auth_result, *_rest = await MCPRequestHandler.process_mcp_request(scope)
|
||||
|
||||
assert isinstance(auth_result, UserAPIKeyAuth)
|
||||
mock_mgr.get_mcp_server_by_name.assert_any_call(
|
||||
|
|
@ -1151,7 +1151,7 @@ class TestMCPPassthroughColdStartAdmission:
|
|||
mock_mgr.get_mcp_server_by_name.return_value = (
|
||||
TestMCPPassthroughColdStartAdmission._make_passthrough_server()
|
||||
)
|
||||
(auth_result, *_rest) = await MCPRequestHandler.process_mcp_request(scope)
|
||||
auth_result, *_rest = await MCPRequestHandler.process_mcp_request(scope)
|
||||
|
||||
assert isinstance(auth_result, UserAPIKeyAuth)
|
||||
mock_mgr.get_mcp_server_by_name.assert_any_call(
|
||||
|
|
@ -1276,7 +1276,7 @@ class TestMCPOAuth2FallbackTargetGating:
|
|||
mock_mgr.get_mcp_server_by_name.return_value = (
|
||||
TestMCPOAuth2FallbackTargetGating._make_server(MCPAuth.oauth2)
|
||||
)
|
||||
(auth_result, *_rest) = await MCPRequestHandler.process_mcp_request(scope)
|
||||
auth_result, *_rest = await MCPRequestHandler.process_mcp_request(scope)
|
||||
assert isinstance(auth_result, UserAPIKeyAuth)
|
||||
|
||||
async def test_fallback_allowed_when_target_is_passthrough(self):
|
||||
|
|
@ -1317,7 +1317,7 @@ class TestMCPOAuth2FallbackTargetGating:
|
|||
is_oauth_passthrough=True,
|
||||
)
|
||||
)
|
||||
(auth_result, *_rest) = await MCPRequestHandler.process_mcp_request(scope)
|
||||
auth_result, *_rest = await MCPRequestHandler.process_mcp_request(scope)
|
||||
assert isinstance(auth_result, UserAPIKeyAuth)
|
||||
assert auth_result.api_key is None
|
||||
|
||||
|
|
@ -1489,6 +1489,39 @@ class TestMCPDelegateAuthToUpstream:
|
|||
is False
|
||||
)
|
||||
|
||||
def test_build_mcp_server_table_preserves_oauth_passthrough(self):
|
||||
"""Registry → API list rows must expose oauth_passthrough for the UI.
|
||||
|
||||
``oauth_passthrough`` is the dedicated non-oauth2 pass-through opt-in,
|
||||
distinct from ``delegate_auth_to_upstream`` (oauth2-only). Both must
|
||||
round-trip independently so neither flag silently implies the other.
|
||||
"""
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
|
||||
MCPServerManager,
|
||||
)
|
||||
from litellm.types.mcp import MCPAuth
|
||||
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
||||
|
||||
manager = MCPServerManager()
|
||||
passthrough = MCPServer(
|
||||
server_id="passthrough-1",
|
||||
name="passthrough",
|
||||
transport="http",
|
||||
auth_type=MCPAuth.none,
|
||||
extra_headers=["Authorization"],
|
||||
oauth_passthrough=True,
|
||||
available_on_public_internet=True,
|
||||
)
|
||||
row = manager._build_mcp_server_table(passthrough)
|
||||
assert row.oauth_passthrough is True
|
||||
# The oauth2-only flag must remain independent and default off.
|
||||
assert row.delegate_auth_to_upstream is False
|
||||
|
||||
not_passthrough = passthrough.model_copy(update={"oauth_passthrough": False})
|
||||
assert (
|
||||
manager._build_mcp_server_table(not_passthrough).oauth_passthrough is False
|
||||
)
|
||||
|
||||
async def test_delegate_skips_litellm_auth_with_no_authorization(self):
|
||||
"""
|
||||
oauth2 + delegate_auth_to_upstream=True, no Authorization header at
|
||||
|
|
@ -1518,7 +1551,7 @@ class TestMCPDelegateAuthToUpstream:
|
|||
delegate_auth_to_upstream=True,
|
||||
)
|
||||
)
|
||||
(auth_result, *_rest) = await MCPRequestHandler.process_mcp_request(scope)
|
||||
auth_result, *_rest = await MCPRequestHandler.process_mcp_request(scope)
|
||||
assert isinstance(auth_result, UserAPIKeyAuth)
|
||||
mock_auth.assert_not_called()
|
||||
|
||||
|
|
@ -1761,7 +1794,7 @@ class TestMCPDelegateAuthToUpstream:
|
|||
delegate_auth_to_upstream=True,
|
||||
)
|
||||
)
|
||||
(auth_result, *_rest) = await MCPRequestHandler.process_mcp_request(scope)
|
||||
auth_result, *_rest = await MCPRequestHandler.process_mcp_request(scope)
|
||||
assert isinstance(auth_result, UserAPIKeyAuth)
|
||||
assert auth_result.user_id == "real-user"
|
||||
mock_auth.assert_called_once()
|
||||
|
|
@ -1798,7 +1831,7 @@ class TestMCPDelegateAuthToUpstream:
|
|||
delegate_auth_to_upstream=True,
|
||||
)
|
||||
)
|
||||
(auth_result, *_rest) = await MCPRequestHandler.process_mcp_request(scope)
|
||||
auth_result, *_rest = await MCPRequestHandler.process_mcp_request(scope)
|
||||
assert isinstance(auth_result, UserAPIKeyAuth)
|
||||
assert auth_result.user_id == "real-user"
|
||||
mock_auth.assert_called_once()
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ def test_is_oauth_passthrough_true_when_none_auth_and_authorization_header():
|
|||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.none,
|
||||
extra_headers=["Authorization"],
|
||||
delegate_auth_to_upstream=True,
|
||||
oauth_passthrough=True,
|
||||
)
|
||||
assert server.is_oauth_passthrough is True
|
||||
|
||||
|
|
@ -82,7 +82,7 @@ def test_is_oauth_passthrough_true_when_auth_type_none_and_mixed_case_header():
|
|||
transport=MCPTransport.http,
|
||||
auth_type=None,
|
||||
extra_headers=["authorization", "x-request-id"],
|
||||
delegate_auth_to_upstream=True,
|
||||
oauth_passthrough=True,
|
||||
)
|
||||
assert server.is_oauth_passthrough is True
|
||||
|
||||
|
|
@ -94,7 +94,7 @@ def test_is_oauth_passthrough_false_for_oauth2_server():
|
|||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.oauth2,
|
||||
extra_headers=["Authorization"],
|
||||
delegate_auth_to_upstream=True,
|
||||
oauth_passthrough=True,
|
||||
)
|
||||
assert server.is_oauth_passthrough is False
|
||||
|
||||
|
|
@ -106,7 +106,7 @@ def test_is_oauth_passthrough_false_without_authorization_header():
|
|||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.none,
|
||||
extra_headers=["x-api-key"],
|
||||
delegate_auth_to_upstream=True,
|
||||
oauth_passthrough=True,
|
||||
)
|
||||
assert server.is_oauth_passthrough is False
|
||||
|
||||
|
|
@ -117,12 +117,12 @@ def test_is_oauth_passthrough_false_without_extra_headers():
|
|||
name="s1",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.none,
|
||||
delegate_auth_to_upstream=True,
|
||||
oauth_passthrough=True,
|
||||
)
|
||||
assert server.is_oauth_passthrough is False
|
||||
|
||||
|
||||
def test_is_oauth_passthrough_false_without_delegate_flag():
|
||||
def test_is_oauth_passthrough_false_without_oauth_passthrough_flag():
|
||||
"""The detection flag must be set explicitly. Without it, the legacy
|
||||
behavior is preserved for servers that forward Authorization for
|
||||
non-OAuth reasons (static bearer tokens, custom auth schemes)."""
|
||||
|
|
@ -132,19 +132,38 @@ def test_is_oauth_passthrough_false_without_delegate_flag():
|
|||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.none,
|
||||
extra_headers=["Authorization"],
|
||||
# delegate_auth_to_upstream defaults to False
|
||||
# oauth_passthrough defaults to False
|
||||
)
|
||||
assert server.is_oauth_passthrough is False
|
||||
|
||||
|
||||
def test_is_oauth_passthrough_false_when_delegate_flag_explicitly_false():
|
||||
def test_is_oauth_passthrough_false_when_oauth_passthrough_explicitly_false():
|
||||
server = MCPServer(
|
||||
server_id="s1",
|
||||
name="s1",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.none,
|
||||
extra_headers=["Authorization"],
|
||||
delegate_auth_to_upstream=False,
|
||||
oauth_passthrough=False,
|
||||
)
|
||||
assert server.is_oauth_passthrough is False
|
||||
|
||||
|
||||
def test_is_oauth_passthrough_false_when_only_delegate_auth_to_upstream_set():
|
||||
"""Regression guard: ``delegate_auth_to_upstream`` is the oauth2-only
|
||||
PKCE-bypass flag and must NOT, on its own, turn a non-oauth2 server into
|
||||
an OAuth pass-through server. Pass-through requires the dedicated
|
||||
``oauth_passthrough`` opt-in. This protects existing deployments that set
|
||||
``delegate_auth_to_upstream`` from silently gaining pass-through behavior.
|
||||
"""
|
||||
server = MCPServer(
|
||||
server_id="s1",
|
||||
name="s1",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.none,
|
||||
extra_headers=["Authorization"],
|
||||
delegate_auth_to_upstream=True,
|
||||
# oauth_passthrough intentionally left at its default (False)
|
||||
)
|
||||
assert server.is_oauth_passthrough is False
|
||||
|
||||
|
|
@ -170,7 +189,7 @@ async def test_oauth_protected_resource_passthrough_proxies_upstream_metadata():
|
|||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.none,
|
||||
extra_headers=["Authorization"],
|
||||
delegate_auth_to_upstream=True,
|
||||
oauth_passthrough=True,
|
||||
)
|
||||
global_mcp_server_manager.registry[passthrough_server.server_id] = (
|
||||
passthrough_server
|
||||
|
|
@ -221,7 +240,7 @@ async def test_oauth_protected_resource_passthrough_cache_hit():
|
|||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.none,
|
||||
extra_headers=["Authorization"],
|
||||
delegate_auth_to_upstream=True,
|
||||
oauth_passthrough=True,
|
||||
)
|
||||
global_mcp_server_manager.registry[passthrough_server.server_id] = (
|
||||
passthrough_server
|
||||
|
|
@ -314,7 +333,7 @@ async def test_oauth_metadata_cache_expired_entry_is_refetched():
|
|||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.none,
|
||||
extra_headers=["Authorization"],
|
||||
delegate_auth_to_upstream=True,
|
||||
oauth_passthrough=True,
|
||||
)
|
||||
_OAUTH_METADATA_CACHE[(passthrough_server.server_id, passthrough_server.url)] = (
|
||||
0,
|
||||
|
|
@ -356,7 +375,7 @@ async def test_oauth_protected_resource_passthrough_network_error_returns_502():
|
|||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.none,
|
||||
extra_headers=["Authorization"],
|
||||
delegate_auth_to_upstream=True,
|
||||
oauth_passthrough=True,
|
||||
)
|
||||
global_mcp_server_manager.registry[passthrough_server.server_id] = (
|
||||
passthrough_server
|
||||
|
|
@ -389,7 +408,7 @@ async def test_fetch_upstream_metadata_returns_none_when_not_all_candidates_netw
|
|||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.none,
|
||||
extra_headers=["Authorization"],
|
||||
delegate_auth_to_upstream=True,
|
||||
oauth_passthrough=True,
|
||||
)
|
||||
|
||||
not_found_response = MagicMock()
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ def test_passthrough_cold_start_emits_401_with_matching_resource_metadata(
|
|||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.none,
|
||||
extra_headers=["Authorization"],
|
||||
delegate_auth_to_upstream=True,
|
||||
oauth_passthrough=True,
|
||||
)
|
||||
global_mcp_server_manager.registry[passthrough_server.server_id] = (
|
||||
passthrough_server
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ async def test_fetch_tools_from_passthrough_raises_on_upstream_401():
|
|||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.none,
|
||||
extra_headers=["Authorization"],
|
||||
delegate_auth_to_upstream=True,
|
||||
oauth_passthrough=True,
|
||||
)
|
||||
|
||||
response = httpx.Response(
|
||||
|
|
@ -98,7 +98,7 @@ async def test_fetch_tools_from_passthrough_returns_tools_on_success():
|
|||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.none,
|
||||
extra_headers=["Authorization"],
|
||||
delegate_auth_to_upstream=True,
|
||||
oauth_passthrough=True,
|
||||
)
|
||||
|
||||
tool = MagicMock()
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ def test_prepare_mcp_server_headers_passthrough_strips_authorization_without_adm
|
|||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.none,
|
||||
extra_headers=["Authorization", "x-request-id"],
|
||||
delegate_auth_to_upstream=True,
|
||||
oauth_passthrough=True,
|
||||
)
|
||||
|
||||
server_auth_header, extra_headers = _prepare_mcp_server_headers(
|
||||
|
|
@ -191,7 +191,7 @@ def test_prepare_mcp_server_headers_passthrough_forwards_authorization_for_anony
|
|||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.none,
|
||||
extra_headers=["Authorization", "x-request-id"],
|
||||
delegate_auth_to_upstream=True,
|
||||
oauth_passthrough=True,
|
||||
)
|
||||
|
||||
server_auth_header, extra_headers = _prepare_mcp_server_headers(
|
||||
|
|
@ -233,7 +233,7 @@ def test_prepare_mcp_server_headers_passthrough_strips_authorization_for_authent
|
|||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.none,
|
||||
extra_headers=["Authorization", "x-request-id"],
|
||||
delegate_auth_to_upstream=True,
|
||||
oauth_passthrough=True,
|
||||
)
|
||||
|
||||
server_auth_header, extra_headers = _prepare_mcp_server_headers(
|
||||
|
|
|
|||
|
|
@ -497,7 +497,7 @@ class TestMCPServerManager:
|
|||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.none,
|
||||
extra_headers=["Authorization", "x-request-id"],
|
||||
delegate_auth_to_upstream=True,
|
||||
oauth_passthrough=True,
|
||||
)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
|
|
@ -550,7 +550,7 @@ class TestMCPServerManager:
|
|||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.none,
|
||||
extra_headers=["Authorization"],
|
||||
delegate_auth_to_upstream=True,
|
||||
oauth_passthrough=True,
|
||||
)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
|
|
@ -606,7 +606,7 @@ class TestMCPServerManager:
|
|||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.none,
|
||||
extra_headers=["Authorization"],
|
||||
delegate_auth_to_upstream=True,
|
||||
oauth_passthrough=True,
|
||||
)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
|
|
|
|||
|
|
@ -51,6 +51,68 @@ const renderWithForm = (props = {}) => {
|
|||
expect(toggle).toHaveAttribute("aria-checked", "false");
|
||||
});
|
||||
|
||||
const renderWithInitialValues = (
|
||||
initialValues: Record<string, unknown>,
|
||||
props = {},
|
||||
) => {
|
||||
const Wrapper: React.FC = ({ children }) => {
|
||||
const [form] = Form.useForm();
|
||||
return (
|
||||
<Form form={form} initialValues={initialValues}>
|
||||
{/* In the real app auth_type is registered by the parent form; the
|
||||
component only watches it. Register a hidden field here so
|
||||
Form.useWatch("auth_type") resolves the initial value. */}
|
||||
<Form.Item name="auth_type" hidden>
|
||||
<input />
|
||||
</Form.Item>
|
||||
{children}
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
return render(
|
||||
<Wrapper>
|
||||
<MCPPermissionManagement {...defaultProps} {...props} />
|
||||
</Wrapper>,
|
||||
);
|
||||
};
|
||||
|
||||
it("shows only the oauth2 PKCE-delegation toggle for oauth2 servers", async () => {
|
||||
renderWithInitialValues({ allow_all_keys: false, auth_type: "oauth2" });
|
||||
await expandPanel();
|
||||
expect(
|
||||
screen.getByText("Delegate auth to upstream (PKCE passthrough)"),
|
||||
).toBeInTheDocument();
|
||||
// The non-oauth2 pass-through toggle must NOT appear for oauth2 servers.
|
||||
expect(screen.queryByText("OAuth pass-through")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows only the OAuth pass-through toggle for none-auth servers forwarding Authorization", async () => {
|
||||
renderWithInitialValues({
|
||||
allow_all_keys: false,
|
||||
auth_type: "none",
|
||||
extra_headers: ["Authorization"],
|
||||
});
|
||||
await expandPanel();
|
||||
expect(screen.getByText("OAuth pass-through")).toBeInTheDocument();
|
||||
// The oauth2-only PKCE delegation toggle must NOT appear here.
|
||||
expect(
|
||||
screen.queryByText("Delegate auth to upstream (PKCE passthrough)"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides both upstream-auth toggles for none-auth servers without an Authorization header", async () => {
|
||||
renderWithInitialValues({
|
||||
allow_all_keys: false,
|
||||
auth_type: "none",
|
||||
extra_headers: ["x-api-key"],
|
||||
});
|
||||
await expandPanel();
|
||||
expect(screen.queryByText("OAuth pass-through")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Delegate auth to upstream (PKCE passthrough)"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should reflect allow_all_keys when editing an existing server", async () => {
|
||||
renderWithForm({
|
||||
mcpServer: {
|
||||
|
|
|
|||
|
|
@ -31,13 +31,15 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
|
|||
&& watchedExtraHeaders.some(
|
||||
(h) => typeof h === "string" && h.toLowerCase() === "authorization",
|
||||
);
|
||||
// Two modes that honor delegate_auth_to_upstream server-side:
|
||||
// - oauth2 servers (PKCE passthrough — bypass LiteLLM admission)
|
||||
// - auth_type=none + Authorization in extra_headers (OAuth pass-through:
|
||||
// proxy upstream oauth-protected-resource, emit 401 challenges,
|
||||
// propagate upstream 401/403)
|
||||
const isOAuthPassthrough = isNoneAuth && hasAuthorizationHeader;
|
||||
const canDelegateAuth = isOAuth2 || isOAuthPassthrough;
|
||||
// Two distinct, independent opt-ins:
|
||||
// - delegate_auth_to_upstream: oauth2 servers only (PKCE passthrough —
|
||||
// bypass LiteLLM admission).
|
||||
// - oauth_passthrough: auth_type=none + Authorization in extra_headers
|
||||
// (OAuth pass-through: proxy upstream oauth-protected-resource, emit 401
|
||||
// challenges, propagate upstream 401/403).
|
||||
// Kept as separate flags so neither silently implies the other and existing
|
||||
// oauth2 servers can't regress into pass-through behavior.
|
||||
const canEnableOAuthPassthrough = isNoneAuth && hasAuthorizationHeader;
|
||||
const watchedDelegateAuth = Form.useWatch("delegate_auth_to_upstream", form);
|
||||
const watchedPublicInternet = Form.useWatch("available_on_public_internet", form);
|
||||
const showInternalDelegatePkceWarning =
|
||||
|
|
@ -64,22 +66,33 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
|
|||
if (typeof mcpServer.delegate_auth_to_upstream === "boolean") {
|
||||
form.setFieldValue("delegate_auth_to_upstream", mcpServer.delegate_auth_to_upstream);
|
||||
}
|
||||
if (typeof mcpServer.oauth_passthrough === "boolean") {
|
||||
form.setFieldValue("oauth_passthrough", mcpServer.oauth_passthrough);
|
||||
}
|
||||
} else {
|
||||
form.setFieldValue("allow_all_keys", false);
|
||||
form.setFieldValue("available_on_public_internet", true);
|
||||
form.setFieldValue("delegate_auth_to_upstream", false);
|
||||
form.setFieldValue("oauth_passthrough", false);
|
||||
}
|
||||
}, [mcpServer, form]);
|
||||
|
||||
// delegate_auth_to_upstream is only honored server-side for oauth2 servers
|
||||
// or auth_type=none servers that forward Authorization to upstream. Force
|
||||
// it back to false whenever the user switches to any other configuration
|
||||
// so a stale toggle value doesn't get persisted unexpectedly.
|
||||
// delegate_auth_to_upstream is only honored server-side for oauth2 servers.
|
||||
// Force it back to false whenever the user switches away from oauth2 so a
|
||||
// stale toggle value doesn't get persisted unexpectedly.
|
||||
useEffect(() => {
|
||||
if (!canDelegateAuth) {
|
||||
if (!isOAuth2) {
|
||||
form.setFieldValue("delegate_auth_to_upstream", false);
|
||||
}
|
||||
}, [canDelegateAuth, form]);
|
||||
}, [isOAuth2, form]);
|
||||
|
||||
// oauth_passthrough is only honored for auth_type=none servers that forward
|
||||
// Authorization upstream. Force it back to false otherwise.
|
||||
useEffect(() => {
|
||||
if (!canEnableOAuthPassthrough) {
|
||||
form.setFieldValue("oauth_passthrough", false);
|
||||
}
|
||||
}, [canEnableOAuthPassthrough, form]);
|
||||
|
||||
return (
|
||||
<Collapse className="bg-gray-50 border border-gray-200 rounded-lg" expandIconPosition="end" ghost={false}>
|
||||
|
|
@ -140,27 +153,17 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
|
|||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{canDelegateAuth && (
|
||||
{isOAuth2 && (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
{isOAuth2
|
||||
? "Delegate auth to upstream (PKCE passthrough)"
|
||||
: "Delegate auth to upstream (OAuth pass-through)"}
|
||||
<Tooltip
|
||||
title={
|
||||
isOAuth2
|
||||
? "When on, LiteLLM skips its own API key/SSO check for this server and lets the client complete PKCE directly with the upstream MCP server. Only honored when Auth Type is oauth2. No spend tracking or per-key rate limiting will run on this route."
|
||||
: "When on, this server is treated as an OAuth pass-through: the gateway proxies the upstream /.well-known/oauth-protected-resource metadata, emits spec-compliant 401 challenges when no bearer is supplied, and propagates upstream 401/403 responses. Only honored when Auth Type is None and 'Authorization' is in Extra Headers."
|
||||
}
|
||||
>
|
||||
Delegate auth to upstream (PKCE passthrough)
|
||||
<Tooltip title="When on, LiteLLM skips its own API key/SSO check for this server and lets the client complete PKCE directly with the upstream MCP server. Only honored when Auth Type is oauth2. No spend tracking or per-key rate limiting will run on this route.">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{isOAuth2
|
||||
? "Bypass LiteLLM auth so clients authenticate directly with the upstream OAuth MCP server."
|
||||
: "Forward upstream OAuth discovery and 401 challenges so clients negotiate OAuth directly with the upstream MCP server."}
|
||||
Bypass LiteLLM auth so clients authenticate directly with the upstream OAuth MCP server.
|
||||
</p>
|
||||
</div>
|
||||
<Form.Item
|
||||
|
|
@ -174,6 +177,30 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{canEnableOAuthPassthrough && (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
OAuth pass-through
|
||||
<Tooltip title="When on, this server is treated as an OAuth pass-through: the gateway proxies the upstream /.well-known/oauth-protected-resource metadata, emits spec-compliant 401 challenges when no bearer is supplied, and propagates upstream 401/403 responses. Only honored when Auth Type is None and 'Authorization' is in Extra Headers.">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Forward upstream OAuth discovery and 401 challenges so clients negotiate OAuth directly with the upstream MCP server.
|
||||
</p>
|
||||
</div>
|
||||
<Form.Item
|
||||
name="oauth_passthrough"
|
||||
valuePropName="checked"
|
||||
initialValue={mcpServer?.oauth_passthrough ?? false}
|
||||
className="mb-0"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showInternalDelegatePkceWarning && (
|
||||
<Alert
|
||||
type="warning"
|
||||
|
|
|
|||
|
|
@ -288,6 +288,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
allow_all_keys: allowAllKeysRaw,
|
||||
available_on_public_internet: availableOnPublicInternetRaw,
|
||||
delegate_auth_to_upstream: delegateAuthToUpstreamRaw,
|
||||
oauth_passthrough: oauthPassthroughRaw,
|
||||
token_validation_json: rawTokenValidationJson,
|
||||
...restValues
|
||||
} = values;
|
||||
|
|
@ -393,6 +394,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
allow_all_keys: Boolean(allowAllKeysRaw),
|
||||
available_on_public_internet: Boolean(availableOnPublicInternetRaw),
|
||||
delegate_auth_to_upstream: Boolean(delegateAuthToUpstreamRaw),
|
||||
oauth_passthrough: Boolean(oauthPassthroughRaw),
|
||||
static_headers: staticHeaders,
|
||||
...(tokenValidation !== null && { token_validation: tokenValidation }),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -216,6 +216,41 @@ describe("MCPServerEdit (delegate auth)", () => {
|
|||
expect(payload.auth_type).toBe("none");
|
||||
expect(payload.delegate_auth_to_upstream).toBe(false);
|
||||
});
|
||||
|
||||
it("does not enable oauth_passthrough for an oauth2 server", async () => {
|
||||
vi.mocked(networking.updateMCPServer).mockResolvedValue({
|
||||
...interactiveOAuthServer,
|
||||
oauth_passthrough: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<MCPServerEdit
|
||||
mcpServer={{
|
||||
...interactiveOAuthServer,
|
||||
extra_headers: ["Authorization"],
|
||||
oauth_passthrough: true,
|
||||
}}
|
||||
accessToken="access-token"
|
||||
onCancel={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
availableAccessGroups={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const saveButtons = screen.getAllByRole("button", { name: "Save Changes" });
|
||||
await act(async () => {
|
||||
fireEvent.click(saveButtons[0]);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(networking.updateMCPServer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const [, payload] = vi.mocked(networking.updateMCPServer).mock.calls[0];
|
||||
expect(payload.auth_type).toBe("oauth2");
|
||||
// oauth_passthrough is non-oauth2 only — must be forced false here.
|
||||
expect(payload.oauth_passthrough).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCPServerEdit (interactive OAuth)", () => {
|
||||
|
|
|
|||
|
|
@ -385,6 +385,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
allow_all_keys: allowAllKeysRaw,
|
||||
available_on_public_internet: availableOnPublicInternetRaw,
|
||||
delegate_auth_to_upstream: delegateAuthToUpstreamRaw,
|
||||
oauth_passthrough: oauthPassthroughRaw,
|
||||
token_validation_json: rawTokenValidationJson,
|
||||
...restValues
|
||||
} = values;
|
||||
|
|
@ -554,14 +555,22 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
allow_all_keys: Boolean(allowAllKeysRaw ?? mcpServer.allow_all_keys),
|
||||
available_on_public_internet: Boolean(availableOnPublicInternetRaw ?? mcpServer.available_on_public_internet),
|
||||
// ``delegate_auth_to_upstream`` is only honored server-side for
|
||||
// ``auth_type=oauth2`` (PKCE passthrough) or ``auth_type=none`` with
|
||||
// ``Authorization`` in ``extra_headers`` (OAuth pass-through). The
|
||||
// Form.Item is conditionally rendered so the value drops out of the
|
||||
// form on auth_type change; force false for any other configuration
|
||||
// to avoid persisting a stale ``true`` that would silently
|
||||
// re-activate if the configuration is later switched back.
|
||||
// ``auth_type=oauth2`` (PKCE passthrough). The Form.Item is
|
||||
// conditionally rendered so the value drops out of the form on
|
||||
// auth_type change; force false for any other configuration to avoid
|
||||
// persisting a stale ``true`` that would silently re-activate if the
|
||||
// configuration is later switched back.
|
||||
delegate_auth_to_upstream: (() => {
|
||||
const isOauth2 = restValues.auth_type === AUTH_TYPE.OAUTH2;
|
||||
return isOauth2
|
||||
? Boolean(delegateAuthToUpstreamRaw ?? mcpServer.delegate_auth_to_upstream)
|
||||
: false;
|
||||
})(),
|
||||
// ``oauth_passthrough`` is the dedicated, non-oauth2 opt-in. It is only
|
||||
// honored for ``auth_type=none`` servers that forward ``Authorization``
|
||||
// upstream. Kept separate from ``delegate_auth_to_upstream`` so enabling
|
||||
// pass-through never regresses oauth2 servers. Force false otherwise.
|
||||
oauth_passthrough: (() => {
|
||||
const isNoneAuth =
|
||||
restValues.auth_type === AUTH_TYPE.NONE || restValues.auth_type == null;
|
||||
const extraHeaders = Array.isArray(restValues.extra_headers)
|
||||
|
|
@ -570,9 +579,8 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
const hasAuthorizationHeader = extraHeaders.some(
|
||||
(h: unknown) => typeof h === "string" && h.toLowerCase() === "authorization",
|
||||
);
|
||||
const eligible = isOauth2 || (isNoneAuth && hasAuthorizationHeader);
|
||||
return eligible
|
||||
? Boolean(delegateAuthToUpstreamRaw ?? mcpServer.delegate_auth_to_upstream)
|
||||
return isNoneAuth && hasAuthorizationHeader
|
||||
? Boolean(oauthPassthroughRaw ?? mcpServer.oauth_passthrough)
|
||||
: false;
|
||||
})(),
|
||||
// Include token_validation when it is set (non-null) or when clearing an existing value
|
||||
|
|
|
|||
|
|
@ -290,6 +290,27 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
{handleAuth(mcpServer.auth_type) !== "oauth2" &&
|
||||
Array.isArray(mcpServer.extra_headers) &&
|
||||
mcpServer.extra_headers.some(
|
||||
(h) => typeof h === "string" && h.toLowerCase() === "authorization",
|
||||
) && (
|
||||
<div className="py-3 grid grid-cols-3 gap-4">
|
||||
<Text className="text-sm font-medium text-gray-500">OAuth Pass-through</Text>
|
||||
<div className="col-span-2">
|
||||
{mcpServer.oauth_passthrough ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-green-50 text-green-700 rounded-full border border-green-200 text-xs font-medium">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-green-500"></span>
|
||||
Enabled
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-gray-50 text-gray-600 rounded-full border border-gray-200 text-xs font-medium">
|
||||
Disabled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="py-3 grid grid-cols-3 gap-4">
|
||||
<Text className="text-sm font-medium text-gray-500">Access Groups</Text>
|
||||
<div className="col-span-2">
|
||||
|
|
|
|||
|
|
@ -210,6 +210,7 @@ export interface MCPServer {
|
|||
allow_all_keys?: boolean;
|
||||
available_on_public_internet?: boolean;
|
||||
delegate_auth_to_upstream?: boolean;
|
||||
oauth_passthrough?: boolean;
|
||||
|
||||
/** Stdio-only fields (present when transport === 'stdio') */
|
||||
command?: string | null;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue