Gate MCP OAuth pass-through on delegate_auth_to_upstream flag

Sameer's review on #28356/#28008 flagged that the new pass-through
behaviors (preemptive 401 challenges, /.well-known/oauth-protected-
resource proxying, upstream 401/403 propagation as MCPUpstreamAuthError,
and Authorization-stripping when no x-litellm-api-key is supplied)
were implicitly enabled for every server with auth_type=none plus
Authorization in extra_headers. Existing users doing static bearer
pass-through for non-OAuth reasons would have silently regressed.

Make the detection rule explicit: extend the existing
delegate_auth_to_upstream flag (previously oauth2-only) to also gate
is_oauth_passthrough. Now requires flag + auth_type=None + Authorization
in extra_headers, per Sameer's suggested detection rule. The UI toggle
now appears for both modes (oauth2 PKCE passthrough and auth_type=none
OAuth pass-through) with mode-appropriate copy.

Update test fixtures to set the flag where the test intent is to
exercise OAuth pass-through behavior, and add negative tests covering
the new default-false case.
This commit is contained in:
mateo-berri 2026-05-21 16:17:16 +00:00
parent 026c2374c2
commit 0511b76ada
No known key found for this signature in database
7 changed files with 121 additions and 22 deletions

View file

@ -68,11 +68,23 @@ class MCPServer(BaseModel):
access_groups: Optional[List[str]] = None access_groups: Optional[List[str]] = None
allow_all_keys: bool = False allow_all_keys: bool = False
available_on_public_internet: bool = True available_on_public_internet: bool = True
# When True AND auth_type == oauth2, MCP requests targeting this server # Explicit opt-in to upstream-delegated authentication. Two distinct modes
# bypass LiteLLM API-key/SSO auth (and the pre-emptive 401) so the client # depending on ``auth_type``:
# completes PKCE directly with the upstream MCP server. Honored only for #
# auth_type=oauth2; ignored for any other auth_type. See # * ``auth_type == oauth2``: MCP requests bypass LiteLLM API-key/SSO
# MCPRequestHandler._target_servers_delegate_auth_to_upstream. # 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).
delegate_auth_to_upstream: bool = False delegate_auth_to_upstream: bool = False
is_byok: bool = False is_byok: bool = False
byok_description: List[str] = [] byok_description: List[str] = []
@ -144,12 +156,19 @@ class MCPServer(BaseModel):
(discovery + 401s) rather than participating as an authorization (discovery + 401s) rather than participating as an authorization
server itself. server itself.
A server is pass-through for OAuth purposes when both conditions hold: A server is pass-through for OAuth purposes when ALL three conditions
hold:
1. ``auth_type`` is ``None`` or ``MCPAuth.none`` (the gateway does 1. ``auth_type`` is ``None`` or ``MCPAuth.none`` (the gateway does
not manage OAuth for this server). not manage OAuth for this server).
2. ``extra_headers`` includes ``Authorization`` — the admin has 2. ``extra_headers`` includes ``Authorization`` — the admin has
opted this server into forwarding the client's bearer token opted this server into forwarding the client's bearer token
straight to the upstream MCP server. straight to the upstream MCP server.
3. ``delegate_auth_to_upstream`` 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 intentionally narrower than ``requires_per_user_auth``, This is intentionally narrower than ``requires_per_user_auth``,
which also covers PATs (``x-api-key``, ``api-key``, ``apikey``). which also covers PATs (``x-api-key``, ``api-key``, ``apikey``).
@ -160,6 +179,8 @@ class MCPServer(BaseModel):
return False return False
if not self.extra_headers: if not self.extra_headers:
return False return False
if self.delegate_auth_to_upstream is not True:
return False
return any(h.lower() == "authorization" for h in self.extra_headers) return any(h.lower() == "authorization" for h in self.extra_headers)
@property @property

View file

@ -70,6 +70,7 @@ def test_is_oauth_passthrough_true_when_none_auth_and_authorization_header():
transport=MCPTransport.http, transport=MCPTransport.http,
auth_type=MCPAuth.none, auth_type=MCPAuth.none,
extra_headers=["Authorization"], extra_headers=["Authorization"],
delegate_auth_to_upstream=True,
) )
assert server.is_oauth_passthrough is True assert server.is_oauth_passthrough is True
@ -81,6 +82,7 @@ def test_is_oauth_passthrough_true_when_auth_type_none_and_mixed_case_header():
transport=MCPTransport.http, transport=MCPTransport.http,
auth_type=None, auth_type=None,
extra_headers=["authorization", "x-request-id"], extra_headers=["authorization", "x-request-id"],
delegate_auth_to_upstream=True,
) )
assert server.is_oauth_passthrough is True assert server.is_oauth_passthrough is True
@ -92,6 +94,7 @@ def test_is_oauth_passthrough_false_for_oauth2_server():
transport=MCPTransport.http, transport=MCPTransport.http,
auth_type=MCPAuth.oauth2, auth_type=MCPAuth.oauth2,
extra_headers=["Authorization"], extra_headers=["Authorization"],
delegate_auth_to_upstream=True,
) )
assert server.is_oauth_passthrough is False assert server.is_oauth_passthrough is False
@ -103,6 +106,7 @@ def test_is_oauth_passthrough_false_without_authorization_header():
transport=MCPTransport.http, transport=MCPTransport.http,
auth_type=MCPAuth.none, auth_type=MCPAuth.none,
extra_headers=["x-api-key"], extra_headers=["x-api-key"],
delegate_auth_to_upstream=True,
) )
assert server.is_oauth_passthrough is False assert server.is_oauth_passthrough is False
@ -113,6 +117,34 @@ def test_is_oauth_passthrough_false_without_extra_headers():
name="s1", name="s1",
transport=MCPTransport.http, transport=MCPTransport.http,
auth_type=MCPAuth.none, auth_type=MCPAuth.none,
delegate_auth_to_upstream=True,
)
assert server.is_oauth_passthrough is False
def test_is_oauth_passthrough_false_without_delegate_flag():
"""The detection flag must be set explicitly. Without it, the legacy
behavior is preserved for servers that forward Authorization for
non-OAuth reasons (static bearer tokens, custom auth schemes)."""
server = MCPServer(
server_id="s1",
name="s1",
transport=MCPTransport.http,
auth_type=MCPAuth.none,
extra_headers=["Authorization"],
# delegate_auth_to_upstream defaults to False
)
assert server.is_oauth_passthrough is False
def test_is_oauth_passthrough_false_when_delegate_flag_explicitly_false():
server = MCPServer(
server_id="s1",
name="s1",
transport=MCPTransport.http,
auth_type=MCPAuth.none,
extra_headers=["Authorization"],
delegate_auth_to_upstream=False,
) )
assert server.is_oauth_passthrough is False assert server.is_oauth_passthrough is False
@ -138,6 +170,7 @@ async def test_oauth_protected_resource_passthrough_proxies_upstream_metadata():
transport=MCPTransport.http, transport=MCPTransport.http,
auth_type=MCPAuth.none, auth_type=MCPAuth.none,
extra_headers=["Authorization"], extra_headers=["Authorization"],
delegate_auth_to_upstream=True,
) )
global_mcp_server_manager.registry[passthrough_server.server_id] = ( global_mcp_server_manager.registry[passthrough_server.server_id] = (
passthrough_server passthrough_server
@ -188,6 +221,7 @@ async def test_oauth_protected_resource_passthrough_cache_hit():
transport=MCPTransport.http, transport=MCPTransport.http,
auth_type=MCPAuth.none, auth_type=MCPAuth.none,
extra_headers=["Authorization"], extra_headers=["Authorization"],
delegate_auth_to_upstream=True,
) )
global_mcp_server_manager.registry[passthrough_server.server_id] = ( global_mcp_server_manager.registry[passthrough_server.server_id] = (
passthrough_server passthrough_server
@ -280,6 +314,7 @@ async def test_oauth_metadata_cache_expired_entry_is_refetched():
transport=MCPTransport.http, transport=MCPTransport.http,
auth_type=MCPAuth.none, auth_type=MCPAuth.none,
extra_headers=["Authorization"], extra_headers=["Authorization"],
delegate_auth_to_upstream=True,
) )
_OAUTH_METADATA_CACHE[(passthrough_server.server_id, passthrough_server.url)] = ( _OAUTH_METADATA_CACHE[(passthrough_server.server_id, passthrough_server.url)] = (
0, 0,
@ -321,6 +356,7 @@ async def test_oauth_protected_resource_passthrough_network_error_returns_502():
transport=MCPTransport.http, transport=MCPTransport.http,
auth_type=MCPAuth.none, auth_type=MCPAuth.none,
extra_headers=["Authorization"], extra_headers=["Authorization"],
delegate_auth_to_upstream=True,
) )
global_mcp_server_manager.registry[passthrough_server.server_id] = ( global_mcp_server_manager.registry[passthrough_server.server_id] = (
passthrough_server passthrough_server
@ -353,6 +389,7 @@ async def test_fetch_upstream_metadata_returns_none_when_not_all_candidates_netw
transport=MCPTransport.http, transport=MCPTransport.http,
auth_type=MCPAuth.none, auth_type=MCPAuth.none,
extra_headers=["Authorization"], extra_headers=["Authorization"],
delegate_auth_to_upstream=True,
) )
not_found_response = MagicMock() not_found_response = MagicMock()

View file

@ -60,6 +60,7 @@ def test_passthrough_cold_start_emits_401_with_matching_resource_metadata(
transport=MCPTransport.http, transport=MCPTransport.http,
auth_type=MCPAuth.none, auth_type=MCPAuth.none,
extra_headers=["Authorization"], extra_headers=["Authorization"],
delegate_auth_to_upstream=True,
) )
global_mcp_server_manager.registry[passthrough_server.server_id] = ( global_mcp_server_manager.registry[passthrough_server.server_id] = (
passthrough_server passthrough_server

View file

@ -60,6 +60,7 @@ async def test_fetch_tools_from_passthrough_raises_on_upstream_401():
transport=MCPTransport.http, transport=MCPTransport.http,
auth_type=MCPAuth.none, auth_type=MCPAuth.none,
extra_headers=["Authorization"], extra_headers=["Authorization"],
delegate_auth_to_upstream=True,
) )
response = httpx.Response( response = httpx.Response(
@ -97,6 +98,7 @@ async def test_fetch_tools_from_passthrough_returns_tools_on_success():
transport=MCPTransport.http, transport=MCPTransport.http,
auth_type=MCPAuth.none, auth_type=MCPAuth.none,
extra_headers=["Authorization"], extra_headers=["Authorization"],
delegate_auth_to_upstream=True,
) )
tool = MagicMock() tool = MagicMock()

View file

@ -153,6 +153,7 @@ def test_prepare_mcp_server_headers_passthrough_strips_authorization_without_adm
transport=MCPTransport.http, transport=MCPTransport.http,
auth_type=MCPAuth.none, auth_type=MCPAuth.none,
extra_headers=["Authorization", "x-request-id"], extra_headers=["Authorization", "x-request-id"],
delegate_auth_to_upstream=True,
) )
server_auth_header, extra_headers = _prepare_mcp_server_headers( server_auth_header, extra_headers = _prepare_mcp_server_headers(

View file

@ -25,6 +25,19 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
const form = Form.useFormInstance(); const form = Form.useFormInstance();
const watchedAuthType = Form.useWatch("auth_type", form); const watchedAuthType = Form.useWatch("auth_type", form);
const isOAuth2 = watchedAuthType === AUTH_TYPE.OAUTH2; const isOAuth2 = watchedAuthType === AUTH_TYPE.OAUTH2;
const isNoneAuth = watchedAuthType === AUTH_TYPE.NONE || watchedAuthType == null;
const watchedExtraHeaders = Form.useWatch("extra_headers", form);
const hasAuthorizationHeader = Array.isArray(watchedExtraHeaders)
&& watchedExtraHeaders.some(
(h) => typeof h === "string" && h.toLowerCase() === "authorization",
);
// Two 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;
const watchedDelegateAuth = Form.useWatch("delegate_auth_to_upstream", form); const watchedDelegateAuth = Form.useWatch("delegate_auth_to_upstream", form);
const watchedPublicInternet = Form.useWatch("available_on_public_internet", form); const watchedPublicInternet = Form.useWatch("available_on_public_internet", form);
const showInternalDelegatePkceWarning = const showInternalDelegatePkceWarning =
@ -58,14 +71,15 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
} }
}, [mcpServer, form]); }, [mcpServer, form]);
// delegate_auth_to_upstream is only honored server-side when auth_type=oauth2. // delegate_auth_to_upstream is only honored server-side for oauth2 servers
// Force it back to false whenever the user switches away from oauth2 so a // or auth_type=none servers that forward Authorization to upstream. Force
// stale toggle value doesn't get persisted with another auth type. // it back to false whenever the user switches to any other configuration
// so a stale toggle value doesn't get persisted unexpectedly.
useEffect(() => { useEffect(() => {
if (!isOAuth2) { if (!canDelegateAuth) {
form.setFieldValue("delegate_auth_to_upstream", false); form.setFieldValue("delegate_auth_to_upstream", false);
} }
}, [isOAuth2, form]); }, [canDelegateAuth, form]);
return ( return (
<Collapse className="bg-gray-50 border border-gray-200 rounded-lg" expandIconPosition="end" ghost={false}> <Collapse className="bg-gray-50 border border-gray-200 rounded-lg" expandIconPosition="end" ghost={false}>
@ -126,17 +140,27 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
</Form.Item> </Form.Item>
</div> </div>
{isOAuth2 && ( {canDelegateAuth && (
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <div>
<span className="text-sm font-medium text-gray-700 flex items-center"> <span className="text-sm font-medium text-gray-700 flex items-center">
Delegate auth to upstream (PKCE passthrough) {isOAuth2
<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."> ? "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."
}
>
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" /> <InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip> </Tooltip>
</span> </span>
<p className="text-sm text-gray-600 mt-1"> <p className="text-sm text-gray-600 mt-1">
Bypass LiteLLM auth so clients authenticate directly with the upstream OAuth MCP server. {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."}
</p> </p>
</div> </div>
<Form.Item <Form.Item

View file

@ -554,14 +554,27 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
allow_all_keys: Boolean(allowAllKeysRaw ?? mcpServer.allow_all_keys), allow_all_keys: Boolean(allowAllKeysRaw ?? mcpServer.allow_all_keys),
available_on_public_internet: Boolean(availableOnPublicInternetRaw ?? mcpServer.available_on_public_internet), available_on_public_internet: Boolean(availableOnPublicInternetRaw ?? mcpServer.available_on_public_internet),
// ``delegate_auth_to_upstream`` is only honored server-side for // ``delegate_auth_to_upstream`` is only honored server-side for
// ``auth_type=oauth2``. The Form.Item is conditionally rendered so the // ``auth_type=oauth2`` (PKCE passthrough) or ``auth_type=none`` with
// value drops out of the form on auth_type change; force false for any // ``Authorization`` in ``extra_headers`` (OAuth pass-through). The
// non-oauth2 server to avoid persisting a stale ``true`` that would // Form.Item is conditionally rendered so the value drops out of the
// silently re-activate if auth_type is later switched back to oauth2. // form on auth_type change; force false for any other configuration
delegate_auth_to_upstream: // to avoid persisting a stale ``true`` that would silently
restValues.auth_type === AUTH_TYPE.OAUTH2 // re-activate if the configuration is later switched back.
delegate_auth_to_upstream: (() => {
const isOauth2 = restValues.auth_type === AUTH_TYPE.OAUTH2;
const isNoneAuth =
restValues.auth_type === AUTH_TYPE.NONE || restValues.auth_type == null;
const extraHeaders = Array.isArray(restValues.extra_headers)
? restValues.extra_headers
: [];
const hasAuthorizationHeader = extraHeaders.some(
(h: unknown) => typeof h === "string" && h.toLowerCase() === "authorization",
);
const eligible = isOauth2 || (isNoneAuth && hasAuthorizationHeader);
return eligible
? Boolean(delegateAuthToUpstreamRaw ?? mcpServer.delegate_auth_to_upstream) ? Boolean(delegateAuthToUpstreamRaw ?? mcpServer.delegate_auth_to_upstream)
: false, : false;
})(),
// Include token_validation when it is set (non-null) or when clearing an existing value // Include token_validation when it is set (non-null) or when clearing an existing value
...(tokenValidation !== null || mcpServer.token_validation ...(tokenValidation !== null || mcpServer.token_validation
? { token_validation: tokenValidation } ? { token_validation: tokenValidation }