mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
feat(mcp): let a resolved OAuth token target a custom upstream header
An MCP server behind an API gateway needs two credentials on one request: the gateway's own token on a private header, and a separate bearer on Authorization for the server behind it. Every arm that minted or held a token hardcoded Authorization, and the conflict rule then dropped the operator's static Authorization to make room, so the second credential never arrived. ApiKeyConfig already modelled this as header_name plus value_prefix behind a header() method. Extend that carrier to the four minted-token configs, have each resolver arm ask its config which header to use instead of naming one, and drop only the header the resolved credential is about to occupy. Operators set it per server via upstream_token_header, plumbed through config.yaml, the credentials blob, the management API and the admin form, on the M2M, token-exchange, authorization-code and ID-JAG arms. It is non-secret so it stays plaintext and round-trips on admin reads. Unset keeps today's behaviour. Moving a credential off Authorization means it stops inheriting what Authorization gets for free, so the slot now carries those protections itself. httpx drops Authorization when a redirect crosses origin and keeps every other header, so a custom slot is dropped by the client on the same condition, mirroring httpx's own scheme/host/port rule with an agreement test that fails if the two ever diverge. The v1 path also mirrors the v2 conflict rule, so an injected header cannot shadow the credential the gateway resolved for that slot. Which header a credential occupies, and what counts as being that header, was answered independently in nine places by four hand-rolled comparisons. same_header, has_header and without_header in litellm/types/mcp.py are now the one owner, shared by both MCP stacks, and the client derives its slot once instead of three times. The header name reaches egress verbatim, so the RFC 7230 grammar lives in one place and is checked where servers are built: a bad value fails the config load and the management API returns 400, rather than raising while a spec is built and emptying the aggregate tool list for every other server. A blank means unset, matching what the endpoint already accepts.
This commit is contained in:
parent
e73e645ff9
commit
37cbdaa2ac
32 changed files with 1349 additions and 87 deletions
|
|
@ -56,6 +56,9 @@ from litellm.types.mcp import (
|
|||
MCPStdioConfig,
|
||||
MCPTransport,
|
||||
MCPTransportType,
|
||||
credential_redirect_hook,
|
||||
has_header,
|
||||
without_header,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -273,6 +276,7 @@ class MCPClient:
|
|||
transport_type: MCPTransportType = MCPTransport.http,
|
||||
auth_type: MCPAuthType = None,
|
||||
auth_value: str | dict[str, str] | None = None,
|
||||
auth_header_name: str | None = None,
|
||||
timeout: float | None = None,
|
||||
stdio_config: MCPStdioConfig | None = None,
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
|
|
@ -288,6 +292,11 @@ class MCPClient:
|
|||
self.auth_type: MCPAuthType = auth_type
|
||||
self.timeout: float = timeout if timeout is not None else MCP_CLIENT_TIMEOUT
|
||||
self._mcp_auth_value: str | dict[str, str] | None = None
|
||||
# The one place this client decides which header its credential occupies: the operator's
|
||||
# configured slot on the v1 path, or the slot the v2 resolver's auth object already owns.
|
||||
# Every consumer reads this rather than re-deriving it, since each re-derivation so far
|
||||
# picked up a different bug.
|
||||
self._credential_slot: str | None = auth_header_name or getattr(resolved_auth, "header_name", None)
|
||||
self.stdio_config: MCPStdioConfig | None = stdio_config
|
||||
self.extra_headers: dict[str, str] | None = extra_headers
|
||||
self.ssl_verify: VerifyTypes | None = ssl_verify
|
||||
|
|
@ -501,26 +510,33 @@ class MCPClient:
|
|||
else:
|
||||
self._mcp_auth_value = mcp_auth_value
|
||||
|
||||
def _header_slot(self, default: str) -> str:
|
||||
return self._credential_slot or default
|
||||
|
||||
def _get_auth_headers(self) -> dict:
|
||||
"""Generate authentication headers based on auth type."""
|
||||
headers: Final = {}
|
||||
if self._mcp_auth_value:
|
||||
if isinstance(self._mcp_auth_value, str):
|
||||
if self.auth_type == MCPAuth.bearer_token:
|
||||
headers["Authorization"] = f"Bearer {strip_auth_scheme(self._mcp_auth_value, 'Bearer')}"
|
||||
static_bearer: Final = strip_auth_scheme(self._mcp_auth_value, "Bearer")
|
||||
headers[self._header_slot("Authorization")] = f"Bearer {static_bearer}"
|
||||
elif self.auth_type == MCPAuth.basic:
|
||||
headers["Authorization"] = f"Basic {self._mcp_auth_value}"
|
||||
headers[self._header_slot("Authorization")] = f"Basic {self._mcp_auth_value}"
|
||||
elif self.auth_type == MCPAuth.api_key:
|
||||
headers["X-API-Key"] = self._mcp_auth_value
|
||||
headers[self._header_slot("X-API-Key")] = self._mcp_auth_value
|
||||
elif self.auth_type == MCPAuth.authorization:
|
||||
# This auth type means the caller owns the whole header value.
|
||||
headers["Authorization"] = self._mcp_auth_value
|
||||
headers[self._header_slot("Authorization")] = self._mcp_auth_value
|
||||
elif self.auth_type == MCPAuth.oauth2:
|
||||
headers["Authorization"] = f"Bearer {strip_auth_scheme(self._mcp_auth_value, 'Bearer')}"
|
||||
oauth2_bearer: Final = strip_auth_scheme(self._mcp_auth_value, "Bearer")
|
||||
headers[self._header_slot("Authorization")] = f"Bearer {oauth2_bearer}"
|
||||
elif self.auth_type == MCPAuth.token:
|
||||
headers["Authorization"] = f"token {strip_auth_scheme(self._mcp_auth_value, 'token')}"
|
||||
scheme_token: Final = strip_auth_scheme(self._mcp_auth_value, "token")
|
||||
headers[self._header_slot("Authorization")] = f"token {scheme_token}"
|
||||
elif self.auth_type == MCPAuth.oauth2_token_exchange:
|
||||
headers["Authorization"] = f"Bearer {strip_auth_scheme(self._mcp_auth_value, 'Bearer')}"
|
||||
exchanged_bearer: Final = strip_auth_scheme(self._mcp_auth_value, "Bearer")
|
||||
headers[self._header_slot("Authorization")] = f"Bearer {exchanged_bearer}"
|
||||
elif isinstance(self._mcp_auth_value, dict):
|
||||
headers.update(self._mcp_auth_value)
|
||||
# Note: aws_sigv4 auth is not handled here — SigV4 requires per-request
|
||||
|
|
@ -528,7 +544,14 @@ class MCPClient:
|
|||
# of static headers. See MCPSigV4Auth and _create_httpx_client_factory().
|
||||
# update the headers with the extra headers
|
||||
if self.extra_headers:
|
||||
headers.update(self.extra_headers)
|
||||
# Mirrors _resolve_v2_auth: when the operator named a slot for the credential the
|
||||
# gateway resolved, no injected header may shadow it, case-insensitively, since HTTP
|
||||
# header names are. Without a configured slot the old precedence stands unchanged.
|
||||
slot: Final = self._credential_slot
|
||||
injected: Final = (
|
||||
without_header(self.extra_headers, slot) if slot and has_header(headers, slot) else self.extra_headers
|
||||
)
|
||||
headers.update(injected or {})
|
||||
return _strip_header_whitespace(headers)
|
||||
|
||||
def _create_httpx_client_factory(self) -> Callable[..., httpx.AsyncClient]:
|
||||
|
|
@ -556,12 +579,14 @@ class MCPClient:
|
|||
# SigV4 aws_auth. Both are None for the common case — no behavior change.
|
||||
fallback_auth: Final = self._resolved_auth if self._resolved_auth is not None else self._aws_auth
|
||||
effective_auth: Final = auth if auth is not None else fallback_auth
|
||||
guard: Final = credential_redirect_hook(self.server_url, self._credential_slot)
|
||||
return httpx.AsyncClient(
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
auth=effective_auth,
|
||||
verify=ssl_config,
|
||||
follow_redirects=True,
|
||||
event_hooks={"request": [guard]} if guard else {},
|
||||
)
|
||||
|
||||
return factory
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ from mcp.types import (
|
|||
)
|
||||
from mcp.types import Tool as MCPTool
|
||||
from pydantic import AnyUrl, BaseModel
|
||||
from typing_extensions import ReadOnly
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_logger
|
||||
|
|
@ -72,6 +73,7 @@ from litellm.proxy._experimental.mcp_server.oauth2_token_cache import (
|
|||
MCPPerUserTokenCache,
|
||||
mcp_per_user_token_cache,
|
||||
resolve_mcp_auth,
|
||||
resolved_token_header,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.oauth_utils import (
|
||||
_redact_mcp_resource_url,
|
||||
|
|
@ -99,6 +101,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.token_exchange_
|
|||
build_token_exchanger,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.types import (
|
||||
DEFAULT_CREDENTIAL_HEADER,
|
||||
AuthorizationCodeConfig,
|
||||
ClientCredentialsConfig,
|
||||
CredError,
|
||||
|
|
@ -153,6 +156,8 @@ from litellm.types.mcp import (
|
|||
MCPAuth,
|
||||
MCPStdioConfig,
|
||||
MCPTokenEndpointAuthMethod,
|
||||
has_header,
|
||||
without_header,
|
||||
)
|
||||
from litellm.types.mcp_server.mcp_server_manager import (
|
||||
MCPInfo,
|
||||
|
|
@ -349,6 +354,7 @@ class MCPServerConfig(TypedDict, total=False):
|
|||
audience: str
|
||||
subject_token_type: str
|
||||
upstream_resource: str
|
||||
upstream_token_header: ReadOnly[str]
|
||||
id_jag_resource_token_endpoint: str
|
||||
id_jag_resource: str
|
||||
client_private_key: str
|
||||
|
|
@ -828,18 +834,6 @@ def _should_strip_caller_authorization(
|
|||
)
|
||||
|
||||
|
||||
def _without_authorization(
|
||||
headers: dict[str, str] | None,
|
||||
) -> dict[str, str] | None:
|
||||
"""A copy of ``headers`` with any ``Authorization`` key removed (case-insensitive), or
|
||||
None if nothing remains. Drops only the credential, keeping other forwarded headers.
|
||||
"""
|
||||
if not headers:
|
||||
return None
|
||||
filtered: Final = {k: v for k, v in headers.items() if k.lower() != "authorization"}
|
||||
return filtered or None
|
||||
|
||||
|
||||
def _format_byok_openapi_auth_header(mcp_server: MCPServer, mcp_auth_header: str) -> str:
|
||||
"""Format a raw BYOK credential for OpenAPI tool ``Authorization`` injection.
|
||||
|
||||
|
|
@ -914,7 +908,9 @@ def _resolve_openapi_tool_auth(
|
|||
|
||||
if isinstance(per_server, dict):
|
||||
authorization: Final = next((v for k, v in per_server.items() if k.lower() == "authorization"), None)
|
||||
merged: Final = merge_mcp_headers(extra_headers=forwarded, static_headers=_without_authorization(per_server))
|
||||
merged: Final = merge_mcp_headers(
|
||||
extra_headers=forwarded, static_headers=without_header(per_server, DEFAULT_CREDENTIAL_HEADER)
|
||||
)
|
||||
if authorization is None:
|
||||
byok: Final = _format_byok_openapi_auth_header(mcp_server, mcp_auth_header) if mcp_auth_header else None
|
||||
return byok, merged, mcp_auth_header
|
||||
|
|
@ -981,7 +977,7 @@ def _client_forwarded_authorization_headers(
|
|||
raw_headers=raw_headers,
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
):
|
||||
return _without_authorization(extra_headers)
|
||||
return without_header(extra_headers, DEFAULT_CREDENTIAL_HEADER)
|
||||
return extra_headers
|
||||
|
||||
|
||||
|
|
@ -994,7 +990,7 @@ def _take_forwarded_authorization(
|
|||
if not headers:
|
||||
return None, headers
|
||||
value: Final = next((v for k, v in headers.items() if k.lower() == "authorization"), None)
|
||||
return value, _without_authorization(headers)
|
||||
return value, without_header(headers, DEFAULT_CREDENTIAL_HEADER)
|
||||
|
||||
|
||||
def _passthrough_token_from_mcp_auth_header(
|
||||
|
|
@ -2166,6 +2162,7 @@ class MCPServerManager:
|
|||
DEFAULT_SUBJECT_TOKEN_TYPE,
|
||||
),
|
||||
upstream_resource=server_config.get("upstream_resource", None),
|
||||
upstream_token_header=server_config.get("upstream_token_header", None),
|
||||
# ID-JAG fields
|
||||
id_jag_resource_token_endpoint=server_config.get("id_jag_resource_token_endpoint", None),
|
||||
id_jag_resource=server_config.get("id_jag_resource", None),
|
||||
|
|
@ -2698,6 +2695,7 @@ class MCPServerManager:
|
|||
or (credentials_dict.get("subject_token_type") if credentials_dict else None)
|
||||
or DEFAULT_SUBJECT_TOKEN_TYPE,
|
||||
upstream_resource=(credentials_dict.get("upstream_resource") if credentials_dict else None),
|
||||
upstream_token_header=(credentials_dict.get("upstream_token_header") if credentials_dict else None),
|
||||
# ID-JAG fields — read from credentials JSON blob
|
||||
id_jag_resource_token_endpoint=(
|
||||
credentials_dict.get("id_jag_resource_token_endpoint") if credentials_dict else None
|
||||
|
|
@ -3525,10 +3523,9 @@ class MCPServerManager:
|
|||
case Ok(auth):
|
||||
# NoOpAuth has no header_name and so never conflicts.
|
||||
header_name: Final[str | None] = getattr(auth, "header_name", None)
|
||||
conflicts: Final = bool(
|
||||
header_name and extra_headers and any(key.lower() == header_name.lower() for key in extra_headers)
|
||||
)
|
||||
if not conflicts:
|
||||
if header_name is None or not extra_headers:
|
||||
return auth, extra_headers
|
||||
if not has_header(extra_headers, header_name):
|
||||
return auth, extra_headers
|
||||
if isinstance(
|
||||
spec.config,
|
||||
|
|
@ -3540,9 +3537,10 @@ class MCPServerManager:
|
|||
# guardrail such as MCPJWTSigner, static_headers, or any other injected
|
||||
# Authorization must NOT shadow it (otherwise the upstream gets e.g. the
|
||||
# signer's JWT instead of the minted token and rejects it, and for M2M the
|
||||
# one-shot 401 refetch is lost with it). Drop the conflicting header so the
|
||||
# resolved token reaches upstream.
|
||||
return auth, _without_authorization(extra_headers)
|
||||
# one-shot 401 refetch is lost with it). Drop only the header the resolved
|
||||
# credential is about to occupy, so a static credential the operator aimed at a
|
||||
# DIFFERENT header still reaches upstream.
|
||||
return auth, without_header(extra_headers, header_name)
|
||||
# Other modes: an Authorization already supplied via extra_headers (a forwarded caller
|
||||
# header or static_headers) is intentional and wins; v1 applies those last.
|
||||
return None, extra_headers
|
||||
|
|
@ -3650,6 +3648,7 @@ class MCPServerManager:
|
|||
):
|
||||
spec = None
|
||||
auth_value: Final = await resolve_mcp_auth(resolved_server, mcp_auth_header) if spec is None else None
|
||||
auth_header_name: Final = resolved_token_header(resolved_server, mcp_auth_header) if spec is None else None
|
||||
|
||||
# Create sampling and elicitation callbacks for this client
|
||||
sampling_cb = (
|
||||
|
|
@ -3758,6 +3757,7 @@ class MCPServerManager:
|
|||
transport_type=transport,
|
||||
auth_type=resolved_server.auth_type,
|
||||
auth_value=auth_value,
|
||||
auth_header_name=auth_header_name,
|
||||
timeout=(resolved_server.timeout if resolved_server.timeout is not None else MCP_CLIENT_TIMEOUT),
|
||||
extra_headers=extra_headers,
|
||||
aws_auth=aws_auth,
|
||||
|
|
@ -5304,7 +5304,7 @@ class MCPServerManager:
|
|||
raw_headers=raw_headers,
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
):
|
||||
extra_headers = _without_authorization(extra_headers)
|
||||
extra_headers = without_header(extra_headers, DEFAULT_CREDENTIAL_HEADER)
|
||||
elif mcp_server.is_client_forwarded_token:
|
||||
extra_headers = _client_forwarded_authorization_headers(
|
||||
mcp_server=mcp_server,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ with ``client_id``, ``client_secret``, and ``token_url``.
|
|||
|
||||
import asyncio
|
||||
import hashlib
|
||||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
import httpx
|
||||
|
|
@ -313,9 +314,26 @@ async def resolve_mcp_auth(
|
|||
1. ``mcp_auth_header`` — per-request/per-user override
|
||||
2. OAuth2 client_credentials token — auto-fetched and cached
|
||||
3. ``server.authentication_token`` — static token from config/DB
|
||||
|
||||
``resolved_token_header`` answers, for the same two inputs, which header the value belongs in.
|
||||
"""
|
||||
if mcp_auth_header:
|
||||
return mcp_auth_header
|
||||
if server.has_client_credentials:
|
||||
return await mcp_oauth2_token_cache.async_get_token(server)
|
||||
return server.authentication_token
|
||||
|
||||
|
||||
def resolved_token_header(
|
||||
server: "MCPServer",
|
||||
mcp_auth_header: str | Mapping[str, str] | None = None,
|
||||
) -> str | None:
|
||||
"""Which upstream header the value ``resolve_mcp_auth`` just returned belongs in.
|
||||
|
||||
``None`` means keep the auth_type default. A caller-supplied ``mcp_auth_header`` is the caller's
|
||||
own credential aimed at the slot the upstream normally uses, so it never moves; only the values
|
||||
the gateway resolved from its own config (the minted M2M token, the static token) follow
|
||||
``upstream_token_header``. Same inputs and same branch order as ``resolve_mcp_auth``, so the two
|
||||
cannot disagree about which case they are in.
|
||||
"""
|
||||
return None if mcp_auth_header else server.upstream_token_header
|
||||
|
|
|
|||
|
|
@ -47,12 +47,14 @@ def sanitize_openapi_tool_name(raw_name: str) -> str:
|
|||
from litellm._logging import verbose_logger
|
||||
from litellm.litellm_core_utils.url_utils import async_safe_get
|
||||
from litellm.llms.custom_httpx.http_handler import (
|
||||
AsyncHTTPHandler,
|
||||
get_async_httpx_client,
|
||||
httpxSpecialProvider,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.tool_registry import (
|
||||
global_mcp_tool_registry,
|
||||
)
|
||||
from litellm.types.mcp import credential_redirect_hook, custom_credential_slot
|
||||
|
||||
|
||||
class _OpenAPIJSONSchema(TypedDict, total=False):
|
||||
|
|
@ -119,6 +121,10 @@ _request_resolved_auth_headers: Final[contextvars.ContextVar[dict[str, str] | No
|
|||
"_request_resolved_auth_headers", default=None
|
||||
)
|
||||
|
||||
_request_upstream_url: Final[contextvars.ContextVar[str | None]] = contextvars.ContextVar(
|
||||
"_request_upstream_url", default=None
|
||||
)
|
||||
|
||||
|
||||
def _sanitize_path_parameter_value(param_value: object, param_name: str) -> str:
|
||||
"""Ensure path params cannot introduce directory traversal."""
|
||||
|
|
@ -349,6 +355,35 @@ def build_input_schema(operation: _OpenAPIOperation) -> dict[str, object]:
|
|||
}
|
||||
|
||||
|
||||
async def _drop_credential_across_origin(request: httpx.Request) -> None:
|
||||
"""Apply this request's cross-origin credential guard, if it needs one.
|
||||
|
||||
Reads the per-request context rather than closing over it so the hook is one stable object, which
|
||||
keeps the guarded client cacheable. A closure would key a new entry per call, and the handler it
|
||||
built would never be closed.
|
||||
"""
|
||||
guard: Final = credential_redirect_hook(
|
||||
_request_upstream_url.get() or "", custom_credential_slot(_request_resolved_auth_headers.get())
|
||||
)
|
||||
if guard is not None:
|
||||
await guard(request)
|
||||
|
||||
|
||||
def _upstream_client() -> AsyncHTTPHandler:
|
||||
"""The HTTP client for one upstream call, guarded when a credential rides a custom slot.
|
||||
|
||||
A resolved credential outside ``Authorization`` is not stripped across origins by the client
|
||||
itself, so this arm installs the same hook the MCP client uses. Both variants come from the
|
||||
shared cache, so a guarded call reuses its connection pool like any other.
|
||||
"""
|
||||
if custom_credential_slot(_request_resolved_auth_headers.get()) is None:
|
||||
return get_async_httpx_client(llm_provider=httpxSpecialProvider.MCP)
|
||||
return get_async_httpx_client(
|
||||
llm_provider=httpxSpecialProvider.MCP,
|
||||
params={"event_hooks": {"request": [_drop_credential_across_origin]}},
|
||||
)
|
||||
|
||||
|
||||
def _merge_openapi_tool_request_headers(
|
||||
static_headers: dict[str, str],
|
||||
) -> dict[str, str]:
|
||||
|
|
@ -510,8 +545,9 @@ def create_tool_function(
|
|||
except (json.JSONDecodeError, TypeError):
|
||||
json_body = {"data": body_value}
|
||||
|
||||
client: Final = get_async_httpx_client(llm_provider=httpxSpecialProvider.MCP)
|
||||
client: Final = _upstream_client()
|
||||
upstream: Final = server_label or f"{original_method.upper()} {path}"
|
||||
url_token: Final = _request_upstream_url.set(url)
|
||||
|
||||
try:
|
||||
if original_method == "get":
|
||||
|
|
@ -529,6 +565,8 @@ def create_tool_function(
|
|||
except MaskedHTTPStatusError as e:
|
||||
_raise_for_upstream_failure(e.response, upstream, relays_upstream_auth)
|
||||
raise
|
||||
finally:
|
||||
_request_upstream_url.reset(url_token)
|
||||
|
||||
_raise_for_upstream_failure(response, upstream, relays_upstream_auth)
|
||||
return response.text
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.result import (
|
|||
Result,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.types import (
|
||||
DEFAULT_CREDENTIAL_HEADER,
|
||||
Ambient,
|
||||
ApiKeyConfig,
|
||||
ApiKeySource,
|
||||
|
|
@ -35,6 +36,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.types import (
|
|||
ClientCredentialsConfig,
|
||||
ClientSecretAuth,
|
||||
CredError,
|
||||
HeaderCarrier,
|
||||
IdJagConfig,
|
||||
NoneConfig,
|
||||
PassthroughConfig,
|
||||
|
|
@ -45,9 +47,11 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.types import (
|
|||
Subject,
|
||||
TokenExchangeConfig,
|
||||
parse_auth_spec_kind,
|
||||
validate_header_name,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_CREDENTIAL_HEADER",
|
||||
"Ambient",
|
||||
"ApiKeyConfig",
|
||||
"ApiKeySource",
|
||||
|
|
@ -63,6 +67,7 @@ __all__ = [
|
|||
"ClientSecretAuth",
|
||||
"CredError",
|
||||
"Error",
|
||||
"HeaderCarrier",
|
||||
"IdJagConfig",
|
||||
"NoOpAuth",
|
||||
"NoneConfig",
|
||||
|
|
@ -78,4 +83,5 @@ __all__ = [
|
|||
"TokenExchangeConfig",
|
||||
"UpstreamCredentialProvider",
|
||||
"parse_auth_spec_kind",
|
||||
"validate_header_name",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from typing_extensions import assert_never
|
|||
|
||||
from litellm.proxy._experimental.mcp_server.oauth_utils import resolve_upstream_resource
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.types import (
|
||||
DEFAULT_CREDENTIAL_HEADER,
|
||||
ApiKeyConfig,
|
||||
AuthorizationCodeConfig,
|
||||
ClientAuth,
|
||||
|
|
@ -45,6 +46,15 @@ _TOKEN_EXCHANGE_SUBJECT_TOKEN_DEFAULT: Final = "urn:ietf:params:oauth:token-type
|
|||
_ID_JAG_SUBJECT_TOKEN_DEFAULT: Final = "urn:ietf:params:oauth:token-type:id_token"
|
||||
|
||||
|
||||
def token_header(server: MCPServer) -> str:
|
||||
"""The upstream header this server's resolved credential occupies.
|
||||
|
||||
One owner for every arm, so no spec builder spells the default itself and a server can never
|
||||
hand two arms different answers.
|
||||
"""
|
||||
return server.upstream_token_header or DEFAULT_CREDENTIAL_HEADER
|
||||
|
||||
|
||||
def to_subject(user_api_key_auth: UserAPIKeyAuth | None, subject_token: str | None) -> Subject:
|
||||
"""Map v1's authenticated principal onto the resolver's Subject.
|
||||
|
||||
|
|
@ -122,7 +132,7 @@ def _oauth2_spec(server: MCPServer, resource: str) -> ServerSpec | None:
|
|||
return ServerSpec(
|
||||
server_id=server.server_id,
|
||||
resource=resource,
|
||||
config=AuthorizationCodeConfig(),
|
||||
config=AuthorizationCodeConfig(header_name=token_header(server)),
|
||||
)
|
||||
return None
|
||||
|
||||
|
|
@ -140,6 +150,7 @@ def _client_credentials_spec(server: MCPServer, resource: str) -> ServerSpec:
|
|||
server_id=server.server_id,
|
||||
resource=resource,
|
||||
config=ClientCredentialsConfig(
|
||||
header_name=token_header(server),
|
||||
client_id=server.client_id,
|
||||
client_secret=SecretStr(server.client_secret) if server.client_secret else None,
|
||||
token_url=server.effective_token_url,
|
||||
|
|
@ -173,6 +184,7 @@ def _token_exchange_spec(server: MCPServer, resource: str) -> ServerSpec | None:
|
|||
server_id=server.server_id,
|
||||
resource=resource,
|
||||
config=TokenExchangeConfig(
|
||||
header_name=token_header(server),
|
||||
profile=profile,
|
||||
subject_token_type=server.subject_token_type or DEFAULT_SUBJECT_TOKEN_TYPE,
|
||||
token_exchange_endpoint=endpoint,
|
||||
|
|
@ -206,7 +218,7 @@ def _shared_key_spec(
|
|||
server_id=server.server_id,
|
||||
resource=resource,
|
||||
config=ApiKeyConfig(
|
||||
header_name=header_name,
|
||||
header_name=server.upstream_token_header or header_name,
|
||||
value_prefix=value_prefix,
|
||||
key_source=SharedKey(value=SecretStr(value)),
|
||||
),
|
||||
|
|
@ -231,6 +243,7 @@ def _id_jag_spec(server: MCPServer, resource: str) -> ServerSpec | None:
|
|||
server_id=server.server_id,
|
||||
resource=resource,
|
||||
config=IdJagConfig(
|
||||
header_name=token_header(server),
|
||||
org_token_endpoint=org_token_endpoint,
|
||||
resource_token_endpoint=resource_token_endpoint,
|
||||
client_id=client_id,
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.result import (
|
|||
from litellm.proxy._experimental.mcp_server.outbound_credentials.types import (
|
||||
ClientCredentialsConfig,
|
||||
CredError,
|
||||
HeaderCarrier,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -328,14 +329,21 @@ class ClientCredentialsBearerAuth(httpx.Auth):
|
|||
refetch fails, or the retried request 401s again, the upstream's response stands.
|
||||
"""
|
||||
|
||||
def __init__(self, access_token: str, refetch: Callable[[str], Awaitable[str | None]]) -> None:
|
||||
self.header_name = "Authorization"
|
||||
def __init__(
|
||||
self,
|
||||
access_token: str,
|
||||
refetch: Callable[[str], Awaitable[str | None]],
|
||||
carrier: HeaderCarrier,
|
||||
) -> None:
|
||||
self._carrier = carrier
|
||||
self.header_name = carrier.header_name
|
||||
self._access_token = SecretStr(access_token)
|
||||
self._refetch = refetch
|
||||
|
||||
async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response]:
|
||||
token: Final = self._access_token.get_secret_value()
|
||||
request.headers[self.header_name] = f"Bearer {token}"
|
||||
name, value = self._carrier.header(token)
|
||||
request.headers[name] = value
|
||||
response: Final = yield request
|
||||
if response.status_code != 401:
|
||||
return
|
||||
|
|
@ -343,7 +351,8 @@ class ClientCredentialsBearerAuth(httpx.Auth):
|
|||
if fresh is None:
|
||||
return
|
||||
self._access_token = SecretStr(fresh)
|
||||
request.headers[self.header_name] = f"Bearer {fresh}"
|
||||
fresh_name, fresh_value = self._carrier.header(fresh)
|
||||
request.headers[fresh_name] = fresh_value
|
||||
yield request
|
||||
|
||||
def sync_auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
|
||||
|
|
|
|||
|
|
@ -145,8 +145,8 @@ class UpstreamCredentialProvider:
|
|||
return await self._token_exchange(subject, server, config)
|
||||
case IdJagConfig() as config:
|
||||
return await self._id_jag(subject, server, config)
|
||||
case AuthorizationCodeConfig():
|
||||
return await self._authorization_code(subject, server)
|
||||
case AuthorizationCodeConfig() as config:
|
||||
return await self._authorization_code(subject, server, config)
|
||||
case AwsSigV4Config():
|
||||
return _not_implemented(AuthSpecKind.aws_sigv4)
|
||||
assert_never(server.config)
|
||||
|
|
@ -284,15 +284,19 @@ class UpstreamCredentialProvider:
|
|||
|
||||
match await self._exchanged_tokens.get_or_compute(slot, _exchange, fingerprint=fingerprint):
|
||||
case Ok(access_token):
|
||||
return Ok(StaticHeaderAuth(f"Bearer {access_token}"))
|
||||
header_name, header_value = config.header(access_token)
|
||||
return Ok(StaticHeaderAuth(header_value, header_name=header_name))
|
||||
case Error(err):
|
||||
return Error(err)
|
||||
|
||||
async def _authorization_code(self, subject: Subject, server: ServerSpec) -> Result[StaticHeaderAuth, CredError]:
|
||||
async def _authorization_code(
|
||||
self, subject: Subject, server: ServerSpec, config: AuthorizationCodeConfig
|
||||
) -> Result[StaticHeaderAuth, CredError]:
|
||||
token: Final = await self._authz_token(subject, server)
|
||||
if token is None:
|
||||
return Error(CredError.of_unauthorized("Authorization required: complete the OAuth flow for this server."))
|
||||
return Ok(StaticHeaderAuth(f"Bearer {token.access_token}", header_name="Authorization"))
|
||||
header_name, header_value = config.header(token.access_token)
|
||||
return Ok(StaticHeaderAuth(header_value, header_name=header_name))
|
||||
|
||||
async def _client_credentials(
|
||||
self, server_id: str, config: ClientCredentialsConfig
|
||||
|
|
@ -307,7 +311,7 @@ class UpstreamCredentialProvider:
|
|||
match await self._client_credentials_source.get(server_id, config):
|
||||
case Ok(token):
|
||||
refetch: Final = partial(self._client_credentials_source.refetch, server_id, config)
|
||||
return Ok(ClientCredentialsBearerAuth(token.access_token, refetch))
|
||||
return Ok(ClientCredentialsBearerAuth(token.access_token, refetch, config))
|
||||
case Error(err):
|
||||
return Error(err)
|
||||
|
||||
|
|
@ -332,7 +336,8 @@ class UpstreamCredentialProvider:
|
|||
inbound.get_secret_value(), server, config, tenant_id=subject.tenant_id
|
||||
):
|
||||
case Ok(token):
|
||||
return Ok(StaticHeaderAuth(f"Bearer {token.access_token}", header_name="Authorization"))
|
||||
header_name, header_value = config.header(token.access_token)
|
||||
return Ok(StaticHeaderAuth(header_value, header_name=header_name))
|
||||
case Error(err):
|
||||
return Error(err)
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ from enum import Enum
|
|||
from typing import Annotated, Final, Literal
|
||||
|
||||
from expression import case, tag, tagged_union
|
||||
from pydantic import BaseModel, ConfigDict, Field, SecretStr
|
||||
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator
|
||||
from typing_extensions import assert_never
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.result import (
|
||||
|
|
@ -39,7 +39,11 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.result import (
|
|||
Ok,
|
||||
Result,
|
||||
)
|
||||
from litellm.types.mcp import DEFAULT_SUBJECT_TOKEN_TYPE
|
||||
from litellm.types.mcp import (
|
||||
DEFAULT_CREDENTIAL_HEADER,
|
||||
DEFAULT_SUBJECT_TOKEN_TYPE,
|
||||
normalize_upstream_header_name,
|
||||
)
|
||||
|
||||
|
||||
class AuthSpecKind(str, Enum):
|
||||
|
|
@ -161,7 +165,52 @@ class CredError:
|
|||
assert_never(self.tag)
|
||||
|
||||
|
||||
class AuthorizationCodeConfig(BaseModel):
|
||||
def validate_header_name(raw: str) -> Result[str, CredError]:
|
||||
"""``normalize_upstream_header_name`` with this package's error-as-value policy.
|
||||
|
||||
The grammar itself lives in ``litellm.types.mcp`` so the v1 model, the management endpoint and
|
||||
this vocabulary all judge a header name the same way while each keeps its own failure shape.
|
||||
"""
|
||||
normalized: Final = normalize_upstream_header_name(raw)
|
||||
if normalized is None:
|
||||
return Error(CredError.of_misconfigured(f"invalid upstream header name: {raw!r}"))
|
||||
return Ok(normalized)
|
||||
|
||||
|
||||
class HeaderCarrier(BaseModel):
|
||||
"""Where a resolved credential is written upstream, and how its value is formatted.
|
||||
|
||||
``Authorization: Bearer`` is only OAuth's *default* conveyance (RFC 6750 section 2.1), not its
|
||||
only one: an ESB or API gateway commonly terminates its own credential in a private header while
|
||||
a second credential passes through to the origin, so a credential has to be able to say which
|
||||
slot it owns. Modeled like OpenAPI's apiKey scheme, so any upstream convention is expressible
|
||||
(Authorization + Bearer, a raw value on X-API-Key, Ocp-Apim-Subscription-Key, esb-oauth, ...).
|
||||
|
||||
Every config whose credential the gateway mints or holds inherits this, so no resolver arm names
|
||||
a header itself and the conflict rule in ``_resolve_v2_auth`` can always ask the auth object
|
||||
which slot it is about to occupy. ``passthrough`` deliberately does not: it forwards the
|
||||
caller's own credential into the slot the caller used, and mints nothing to place.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
header_name: str = DEFAULT_CREDENTIAL_HEADER
|
||||
value_prefix: str = "Bearer"
|
||||
|
||||
@field_validator("header_name")
|
||||
@classmethod
|
||||
def _check_header_name(cls, value: str) -> str:
|
||||
match validate_header_name(value):
|
||||
case Ok(name):
|
||||
return name
|
||||
case Error(err):
|
||||
raise ValueError(err.summary)
|
||||
|
||||
def header(self, value: str) -> tuple[str, str]:
|
||||
formatted: Final = f"{self.value_prefix} {value}" if self.value_prefix else value
|
||||
return self.header_name, formatted
|
||||
|
||||
|
||||
class AuthorizationCodeConfig(HeaderCarrier):
|
||||
"""Per-user 3LO; the gateway is the OAuth client and stores the user's token.
|
||||
|
||||
Endpoints are discovered (RFC 9728 -> RFC 8414) and the client is registered via DCR
|
||||
|
|
@ -179,7 +228,7 @@ class AuthorizationCodeConfig(BaseModel):
|
|||
token_url: str | None = None
|
||||
|
||||
|
||||
class ClientCredentialsConfig(BaseModel):
|
||||
class ClientCredentialsConfig(HeaderCarrier):
|
||||
"""M2M service account; one upstream identity for every user.
|
||||
|
||||
Fields are optional so the config can be built incomplete: a value may be supplied at
|
||||
|
|
@ -203,7 +252,7 @@ class ClientCredentialsConfig(BaseModel):
|
|||
token_endpoint_auth_method: Literal["client_secret_post", "client_secret_basic"] | None = None
|
||||
|
||||
|
||||
class TokenExchangeConfig(BaseModel):
|
||||
class TokenExchangeConfig(HeaderCarrier):
|
||||
"""OBO: swap the caller's live inbound token for a token bound to the upstream's audience. The
|
||||
gateway authenticates to the exchange endpoint as an OAuth client (`client_id`/`client_secret`);
|
||||
the inbound token is sent only to that endpoint, never to the upstream.
|
||||
|
|
@ -255,7 +304,7 @@ class ClientSecretAuth(BaseModel):
|
|||
ClientAuth = Annotated[PrivateKeyJwtAuth | ClientSecretAuth, Field(discriminator="source")]
|
||||
|
||||
|
||||
class IdJagConfig(BaseModel):
|
||||
class IdJagConfig(HeaderCarrier):
|
||||
"""draft-ietf-oauth-identity-assertion-authz-grant (Okta "AI agent token exchange").
|
||||
|
||||
Two legs: leg 1 is an RFC 8693 token exchange at the IdP org AS (`org_token_endpoint`) that
|
||||
|
|
@ -297,23 +346,16 @@ class Byok(BaseModel):
|
|||
ApiKeySource = Annotated[SharedKey | Byok, Field(discriminator="source")]
|
||||
|
||||
|
||||
class ApiKeyConfig(BaseModel):
|
||||
class ApiKeyConfig(HeaderCarrier):
|
||||
"""A fixed credential injected as a header. The value is shared (in config) or seeded
|
||||
per-user (pulled from the store); `header_name` and `value_prefix` say where and how it is
|
||||
written, modeled like OpenAPI's apiKey scheme so any upstream convention is expressible
|
||||
(Authorization + Bearer, a raw value on X-API-Key, Ocp-Apim-Subscription-Key, etc.).
|
||||
per-user (pulled from the store); the inherited `header_name` and `value_prefix` say where
|
||||
and how it is written.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
kind: Literal[AuthSpecKind.api_key] = AuthSpecKind.api_key
|
||||
header_name: str = "Authorization"
|
||||
value_prefix: str = "Bearer"
|
||||
key_source: ApiKeySource
|
||||
|
||||
def header(self, value: str) -> tuple[str, str]:
|
||||
formatted: Final = f"{self.value_prefix} {value}" if self.value_prefix else value
|
||||
return self.header_name, formatted
|
||||
|
||||
|
||||
class PassthroughConfig(BaseModel):
|
||||
"""Client-driven upstream OAuth; the gateway forwards the client's upstream token."""
|
||||
|
|
|
|||
|
|
@ -432,7 +432,6 @@ if MCP_AVAILABLE:
|
|||
_client_forwarded_authorization_headers,
|
||||
_resolve_openapi_tool_auth,
|
||||
_should_strip_caller_authorization,
|
||||
_without_authorization,
|
||||
global_mcp_server_manager,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
|
||||
|
|
@ -451,6 +450,7 @@ if MCP_AVAILABLE:
|
|||
split_server_prefix_from_name,
|
||||
strip_known_server_prefix,
|
||||
)
|
||||
from litellm.types.mcp import DEFAULT_CREDENTIAL_HEADER, without_header
|
||||
|
||||
######################################################
|
||||
############ MCP Tools List REST API Response Object #
|
||||
|
|
@ -1732,7 +1732,7 @@ if MCP_AVAILABLE:
|
|||
raw_headers=raw_headers,
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
):
|
||||
extra_headers = _without_authorization(extra_headers)
|
||||
extra_headers = without_header(extra_headers, DEFAULT_CREDENTIAL_HEADER)
|
||||
elif is_client_forwarded_mode:
|
||||
if not withhold_forwarded_authorization:
|
||||
extra_headers = _client_forwarded_authorization_headers(
|
||||
|
|
|
|||
|
|
@ -204,6 +204,7 @@ if MCP_AVAILABLE:
|
|||
MCP_ADMIN_CONFIG_CREDENTIAL_KEYS,
|
||||
MCPAuth,
|
||||
MCPCredentials,
|
||||
normalize_upstream_header_name,
|
||||
)
|
||||
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
||||
|
||||
|
|
@ -239,9 +240,26 @@ if MCP_AVAILABLE:
|
|||
detail={"error": error_messages_text},
|
||||
)
|
||||
|
||||
def _validate_upstream_token_header(payload: McpServerPayloadLike) -> None:
|
||||
credentials: Final = getattr(payload, "credentials", None)
|
||||
raw: Final = credentials.get("upstream_token_header") if isinstance(credentials, dict) else None
|
||||
if not isinstance(raw, str) or raw == "":
|
||||
return
|
||||
if normalize_upstream_header_name(raw) is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"error": (
|
||||
f"Invalid upstream_token_header {raw!r}: must be a valid HTTP header name "
|
||||
"(RFC 7230 token, e.g. 'esb-oauth')"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
def validate_and_normalize_mcp_server_payload(payload: McpServerPayloadLike) -> None:
|
||||
_base_validate_and_normalize_mcp_server_payload(payload)
|
||||
_validate_mcp_server_name_fields(payload)
|
||||
_validate_upstream_token_header(payload)
|
||||
|
||||
def stamp_omitted_oauth2_flow(payload: NewMCPServerRequest) -> None:
|
||||
"""Fallback only: fill in oauth2_flow when an oauth2 create omits it.
|
||||
|
|
@ -739,6 +757,7 @@ if MCP_AVAILABLE:
|
|||
("aws_region_name", "aws_region_name"),
|
||||
("aws_service_name", "aws_service_name"),
|
||||
("upstream_resource", "upstream_resource"),
|
||||
("upstream_token_header", "upstream_token_header"),
|
||||
)
|
||||
|
||||
def _has_non_admin_config_credentials(credentials: "MCPCredentials | None") -> bool:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import enum
|
||||
import re
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
|
|
@ -181,6 +186,15 @@ class MCPCredentials(TypedDict, total=False):
|
|||
``audience``, which is the RFC 8693 token-exchange parameter.
|
||||
"""
|
||||
|
||||
upstream_token_header: str | None # writable-ok: pydantic warns it cannot honour ReadOnly here
|
||||
"""
|
||||
Which upstream header carries the credential LiteLLM resolves for this server. Omitted when
|
||||
unset, which keeps RFC 6750's default of ``Authorization``. Set it when the upstream expects the
|
||||
gateway's token somewhere else (an ESB terminating its own credential on e.g. ``esb-oauth``), so
|
||||
a separate operator-configured ``Authorization`` reaches the origin untouched. Non-secret, so it
|
||||
is stored in plaintext and returned on admin reads.
|
||||
"""
|
||||
|
||||
client_private_key: str | None
|
||||
"""
|
||||
PEM private key used to sign the private-key-JWT client_assertion (RFC 7523)
|
||||
|
|
@ -223,7 +237,92 @@ class MCPCredentials(TypedDict, total=False):
|
|||
"""
|
||||
|
||||
|
||||
MCP_ADMIN_CONFIG_CREDENTIAL_KEYS: Final[tuple[str, ...]] = ("upstream_resource",)
|
||||
DEFAULT_CREDENTIAL_HEADER: Final = "Authorization"
|
||||
|
||||
_HEADER_NAME_TOKEN: Final = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$")
|
||||
|
||||
|
||||
def normalize_upstream_header_name(raw: str) -> str | None:
|
||||
"""The trimmed header name if it is a usable RFC 7230 ``token``, else None.
|
||||
|
||||
One owner for the grammar; each caller picks its own failure shape (a config-load raise, an
|
||||
API 400, a typed CredError). An operator-supplied name reaches egress verbatim, so a value
|
||||
carrying CR/LF, spaces or separators must never get that far.
|
||||
"""
|
||||
stripped: Final = raw.strip()
|
||||
return stripped if stripped and _HEADER_NAME_TOKEN.match(stripped) else None
|
||||
|
||||
|
||||
def same_header(name: str, other: str) -> bool:
|
||||
"""Whether two HTTP header names are the same one. They are case-insensitive (RFC 7230 3.2)."""
|
||||
return name.lower() == other.lower()
|
||||
|
||||
|
||||
def has_header(headers: Mapping[str, str] | None, name: str) -> bool:
|
||||
"""Whether ``headers`` carries ``name`` under any casing."""
|
||||
return bool(headers) and any(same_header(key, name) for key in headers or {})
|
||||
|
||||
|
||||
def without_header(headers: Mapping[str, str] | None, name: str) -> dict[str, str] | None:
|
||||
"""A copy of ``headers`` with every casing of ``name`` removed, or None if nothing remains.
|
||||
|
||||
The one owner of "drop this credential's header". Both MCP stacks and the upstream-credential
|
||||
resolver share it so a slot can never be dropped case-sensitively in one place and
|
||||
case-insensitively in another, which is how an injected header came to shadow a resolved
|
||||
credential on the v1 path.
|
||||
"""
|
||||
if not headers:
|
||||
return None
|
||||
filtered: Final = {key: value for key, value in headers.items() if not same_header(key, name)}
|
||||
return filtered or None
|
||||
|
||||
|
||||
_DEFAULT_PORTS: Final[Mapping[str, int]] = MappingProxyType({"http": 80, "https": 443})
|
||||
|
||||
|
||||
def crosses_origin(configured: str, target: str) -> bool:
|
||||
"""Whether ``target`` leaves ``configured``'s origin, by the rule HTTP clients use.
|
||||
|
||||
Origin is scheme, host and port, not host alone, so a same-host HTTPS downgrade or a port change
|
||||
counts as crossing it. A plain http -> https upgrade of the same host is exempt, matching what
|
||||
httpx exempts when it decides whether to keep ``Authorization`` across a redirect.
|
||||
"""
|
||||
a: Final = urlsplit(configured)
|
||||
b: Final = urlsplit(target)
|
||||
port_a: Final = a.port or _DEFAULT_PORTS.get(a.scheme)
|
||||
port_b: Final = b.port or _DEFAULT_PORTS.get(b.scheme)
|
||||
if a.scheme == b.scheme and a.hostname == b.hostname and port_a == port_b:
|
||||
return False
|
||||
return not (
|
||||
a.hostname == b.hostname and a.scheme == "http" and port_a == 80 and b.scheme == "https" and port_b == 443
|
||||
)
|
||||
|
||||
|
||||
def custom_credential_slot(headers: Mapping[str, str] | None) -> str | None:
|
||||
"""The first header carrying a credential somewhere other than ``Authorization``, if any."""
|
||||
return next((name for name in headers or {} if not same_header(name, DEFAULT_CREDENTIAL_HEADER)), None)
|
||||
|
||||
|
||||
def credential_redirect_hook(
|
||||
configured_url: str, slot: str | None
|
||||
) -> Callable[[httpx.Request], Awaitable[None]] | None:
|
||||
"""An httpx request hook dropping ``slot`` once a redirect leaves ``configured_url``'s origin.
|
||||
|
||||
None when no guard is needed, so callers do not each repeat the exemption: HTTP clients already
|
||||
strip ``Authorization`` across origins, but forward every other header, so only a credential an
|
||||
operator moved to its own slot can be replayed to whatever host the upstream redirects to.
|
||||
"""
|
||||
if not configured_url or not slot or same_header(slot, DEFAULT_CREDENTIAL_HEADER):
|
||||
return None
|
||||
|
||||
async def guard(request: httpx.Request) -> None:
|
||||
if slot in request.headers and crosses_origin(configured_url, str(request.url)):
|
||||
del request.headers[slot]
|
||||
|
||||
return guard
|
||||
|
||||
|
||||
MCP_ADMIN_CONFIG_CREDENTIAL_KEYS: Final[tuple[str, ...]] = ("upstream_resource", "upstream_token_header")
|
||||
"""Non-secret credential keys returned on read so the admin form can show and clear them. Mirrors
|
||||
``ADMIN_CONFIG_CREDENTIAL_KEYS`` in ``ui/litellm-dashboard/src/components/mcp_tools/types.tsx``."""
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from datetime import datetime
|
||||
from typing import Any, Final, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
|
||||
from litellm.types.mcp import (
|
||||
DEFAULT_SUBJECT_TOKEN_TYPE,
|
||||
|
|
@ -9,6 +9,7 @@ from litellm.types.mcp import (
|
|||
MCPAuthType,
|
||||
MCPTokenEndpointAuthMethod,
|
||||
MCPTransportType,
|
||||
normalize_upstream_header_name,
|
||||
)
|
||||
|
||||
# MCPInfo now allows arbitrary additional fields for custom metadata
|
||||
|
|
@ -86,6 +87,22 @@ class MCPServer(BaseModel):
|
|||
# today's behavior; "auto" derives the canonical URI from ``url``; any other value is sent
|
||||
# verbatim. Resolved by ``oauth_utils.resolve_upstream_resource``.
|
||||
upstream_resource: str | None = None
|
||||
# Which upstream header carries the credential LiteLLM resolves for this server (the minted
|
||||
# OAuth token, or the static key). None keeps RFC 6750's default, ``Authorization``. An ESB or
|
||||
# API gateway that terminates its own credential in a private header needs this so a second,
|
||||
# operator-configured ``Authorization`` can pass through to the origin untouched.
|
||||
upstream_token_header: str | None = None
|
||||
|
||||
@field_validator("upstream_token_header")
|
||||
@classmethod
|
||||
def _check_upstream_token_header(cls, value: str | None) -> str | None:
|
||||
if value is None or not value.strip():
|
||||
return None
|
||||
normalized: Final = normalize_upstream_header_name(value)
|
||||
if normalized is None:
|
||||
raise ValueError(f"upstream_token_header must be a valid HTTP header name (RFC 7230 token), got {value!r}")
|
||||
return normalized
|
||||
|
||||
# AWS SigV4 fields
|
||||
aws_access_key_id: str | None = None
|
||||
aws_secret_access_key: str | None = None
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||
import anyio
|
||||
import httpx
|
||||
import pytest
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.httpx_auth import StaticHeaderAuth
|
||||
from mcp import McpError
|
||||
from mcp.shared.message import SessionMessage
|
||||
from mcp.types import (
|
||||
|
|
@ -1095,3 +1096,188 @@ def test_mcp_extra_matches_proxy_extra_and_supports_streamable_http():
|
|||
specifier = Requirement(mcp_extra[0]).specifier
|
||||
assert not specifier.contains("1.23.0")
|
||||
assert specifier.contains("1.28.1")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"auth_type, default_header",
|
||||
[
|
||||
(MCPAuth.oauth2, "Authorization"),
|
||||
(MCPAuth.bearer_token, "Authorization"),
|
||||
(MCPAuth.api_key, "X-API-Key"),
|
||||
],
|
||||
)
|
||||
def test_v1_auth_headers_default_to_the_auth_type_slot(auth_type: MCPAuth, default_header: str) -> None:
|
||||
client = MCPClient(server_url="http://up.example.com/mcp", auth_type=auth_type)
|
||||
client.update_auth_value("tok")
|
||||
assert default_header in client._get_auth_headers()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("auth_type", [MCPAuth.oauth2, MCPAuth.bearer_token, MCPAuth.api_key])
|
||||
def test_v1_auth_headers_honor_the_configured_slot(auth_type: MCPAuth) -> None:
|
||||
"""The v1 stack mints its own client_credentials token (oauth2_token_cache) and writes it here,
|
||||
so leaving this table hardcoded makes the knob a silent no-op for every server that resolves
|
||||
through v1 rather than the v2 resolver."""
|
||||
client = MCPClient(
|
||||
server_url="http://up.example.com/mcp",
|
||||
auth_type=auth_type,
|
||||
auth_header_name="esb-oauth",
|
||||
)
|
||||
client.update_auth_value("tok")
|
||||
headers = client._get_auth_headers()
|
||||
assert "esb-oauth" in headers
|
||||
assert "Authorization" not in headers
|
||||
assert "X-API-Key" not in headers
|
||||
|
||||
|
||||
def test_v1_static_headers_still_win_their_own_slot():
|
||||
# extra_headers (which carries static_headers) is applied last on the v1 path, so a static
|
||||
# Authorization survives untouched while the resolved credential sits on its own header.
|
||||
client = MCPClient(
|
||||
server_url="http://up.example.com/mcp",
|
||||
auth_type=MCPAuth.oauth2,
|
||||
auth_header_name="esb-oauth",
|
||||
extra_headers={"Authorization": "Bearer static-upstream-mcp-token"},
|
||||
)
|
||||
client.update_auth_value("minted")
|
||||
headers = client._get_auth_headers()
|
||||
assert headers["esb-oauth"] == "Bearer minted"
|
||||
assert headers["Authorization"] == "Bearer static-upstream-mcp-token"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_custom_credential_header_is_stripped_when_a_redirect_crosses_origin():
|
||||
"""httpx drops Authorization across origins but keeps every other header, so a credential the
|
||||
operator moved to its own slot would be replayed to whatever host the upstream redirects to.
|
||||
Verified against real httpx redirect handling, not a hand-built request.
|
||||
"""
|
||||
seen: "list[tuple[str, str]]" = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen.append((request.url.host, request.headers.get("esb-oauth", "<stripped>")))
|
||||
if request.url.host == "upstream.example.com":
|
||||
return httpx.Response(302, headers={"Location": "https://attacker.example.com/collect"})
|
||||
return httpx.Response(200)
|
||||
|
||||
client = MCPClient(
|
||||
server_url="https://upstream.example.com/mcp",
|
||||
auth_type=MCPAuth.oauth2,
|
||||
auth_header_name="esb-oauth",
|
||||
)
|
||||
client.update_auth_value("minted-token")
|
||||
factory = client._create_httpx_client_factory()
|
||||
async with factory(headers=client._get_auth_headers(), timeout=None) as http_client:
|
||||
http_client._transport = httpx.MockTransport(handler)
|
||||
await http_client.get("https://upstream.example.com/mcp")
|
||||
|
||||
assert seen[0] == ("upstream.example.com", "Bearer minted-token")
|
||||
assert seen[1] == ("attacker.example.com", "<stripped>")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authorization_is_left_to_httpx_and_needs_no_guard():
|
||||
# The default slot is already protected by httpx, so the client must not install a guard for it
|
||||
# and must not interfere with the ordinary Authorization path.
|
||||
url = "https://upstream.example.com/mcp"
|
||||
from litellm.types.mcp import credential_redirect_hook
|
||||
|
||||
def guard_for(client: MCPClient):
|
||||
return credential_redirect_hook(client.server_url, client._credential_slot)
|
||||
|
||||
assert guard_for(MCPClient(server_url=url, auth_type=MCPAuth.oauth2)) is None
|
||||
assert guard_for(MCPClient(server_url=url, resolved_auth=StaticHeaderAuth("Bearer x"))) is None
|
||||
# a v2 resolver slot is discovered from the auth object, without the caller naming it again
|
||||
custom = MCPClient(server_url=url, resolved_auth=StaticHeaderAuth("Bearer x", header_name="esb-oauth"))
|
||||
assert guard_for(custom) is not None
|
||||
# and the same answer arrives via the v1 configured slot
|
||||
assert guard_for(MCPClient(server_url=url, auth_header_name="ESB-OAuth")) is not None
|
||||
|
||||
|
||||
def test_an_injected_header_cannot_shadow_the_configured_credential_slot():
|
||||
"""The v2 path drops a colliding injected header so the resolved credential wins its slot. The
|
||||
v1 path applies extra_headers last, so without this it silently sends the injected value and the
|
||||
upstream rejects a credential the gateway thought it had sent.
|
||||
"""
|
||||
client = MCPClient(
|
||||
server_url="https://upstream.example.com/mcp",
|
||||
auth_type=MCPAuth.oauth2,
|
||||
auth_header_name="esb-oauth",
|
||||
extra_headers={"esb-oauth": "Bearer injected", "X-Trace": "keep"},
|
||||
)
|
||||
client.update_auth_value("minted-token")
|
||||
headers = client._get_auth_headers()
|
||||
assert headers["esb-oauth"] == "Bearer minted-token"
|
||||
assert headers["X-Trace"] == "keep"
|
||||
|
||||
|
||||
def test_without_a_configured_slot_the_existing_precedence_is_unchanged():
|
||||
# extra_headers winning over authentication_token is long-standing v1 behavior; the fix above
|
||||
# must apply only to the slot the operator explicitly named.
|
||||
client = MCPClient(
|
||||
server_url="https://upstream.example.com/mcp",
|
||||
auth_type=MCPAuth.oauth2,
|
||||
extra_headers={"Authorization": "Bearer injected"},
|
||||
)
|
||||
client.update_auth_value("minted-token")
|
||||
assert client._get_auth_headers()["Authorization"] == "Bearer injected"
|
||||
|
||||
|
||||
_REDIRECT_CASES = [
|
||||
("https://upstream.example.com/mcp", "https://upstream.example.com/other"), # same origin
|
||||
("https://upstream.example.com/mcp", "https://upstream.example.com:443/other"), # explicit default port
|
||||
("https://upstream.example.com/mcp", "https://attacker.example.com/collect"), # different host
|
||||
("https://upstream.example.com/mcp", "http://upstream.example.com/collect"), # scheme downgrade
|
||||
("https://upstream.example.com/mcp", "https://upstream.example.com:8443/other"), # different port
|
||||
("https://upstream.example.com/mcp", "https://sub.upstream.example.com/x"), # different host
|
||||
("http://upstream.example.com/mcp", "https://upstream.example.com/other"), # http -> https upgrade
|
||||
("http://upstream.example.com/mcp", "http://upstream.example.com/other"), # same origin, plain http
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("start,target", _REDIRECT_CASES)
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_guard_agrees_with_httpx_about_authorization(start: str, target: str) -> None:
|
||||
"""Our custom slot must be dropped on exactly the redirects where httpx drops Authorization.
|
||||
|
||||
The rule is mirrored rather than imported, so this drives real httpx and compares the two
|
||||
outcomes. A future httpx that changes its redirect rule reds here instead of silently leaving
|
||||
the custom slot forwarded where Authorization is not (or stripped where it is not needed).
|
||||
"""
|
||||
seen: "list[tuple[str, str, str]]" = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(
|
||||
(
|
||||
str(request.url),
|
||||
request.headers.get("authorization", "<stripped>"),
|
||||
request.headers.get("esb-oauth", "<stripped>"),
|
||||
)
|
||||
)
|
||||
if str(request.url) == start:
|
||||
return httpx.Response(302, headers={"Location": target})
|
||||
return httpx.Response(200)
|
||||
|
||||
client = MCPClient(server_url=start, auth_type=MCPAuth.oauth2, auth_header_name="esb-oauth")
|
||||
factory = client._create_httpx_client_factory()
|
||||
async with factory(headers={"Authorization": "Bearer AUTH", "esb-oauth": "Bearer ESB"}, timeout=None) as http:
|
||||
http._transport = httpx.MockTransport(handler)
|
||||
await http.get(start)
|
||||
|
||||
_url, authorization, esb = seen[-1]
|
||||
assert (authorization == "<stripped>") == (esb == "<stripped>"), (
|
||||
f"httpx and the guard disagree for {target}: authorization={authorization!r} esb-oauth={esb!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_a_differently_cased_injected_header_cannot_shadow_the_slot() -> None:
|
||||
# HTTP header names are case-insensitive and v2 drops the collision case-insensitively, so an
|
||||
# exact-key check here would leave both spellings in the dict and let the injected value win.
|
||||
client = MCPClient(
|
||||
server_url="https://upstream.example.com/mcp",
|
||||
auth_type=MCPAuth.oauth2,
|
||||
auth_header_name="esb-oauth",
|
||||
extra_headers={"ESB-OAuth": "Bearer injected", "X-Trace": "keep"},
|
||||
)
|
||||
client.update_auth_value("minted-token")
|
||||
headers = client._get_auth_headers()
|
||||
assert [v for k, v in headers.items() if k.lower() == "esb-oauth"] == ["Bearer minted-token"]
|
||||
assert headers["X-Trace"] == "keep"
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from types import SimpleNamespace
|
|||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from pydantic import ValidationError
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.adapter import (
|
||||
oauth_protected_resource_path,
|
||||
|
|
@ -598,3 +599,92 @@ def test_client_credentials_uses_admin_entered_token_url_when_issuer_yield_empti
|
|||
assert spec is not None
|
||||
assert isinstance(spec.config, ClientCredentialsConfig)
|
||||
assert spec.config.token_url == "https://idp.example.com/token"
|
||||
|
||||
|
||||
_M2M_FIELDS = dict(
|
||||
auth_type=MCPAuth.oauth2,
|
||||
oauth2_flow="client_credentials",
|
||||
client_id="cid",
|
||||
client_secret="csec",
|
||||
token_url="https://idp.example.com/token",
|
||||
)
|
||||
_OBO_FIELDS = dict(
|
||||
auth_type=MCPAuth.oauth2_token_exchange,
|
||||
client_id="cid",
|
||||
client_secret="csec",
|
||||
token_exchange_endpoint="https://idp.example.com/token",
|
||||
)
|
||||
_ID_JAG_FIELDS = dict(
|
||||
auth_type=MCPAuth.oauth2_id_jag,
|
||||
client_id="cid",
|
||||
client_secret="csec",
|
||||
token_exchange_endpoint="https://idp.example.com/token",
|
||||
id_jag_resource_token_endpoint="https://mcp-as.example.com/token",
|
||||
audience="api://mcp",
|
||||
)
|
||||
_AUTHZ_CODE_FIELDS = dict(auth_type=MCPAuth.oauth2, url="https://up.example.com/mcp")
|
||||
_STATIC_FIELDS = dict(auth_type=MCPAuth.bearer_token, authentication_token="static-tok")
|
||||
|
||||
_ARM_FIELDS = (
|
||||
("client_credentials", _M2M_FIELDS),
|
||||
("token_exchange", _OBO_FIELDS),
|
||||
("id_jag", _ID_JAG_FIELDS),
|
||||
("authorization_code", _AUTHZ_CODE_FIELDS),
|
||||
("api_key", _STATIC_FIELDS),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,fields", _ARM_FIELDS, ids=[n for n, _ in _ARM_FIELDS])
|
||||
def test_upstream_token_header_reaches_every_arms_config(name, fields):
|
||||
# to_server_spec builds each arm's config from a hand-written kwargs list, so an arm that
|
||||
# forgets to read the field fails silently: the server keeps writing to Authorization.
|
||||
spec = to_server_spec(_server(upstream_token_header="esb-oauth", **fields))
|
||||
assert spec is not None
|
||||
assert spec.config.header_name == "esb-oauth"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,fields", _ARM_FIELDS, ids=[n for n, _ in _ARM_FIELDS])
|
||||
def test_omitting_the_field_keeps_each_arms_shipped_default(name, fields):
|
||||
spec = to_server_spec(_server(**fields))
|
||||
assert spec is not None
|
||||
assert spec.config.header_name == "Authorization"
|
||||
|
||||
|
||||
def test_api_key_scheme_default_survives_when_the_field_is_unset():
|
||||
spec = to_server_spec(_server(auth_type=MCPAuth.api_key, authentication_token="k"))
|
||||
assert spec is not None
|
||||
assert spec.config.header_name == "X-API-Key"
|
||||
assert spec.config.value_prefix == ""
|
||||
|
||||
|
||||
def test_the_field_overrides_the_api_key_scheme_default():
|
||||
spec = to_server_spec(_server(auth_type=MCPAuth.api_key, authentication_token="k", upstream_token_header="X-Esb"))
|
||||
assert spec is not None
|
||||
assert spec.config.header_name == "X-Esb"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["with space", "has:colon", "trailing\r\nX-Injected", 'quoted"name'])
|
||||
def test_a_malformed_header_name_is_refused_when_the_server_is_built(bad):
|
||||
"""Validation belongs at ingestion, not at spec building. Raising inside to_server_spec would
|
||||
abort the whole aggregate tools/list, so one mistyped server would silently empty the tool list
|
||||
for every other server too. Refusing at MCPServer construction fails the config load loudly
|
||||
instead, and means no malformed value can ever reach an arm.
|
||||
"""
|
||||
with pytest.raises(ValidationError):
|
||||
_server(upstream_token_header=bad, **_M2M_FIELDS)
|
||||
|
||||
|
||||
def test_a_valid_header_name_is_trimmed_at_ingestion():
|
||||
assert _server(upstream_token_header=" esb-oauth ", **_M2M_FIELDS).upstream_token_header == "esb-oauth"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("blank", ["", " ", "\t"])
|
||||
def test_a_blank_header_name_means_unset_rather_than_an_error(blank):
|
||||
"""The management API treats a blank as "not supplied" and stores it, so raising here made every
|
||||
later rebuild of that server 500 instead of falling back to the default Authorization behavior.
|
||||
"""
|
||||
server = _server(upstream_token_header=blank, **_M2M_FIELDS)
|
||||
assert server.upstream_token_header is None
|
||||
spec = to_server_spec(server)
|
||||
assert spec is not None
|
||||
assert spec.config.header_name == "Authorization"
|
||||
|
|
|
|||
|
|
@ -341,7 +341,7 @@ async def test_bearer_auth_sends_the_token_and_leaves_a_success_alone():
|
|||
async def refetch(failed: str) -> "str | None":
|
||||
raise AssertionError("must not refetch on success")
|
||||
|
||||
auth = ClientCredentialsBearerAuth("m2m-token", refetch)
|
||||
auth = ClientCredentialsBearerAuth("m2m-token", refetch, ClientCredentialsConfig())
|
||||
async with httpx.AsyncClient(transport=transport, auth=auth) as client:
|
||||
response = await client.get("https://upstream.example.com/mcp")
|
||||
assert response.status_code == 200
|
||||
|
|
@ -357,7 +357,7 @@ async def test_bearer_auth_retries_a_401_once_with_a_fresh_token():
|
|||
refetched.append(failed)
|
||||
return "fresh-token"
|
||||
|
||||
auth = ClientCredentialsBearerAuth("stale-token", refetch)
|
||||
auth = ClientCredentialsBearerAuth("stale-token", refetch, ClientCredentialsConfig())
|
||||
async with httpx.AsyncClient(transport=transport, auth=auth) as client:
|
||||
response = await client.get("https://upstream.example.com/mcp")
|
||||
assert response.status_code == 200
|
||||
|
|
@ -377,7 +377,7 @@ async def test_bearer_auth_remembers_the_rotated_token_for_later_requests():
|
|||
refetched.append(failed)
|
||||
return "fresh-token"
|
||||
|
||||
auth = ClientCredentialsBearerAuth("stale-token", refetch)
|
||||
auth = ClientCredentialsBearerAuth("stale-token", refetch, ClientCredentialsConfig())
|
||||
async with httpx.AsyncClient(transport=transport, auth=auth) as client:
|
||||
first = await client.get("https://upstream.example.com/mcp")
|
||||
second = await client.get("https://upstream.example.com/mcp")
|
||||
|
|
@ -393,7 +393,7 @@ async def test_bearer_auth_surfaces_the_401_when_the_refetch_fails():
|
|||
async def refetch(failed: str) -> "str | None":
|
||||
return None
|
||||
|
||||
auth = ClientCredentialsBearerAuth("stale-token", refetch)
|
||||
auth = ClientCredentialsBearerAuth("stale-token", refetch, ClientCredentialsConfig())
|
||||
async with httpx.AsyncClient(transport=transport, auth=auth) as client:
|
||||
response = await client.get("https://upstream.example.com/mcp")
|
||||
assert response.status_code == 401
|
||||
|
|
@ -409,7 +409,7 @@ async def test_bearer_auth_gives_up_after_a_second_401():
|
|||
refetched.append(failed)
|
||||
return "fresh-token"
|
||||
|
||||
auth = ClientCredentialsBearerAuth("stale-token", refetch)
|
||||
auth = ClientCredentialsBearerAuth("stale-token", refetch, ClientCredentialsConfig())
|
||||
async with httpx.AsyncClient(transport=transport, auth=auth) as client:
|
||||
response = await client.get("https://upstream.example.com/mcp")
|
||||
assert response.status_code == 401
|
||||
|
|
@ -421,7 +421,60 @@ def test_bearer_auth_rejects_sync_clients():
|
|||
async def refetch(failed: str) -> "str | None":
|
||||
return None
|
||||
|
||||
auth = ClientCredentialsBearerAuth("token", refetch)
|
||||
auth = ClientCredentialsBearerAuth("token", refetch, ClientCredentialsConfig())
|
||||
with httpx.Client(transport=httpx.MockTransport(lambda request: httpx.Response(200)), auth=auth) as client:
|
||||
with pytest.raises(RuntimeError):
|
||||
client.get("https://upstream.example.com/mcp")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bearer_auth_writes_the_minted_token_to_the_configured_header():
|
||||
seen: "list[dict[str, str]]" = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(dict(request.headers))
|
||||
return httpx.Response(200)
|
||||
|
||||
async def refetch(failed: str) -> "str | None":
|
||||
raise AssertionError("must not refetch on success")
|
||||
|
||||
auth = ClientCredentialsBearerAuth("m2m-token", refetch, ClientCredentialsConfig(header_name="esb-oauth"))
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler), auth=auth) as client:
|
||||
await client.get("https://upstream.example.com/mcp")
|
||||
assert seen[0]["esb-oauth"] == "Bearer m2m-token"
|
||||
assert "authorization" not in seen[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_401_refetch_retry_also_targets_the_configured_header():
|
||||
# The retry is a SECOND write of the credential. Honoring the carrier only on the first write
|
||||
# would silently send the fresh token to Authorization, so the ESB rejects every recovered
|
||||
# request while the first attempt looked correct.
|
||||
seen: "list[dict[str, str]]" = []
|
||||
responses = [httpx.Response(401), httpx.Response(200)]
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(dict(request.headers))
|
||||
return responses[min(len(seen) - 1, len(responses) - 1)]
|
||||
|
||||
async def refetch(failed: str) -> "str | None":
|
||||
return "fresh-token"
|
||||
|
||||
auth = ClientCredentialsBearerAuth("stale-token", refetch, ClientCredentialsConfig(header_name="esb-oauth"))
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler), auth=auth) as client:
|
||||
response = await client.get("https://upstream.example.com/mcp")
|
||||
assert response.status_code == 200
|
||||
assert [h["esb-oauth"] for h in seen] == ["Bearer stale-token", "Bearer fresh-token"]
|
||||
assert all("authorization" not in h for h in seen)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bearer_auth_advertises_the_header_it_will_occupy():
|
||||
# _resolve_v2_auth reads header_name off the auth object to decide which injected header
|
||||
# conflicts; an auth object that lies about its slot would drop the wrong one.
|
||||
async def refetch(failed: str) -> "str | None":
|
||||
return None
|
||||
|
||||
assert ClientCredentialsBearerAuth("t", refetch, ClientCredentialsConfig()).header_name == "Authorization"
|
||||
default_carrier = ClientCredentialsConfig(header_name="esb-oauth")
|
||||
assert ClientCredentialsBearerAuth("t", refetch, default_carrier).header_name == "esb-oauth"
|
||||
|
|
|
|||
|
|
@ -1033,3 +1033,70 @@ async def test_invalidate_credentials_for_id_jag_is_a_noop_without_a_caller_toke
|
|||
assert isinstance(first, Ok) and isinstance(second, Ok)
|
||||
assert _emitted(second.ok)["Authorization"] == "Bearer cached-bearer"
|
||||
assert len(endpoint.calls) == 2
|
||||
|
||||
|
||||
async def _resolve_with_carrier(kind: str, header: str):
|
||||
"""Resolve one minted-token arm whose config targets ``header``."""
|
||||
if kind == "client_credentials":
|
||||
source = _FakeM2MSource(Ok(OAuthToken(access_token="minted")))
|
||||
config = _M2M.model_copy(update={"header_name": header})
|
||||
provider = UpstreamCredentialProvider(client_credentials_source=source)
|
||||
return await provider.resolve_credentials(_SUBJECT, _spec(config))
|
||||
if kind == "token_exchange":
|
||||
exchanger = _FakeExchanger(Ok(OAuthToken(access_token="minted")))
|
||||
config = _OBO.model_copy(update={"header_name": header})
|
||||
subject = Subject(tenant_id="acme", subject_id="alice", inbound_token=SecretStr("caller-jwt"))
|
||||
provider = UpstreamCredentialProvider(token_exchanger=exchanger)
|
||||
return await provider.resolve_credentials(subject, _spec(config))
|
||||
if kind == "authorization_code":
|
||||
store = _FakeTokenStore({("alice", "s"): OAuthToken(access_token="minted")})
|
||||
provider = UpstreamCredentialProvider(oauth_token_store=store)
|
||||
return await provider.resolve_credentials(
|
||||
Subject(tenant_id="", subject_id="alice"),
|
||||
_spec(AuthorizationCodeConfig(header_name=header)),
|
||||
)
|
||||
endpoint = _FakeTokenEndpoint(
|
||||
[
|
||||
Ok(ExchangedToken(access_token="id-jag-assertion", expires_in=300)),
|
||||
Ok(ExchangedToken(access_token="minted", expires_in=300)),
|
||||
]
|
||||
)
|
||||
config = _id_jag_config().model_copy(update={"header_name": header})
|
||||
subject = Subject(tenant_id="acme", subject_id="alice", inbound_token=SecretStr("caller-id-token"))
|
||||
provider = UpstreamCredentialProvider(token_endpoint=endpoint)
|
||||
return await provider.resolve_credentials(subject, _spec(config))
|
||||
|
||||
|
||||
_MINTED_ARMS = ("client_credentials", "token_exchange", "authorization_code", "id_jag")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", _MINTED_ARMS)
|
||||
@pytest.mark.asyncio
|
||||
async def test_every_minted_arm_emits_its_configured_header(kind):
|
||||
# One arm left on a hardcoded Authorization is a silent no-op for exactly the server that
|
||||
# configured the knob, so this is asserted across all four rather than on the M2M arm alone.
|
||||
result = await _resolve_with_carrier(kind, "esb-oauth")
|
||||
assert isinstance(result, Ok)
|
||||
headers, _ = await _emitted_async(result.ok)
|
||||
assert headers["esb-oauth"] == "Bearer minted"
|
||||
assert "authorization" not in headers
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", _MINTED_ARMS)
|
||||
@pytest.mark.asyncio
|
||||
async def test_every_minted_arm_still_defaults_to_authorization(kind):
|
||||
result = await _resolve_with_carrier(kind, "Authorization")
|
||||
assert isinstance(result, Ok)
|
||||
headers, _ = await _emitted_async(result.ok)
|
||||
assert headers["Authorization"] == "Bearer minted"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_passthrough_ignores_the_carrier_and_keeps_the_callers_slot():
|
||||
# Passthrough mints nothing: it forwards the caller's own credential, so it has no carrier to
|
||||
# configure and must keep using the header the caller aimed it at.
|
||||
subject = Subject(tenant_id="", subject_id="", inbound_token=SecretStr("caller-token"))
|
||||
result = await UpstreamCredentialProvider().resolve_credentials(subject, _spec(PassthroughConfig()))
|
||||
assert isinstance(result, Ok)
|
||||
headers, _ = await _emitted_async(result.ok)
|
||||
assert headers["Authorization"] == "caller-token"
|
||||
|
|
|
|||
|
|
@ -14,9 +14,11 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials import (
|
|||
Ambient,
|
||||
ApiKeyConfig,
|
||||
AuthConfig,
|
||||
AuthorizationCodeConfig,
|
||||
AuthSpecKind,
|
||||
AwsSigV4Config,
|
||||
Byok,
|
||||
ClientCredentialsConfig,
|
||||
ClientSecretAuth,
|
||||
CredError,
|
||||
Error,
|
||||
|
|
@ -27,7 +29,9 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials import (
|
|||
ServerSpec,
|
||||
SharedKey,
|
||||
StaticKeys,
|
||||
TokenExchangeConfig,
|
||||
parse_auth_spec_kind,
|
||||
validate_header_name,
|
||||
)
|
||||
|
||||
_AUTH_CONFIG = TypeAdapter(AuthConfig)
|
||||
|
|
@ -229,3 +233,61 @@ def test_id_jag_server_spec_derives_auth_spec_kind():
|
|||
config=config,
|
||||
)
|
||||
assert spec.auth_spec_kind is AuthSpecKind.id_jag
|
||||
|
||||
|
||||
_CARRIER_CONFIGS = (
|
||||
("client_credentials", ClientCredentialsConfig),
|
||||
("token_exchange", lambda **kw: TokenExchangeConfig(token_exchange_endpoint="https://idp/te", **kw)),
|
||||
("authorization_code", AuthorizationCodeConfig),
|
||||
(
|
||||
"id_jag",
|
||||
lambda **kw: IdJagConfig(
|
||||
org_token_endpoint="https://idp.example.com/token",
|
||||
resource_token_endpoint="https://mcp-as.example.com/token",
|
||||
client_id="litellm",
|
||||
client_auth=ClientSecretAuth(client_secret=SecretStr("s")),
|
||||
**kw,
|
||||
),
|
||||
),
|
||||
("api_key", lambda **kw: ApiKeyConfig(key_source=SharedKey(value=SecretStr("k")), **kw)),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,build", _CARRIER_CONFIGS, ids=[n for n, _ in _CARRIER_CONFIGS])
|
||||
def test_every_resolved_credential_config_defaults_to_rfc6750_authorization(name, build):
|
||||
# The default is what preserves today's wire behavior for every existing server.
|
||||
assert build().header("tok") == ("Authorization", "Bearer tok")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,build", _CARRIER_CONFIGS, ids=[n for n, _ in _CARRIER_CONFIGS])
|
||||
def test_every_resolved_credential_config_honors_a_custom_header(name, build):
|
||||
assert build(header_name="esb-oauth").header("tok") == ("esb-oauth", "Bearer tok")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,build", _CARRIER_CONFIGS, ids=[n for n, _ in _CARRIER_CONFIGS])
|
||||
def test_every_resolved_credential_config_can_send_a_raw_value(name, build):
|
||||
assert build(header_name="esb-oauth", value_prefix="").header("tok") == ("esb-oauth", "tok")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad",
|
||||
[
|
||||
"with space",
|
||||
"has:colon",
|
||||
"trailing\r\nX-Injected",
|
||||
"",
|
||||
" ",
|
||||
"quoted\"name",
|
||||
],
|
||||
)
|
||||
def test_header_name_outside_the_rfc7230_token_grammar_is_rejected(bad):
|
||||
# An operator-supplied name reaches egress verbatim, so anything that could split a
|
||||
# header must fail closed at construction rather than be sanitized later.
|
||||
with pytest.raises(ValidationError):
|
||||
ClientCredentialsConfig(header_name=bad)
|
||||
assert isinstance(validate_header_name(bad), Error)
|
||||
|
||||
|
||||
def test_header_name_is_trimmed_by_the_one_validator():
|
||||
assert validate_header_name(" esb-oauth ") == Ok("esb-oauth")
|
||||
assert ClientCredentialsConfig(header_name=" esb-oauth ").header_name == "esb-oauth"
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
|
|||
_obo_retry_applies,
|
||||
_resolve_openapi_tool_auth,
|
||||
_should_strip_caller_authorization,
|
||||
_without_authorization,
|
||||
)
|
||||
from litellm.proxy._types import (
|
||||
LiteLLM_MCPServerTable,
|
||||
|
|
@ -2405,6 +2404,104 @@ class TestMCPServerManager:
|
|||
assert client._resolved_auth is not None
|
||||
assert "authorization" not in {k.lower() for k in (client.extra_headers or {})}
|
||||
|
||||
@staticmethod
|
||||
def _esb_server(header: "str | None") -> MCPServer:
|
||||
return MCPServer(
|
||||
server_id="esb",
|
||||
name="esb-server",
|
||||
url="https://up.example.com/mcp",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.oauth2,
|
||||
oauth2_flow="client_credentials",
|
||||
client_id="cid",
|
||||
client_secret="csec",
|
||||
token_url="https://idp.example.com/token",
|
||||
upstream_token_header=header,
|
||||
static_headers={"Authorization": "Bearer static-upstream-mcp-token"},
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_static_authorization_survives_a_minted_token_aimed_elsewhere(self):
|
||||
"""The dual-credential case: an ESB wants the gateway-minted token on its own header while a
|
||||
separate static Authorization passes through to the origin. Dropping Authorization here (the
|
||||
old name-blind behavior) deletes the second credential and the upstream 401s."""
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.httpx_auth import (
|
||||
StaticHeaderAuth,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.result import Ok
|
||||
|
||||
class _FakeProvider:
|
||||
async def resolve_credentials(self, subject, server):
|
||||
return Ok(StaticHeaderAuth("Bearer MINTED-M2M", header_name="esb-oauth"))
|
||||
|
||||
manager = MCPServerManager(cred_provider=_FakeProvider())
|
||||
client = await manager._create_mcp_client(
|
||||
self._esb_server("esb-oauth"),
|
||||
extra_headers={"Authorization": "Bearer static-upstream-mcp-token"},
|
||||
)
|
||||
|
||||
assert client._resolved_auth is not None
|
||||
assert (client.extra_headers or {})["Authorization"] == "Bearer static-upstream-mcp-token"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_minted_token_aimed_at_the_static_header_still_wins_that_slot(self):
|
||||
"""The negative class of the test above: when the two DO collide the resolver-owned
|
||||
credential is still authoritative, so the knob cannot be used to smuggle a second
|
||||
credential into the same slot."""
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.httpx_auth import (
|
||||
StaticHeaderAuth,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.result import Ok
|
||||
|
||||
class _FakeProvider:
|
||||
async def resolve_credentials(self, subject, server):
|
||||
return Ok(StaticHeaderAuth("Bearer MINTED-M2M", header_name="esb-oauth"))
|
||||
|
||||
manager = MCPServerManager(cred_provider=_FakeProvider())
|
||||
client = await manager._create_mcp_client(
|
||||
self._esb_server("esb-oauth"),
|
||||
extra_headers={"esb-oauth": "Bearer signer-jwt", "X-Trace": "keep-me"},
|
||||
)
|
||||
|
||||
assert client._resolved_auth is not None
|
||||
assert "esb-oauth" not in {k.lower() for k in (client.extra_headers or {})}
|
||||
assert (client.extra_headers or {})["X-Trace"] == "keep-me"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_differently_cased_injected_header_is_still_recognised_as_the_collision(self):
|
||||
"""HTTP header names are case-insensitive, so the conflict check must be too.
|
||||
|
||||
A case-sensitive check reports no conflict and hands the injected header back untouched, so
|
||||
the returned extra_headers still carries a second copy of the credential slot for every
|
||||
downstream consumer of that dict. httpx happens to collapse the two on the wire, which is
|
||||
exactly why this needs pinning rather than being left to luck.
|
||||
"""
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.httpx_auth import (
|
||||
StaticHeaderAuth,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.result import Ok
|
||||
|
||||
class _FakeProvider:
|
||||
async def resolve_credentials(self, subject, server):
|
||||
return Ok(StaticHeaderAuth("Bearer MINTED", header_name="esb-oauth"))
|
||||
|
||||
manager = MCPServerManager(cred_provider=_FakeProvider())
|
||||
client = await manager._create_mcp_client(
|
||||
self._esb_server("esb-oauth"),
|
||||
extra_headers={"ESB-OAuth": "Bearer injected", "X-Trace": "keep"},
|
||||
)
|
||||
|
||||
assert client._resolved_auth is not None
|
||||
assert not any(k.lower() == "esb-oauth" for k in (client.extra_headers or {}))
|
||||
assert (client.extra_headers or {})["X-Trace"] == "keep"
|
||||
|
||||
def test_without_header_drops_only_the_named_header(self):
|
||||
from litellm.types.mcp import DEFAULT_CREDENTIAL_HEADER, without_header
|
||||
|
||||
headers = {"Authorization": "Bearer a", "esb-oauth": "Bearer b", "X-Trace": "t"}
|
||||
assert without_header(headers, "ESB-OAuth") == {"Authorization": "Bearer a", "X-Trace": "t"}
|
||||
assert without_header(headers, DEFAULT_CREDENTIAL_HEADER) == {"esb-oauth": "Bearer b", "X-Trace": "t"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preflight_token_exchange_challenges_on_rejected_subject(self):
|
||||
"""A subject the IdP rejects must raise the RFC 9728 401 challenge from the preflight, so a
|
||||
|
|
@ -2624,14 +2721,16 @@ class TestMCPServerManager:
|
|||
if captured_extra_headers:
|
||||
assert "authorization" not in {k.lower() for k in captured_extra_headers}
|
||||
|
||||
def test_without_authorization_drops_only_the_credential(self):
|
||||
def test_without_header_drops_only_the_credential(self):
|
||||
from litellm.types.mcp import without_header
|
||||
|
||||
# None / empty -> None
|
||||
assert _without_authorization(None) is None
|
||||
assert _without_authorization({}) is None
|
||||
assert without_header(None, "Authorization") is None
|
||||
assert without_header({}, "Authorization") is None
|
||||
# Only Authorization present -> nothing left -> None (case-insensitive)
|
||||
assert _without_authorization({"authorization": "Bearer x"}) is None
|
||||
assert without_header({"authorization": "Bearer x"}, "Authorization") is None
|
||||
# Authorization dropped, other headers kept
|
||||
assert _without_authorization({"Authorization": "Bearer x", "X-Trace-Id": "t"}) == {"X-Trace-Id": "t"}
|
||||
assert without_header({"Authorization": "Bearer x", "X-Trace-Id": "t"}, "Authorization") == {"X-Trace-Id": "t"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_regular_mcp_tool_passthrough_forwards_authorization_with_admission_header(
|
||||
|
|
@ -9641,13 +9740,38 @@ class TestMaterializeAuthHeaders:
|
|||
from litellm.proxy._experimental.mcp_server.outbound_credentials.client_credentials import (
|
||||
ClientCredentialsBearerAuth,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.types import (
|
||||
ClientCredentialsConfig,
|
||||
)
|
||||
|
||||
async def _refetch(_stale: str):
|
||||
return None
|
||||
|
||||
headers = await _materialize_auth_headers(ClientCredentialsBearerAuth("m2m-token", _refetch))
|
||||
default_carrier = ClientCredentialsConfig()
|
||||
headers = await _materialize_auth_headers(ClientCredentialsBearerAuth("m2m-token", _refetch, default_carrier))
|
||||
assert headers == {"Authorization": "Bearer m2m-token"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_materialize_follows_the_minted_token_to_a_custom_header(self):
|
||||
# The OpenAPI arm reads header_name off the auth object rather than assuming Authorization,
|
||||
# so it carries the knob with no per-arm change. This pins that it stays that way.
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
|
||||
_materialize_auth_headers,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.client_credentials import (
|
||||
ClientCredentialsBearerAuth,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.types import (
|
||||
ClientCredentialsConfig,
|
||||
)
|
||||
|
||||
async def _refetch(_stale: str):
|
||||
return None
|
||||
|
||||
esb_carrier = ClientCredentialsConfig(header_name="esb-oauth")
|
||||
headers = await _materialize_auth_headers(ClientCredentialsBearerAuth("m2m-token", _refetch, esb_carrier))
|
||||
assert headers == {"esb-oauth": "Bearer m2m-token"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_noop_and_none_materialize_to_none(self):
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import pytest
|
|||
from litellm.proxy._experimental.mcp_server.oauth2_token_cache import (
|
||||
MCPOAuth2TokenCache,
|
||||
resolve_mcp_auth,
|
||||
resolved_token_header,
|
||||
)
|
||||
from litellm.proxy._types import MCPTransport
|
||||
from litellm.types.mcp import MCPAuth
|
||||
|
|
@ -411,3 +412,50 @@ async def test_m2m_mint_uses_admin_entered_token_url_when_issuer_yield_empties_r
|
|||
|
||||
assert result == "m2m-token-configured"
|
||||
assert mock_client.post.call_args[0][0] == "https://auth.example.com/token"
|
||||
|
||||
|
||||
def _m2m_server(**overrides):
|
||||
from litellm.types.mcp import MCPAuth, MCPTransport
|
||||
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
||||
|
||||
fields = dict(
|
||||
server_id="s",
|
||||
name="n",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.oauth2,
|
||||
oauth2_flow="client_credentials",
|
||||
client_id="cid",
|
||||
client_secret="csec",
|
||||
token_url="https://idp.example.com/token",
|
||||
)
|
||||
fields.update(overrides)
|
||||
return MCPServer(**fields)
|
||||
|
||||
|
||||
def test_resolved_token_header_follows_the_configured_header_for_a_gateway_resolved_token():
|
||||
# resolve_mcp_auth mints the M2M token on this branch, so the value is the gateway's own and
|
||||
# follows upstream_token_header.
|
||||
assert resolved_token_header(_m2m_server(upstream_token_header="esb-oauth")) == "esb-oauth"
|
||||
|
||||
|
||||
def test_resolved_token_header_is_none_when_the_server_configures_nothing():
|
||||
assert resolved_token_header(_m2m_server()) is None
|
||||
|
||||
|
||||
def test_a_caller_supplied_credential_never_moves():
|
||||
# The caller aimed their own token at the slot the upstream normally uses. Relocating it would
|
||||
# break every existing x-mcp-auth caller on a server that sets the field for its own token.
|
||||
server = _m2m_server(upstream_token_header="esb-oauth")
|
||||
assert resolved_token_header(server, "Bearer caller-token") is None
|
||||
assert resolved_token_header(server, {"Authorization": "Bearer caller-token"}) is None
|
||||
|
||||
|
||||
def test_the_header_and_the_value_agree_on_which_branch_they_took():
|
||||
# The two helpers are read as a pair at one call site, so they must never disagree about
|
||||
# whether the credential came from the caller or from the server's own config.
|
||||
import asyncio
|
||||
|
||||
server = _m2m_server(upstream_token_header="esb-oauth", authentication_token="static-tok")
|
||||
caller = "Bearer caller-token"
|
||||
assert asyncio.run(resolve_mcp_auth(server, caller)) == caller
|
||||
assert resolved_token_header(server, caller) is None
|
||||
|
|
|
|||
|
|
@ -729,3 +729,121 @@ async def test_local_dispatch_reports_the_outcome_instead_of_success(failure: st
|
|||
# A non-auth upstream failure stays a 200 with isError, so REST does not report it as a gateway 500
|
||||
assert result.isError is True
|
||||
assert "upstream returned HTTP 429" in result.content[0].text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"resolved,expect_guard",
|
||||
[
|
||||
({"esb-oauth": "Bearer minted"}, True),
|
||||
({"Authorization": "Bearer minted"}, False),
|
||||
({}, False),
|
||||
],
|
||||
)
|
||||
def test_only_a_custom_credential_slot_needs_the_redirect_guard(resolved, expect_guard):
|
||||
"""The OpenAPI arm sends resolved credentials through a redirect-following client, so a custom
|
||||
slot needs the same cross-origin guard the MCP client installs. Authorization does not: the HTTP
|
||||
client already strips that one, and taking the guarded path would give up the shared client.
|
||||
"""
|
||||
from litellm.types.mcp import DEFAULT_CREDENTIAL_HEADER, same_header
|
||||
|
||||
guarded = next((n for n in resolved if not same_header(n, DEFAULT_CREDENTIAL_HEADER)), None)
|
||||
assert (guarded is not None) is expect_guard
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_openapi_arm_drops_a_custom_slot_across_origins():
|
||||
"""End to end on the hook the OpenAPI arm installs: same origin keeps the credential, a redirect
|
||||
to another host does not carry it.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
from litellm.types.mcp import credential_redirect_hook
|
||||
|
||||
hook = credential_redirect_hook("https://api.example.com/v1/things", "esb-oauth")
|
||||
|
||||
same = httpx.Request("POST", "https://api.example.com/v1/other", headers={"esb-oauth": "Bearer m"})
|
||||
await hook(same)
|
||||
assert same.headers["esb-oauth"] == "Bearer m"
|
||||
|
||||
foreign = httpx.Request("POST", "https://attacker.example.com/collect", headers={"esb-oauth": "Bearer m"})
|
||||
await hook(foreign)
|
||||
assert "esb-oauth" not in foreign.headers
|
||||
|
||||
|
||||
def test_the_openapi_arm_installs_the_guard_when_a_credential_rides_a_custom_slot():
|
||||
"""Pins the wiring, not just the hook: the arm must actually build a guarded client. Testing the
|
||||
hook alone passes even if this arm never installs it.
|
||||
"""
|
||||
from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
|
||||
_request_resolved_auth_headers,
|
||||
_upstream_client,
|
||||
)
|
||||
|
||||
token = _request_resolved_auth_headers.set({"esb-oauth": "Bearer minted"})
|
||||
try:
|
||||
client = _upstream_client()
|
||||
assert client.client.event_hooks["request"], "custom slot must install a redirect guard"
|
||||
finally:
|
||||
_request_resolved_auth_headers.reset(token)
|
||||
|
||||
|
||||
def test_the_guarded_client_is_reused_rather_than_built_per_call():
|
||||
"""A fresh handler per guarded call is never closed, so every OpenAPI tool call on a server that
|
||||
sets upstream_token_header would leak an httpx client and its connection pool. Both variants
|
||||
have to come from the shared cache.
|
||||
"""
|
||||
from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
|
||||
_request_resolved_auth_headers,
|
||||
_upstream_client,
|
||||
)
|
||||
|
||||
token = _request_resolved_auth_headers.set({"esb-oauth": "Bearer minted"})
|
||||
try:
|
||||
assert _upstream_client() is _upstream_client()
|
||||
finally:
|
||||
_request_resolved_auth_headers.reset(token)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_shared_guard_reads_the_url_from_the_request_context():
|
||||
"""The hook is one stable object so the client stays cacheable, which means the origin it guards
|
||||
against has to arrive per request rather than being closed over.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
|
||||
_drop_credential_across_origin,
|
||||
_request_resolved_auth_headers,
|
||||
_request_upstream_url,
|
||||
)
|
||||
|
||||
creds = _request_resolved_auth_headers.set({"esb-oauth": "Bearer minted"})
|
||||
url = _request_upstream_url.set("https://api.example.com/v1/things")
|
||||
try:
|
||||
same = httpx.Request("POST", "https://api.example.com/v1/other", headers={"esb-oauth": "Bearer m"})
|
||||
await _drop_credential_across_origin(same)
|
||||
assert same.headers["esb-oauth"] == "Bearer m"
|
||||
|
||||
foreign = httpx.Request("POST", "https://attacker.example.com/x", headers={"esb-oauth": "Bearer m"})
|
||||
await _drop_credential_across_origin(foreign)
|
||||
assert "esb-oauth" not in foreign.headers
|
||||
finally:
|
||||
_request_upstream_url.reset(url)
|
||||
_request_resolved_auth_headers.reset(creds)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("resolved", [{"Authorization": "Bearer minted"}, {}, None])
|
||||
def test_the_openapi_arm_keeps_the_shared_client_when_no_guard_is_needed(resolved):
|
||||
# Authorization is already stripped across origins by the HTTP client, so taking the guarded
|
||||
# path for it would give up the shared connection pool for nothing.
|
||||
from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
|
||||
_request_resolved_auth_headers,
|
||||
_upstream_client,
|
||||
)
|
||||
|
||||
token = _request_resolved_auth_headers.set(resolved)
|
||||
try:
|
||||
client = _upstream_client()
|
||||
assert not client.client.event_hooks.get("request")
|
||||
finally:
|
||||
_request_resolved_auth_headers.reset(token)
|
||||
|
|
|
|||
|
|
@ -1573,6 +1573,7 @@ class TestTemporaryMCPSessionEndpoints:
|
|||
existing_server.aws_region_name = None
|
||||
existing_server.aws_service_name = None
|
||||
existing_server.upstream_resource = None
|
||||
existing_server.upstream_token_header = None
|
||||
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_mcp_server_by_id.return_value = existing_server
|
||||
|
|
@ -1608,6 +1609,7 @@ class TestTemporaryMCPSessionEndpoints:
|
|||
existing_server.aws_region_name = None
|
||||
existing_server.aws_service_name = None
|
||||
existing_server.upstream_resource = None
|
||||
existing_server.upstream_token_header = None
|
||||
for key, value in server_overrides.items():
|
||||
setattr(existing_server, key, value)
|
||||
|
||||
|
|
@ -1639,6 +1641,23 @@ class TestTemporaryMCPSessionEndpoints:
|
|||
assert updated.credentials["client_id"] == "client-123"
|
||||
assert updated.credentials["client_secret"] == "secret-xyz"
|
||||
|
||||
def test_upstream_token_header_is_inherited_like_other_admin_config(self):
|
||||
"""It is admin config rather than a credential, so a session server derived from an existing
|
||||
one must carry it. Miss it and the derived server silently sends its token to Authorization
|
||||
while the original sends it to the gateway's header."""
|
||||
updated = self._inherit_with({}, upstream_token_header="esb-oauth")
|
||||
|
||||
assert updated.credentials["upstream_token_header"] == "esb-oauth"
|
||||
|
||||
def test_a_supplied_upstream_token_header_does_not_read_as_a_credential(self):
|
||||
"""It is in the admin-config key set, so submitting only it must still inherit the declared
|
||||
app rather than reading as "the caller supplied real credentials"."""
|
||||
updated = self._inherit_with({"upstream_token_header": "esb-oauth"})
|
||||
|
||||
assert updated.credentials["client_id"] == "client-123"
|
||||
assert updated.credentials["client_secret"] == "secret-xyz"
|
||||
assert updated.credentials["upstream_token_header"] == "esb-oauth"
|
||||
|
||||
def test_supplied_credential_still_wins_over_inheritance(self):
|
||||
"""A caller that supplies a real credential keeps it; inheritance must not overwrite it."""
|
||||
updated = self._inherit_with({"auth_value": "caller-token"})
|
||||
|
|
@ -2256,6 +2275,7 @@ class TestTemporaryMCPSessionEndpoints:
|
|||
aws_region_name=None,
|
||||
aws_service_name=None,
|
||||
upstream_resource=None,
|
||||
upstream_token_header=None,
|
||||
)
|
||||
built_server = generate_mock_mcp_server_config_record(server_id="temp-server")
|
||||
mock_manager = MagicMock()
|
||||
|
|
|
|||
87
tests/test_litellm/types/test_mcp.py
Normal file
87
tests/test_litellm/types/test_mcp.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""Tests for the shared MCP header primitives.
|
||||
|
||||
``same_header`` / ``has_header`` / ``without_header`` are the one owner of "is this the credential's
|
||||
header", used by both MCP stacks and the upstream-credential resolver. They live here rather than in
|
||||
either stack because a second implementation is exactly how an injected header came to shadow a
|
||||
resolved credential on one path and not the other.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.types.mcp import (
|
||||
credential_redirect_hook,
|
||||
crosses_origin,
|
||||
has_header,
|
||||
same_header,
|
||||
without_header,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"a,b,expected",
|
||||
[
|
||||
("Authorization", "authorization", True),
|
||||
("ESB-OAuth", "esb-oauth", True),
|
||||
("esb-oauth", "esb-oauth", True),
|
||||
("esb-oauth", "esb_oauth", False),
|
||||
("esb-oauth", "Authorization", False),
|
||||
],
|
||||
)
|
||||
def test_header_names_compare_case_insensitively(a: str, b: str, expected: bool) -> None:
|
||||
# RFC 7230 3.2. Every consumer of a credential slot routes through this, so a case-sensitive
|
||||
# comparison anywhere would let an injected header shadow a resolved credential.
|
||||
assert same_header(a, b) is expected
|
||||
|
||||
|
||||
def test_without_header_drops_every_casing_and_keeps_the_rest() -> None:
|
||||
headers = {"ESB-OAuth": "injected", "esb-oauth": "also injected", "X-Trace": "keep"}
|
||||
assert without_header(headers, "esb-oauth") == {"X-Trace": "keep"}
|
||||
|
||||
|
||||
def test_without_header_collapses_to_none_when_nothing_remains() -> None:
|
||||
assert without_header({"Authorization": "Bearer x"}, "AUTHORIZATION") is None
|
||||
assert without_header(None, "esb-oauth") is None
|
||||
assert without_header({}, "esb-oauth") is None
|
||||
|
||||
|
||||
def test_has_header_matches_any_casing() -> None:
|
||||
assert has_header({"ESB-OAuth": "v"}, "esb-oauth") is True
|
||||
assert has_header({"X-Other": "v"}, "esb-oauth") is False
|
||||
assert has_header(None, "esb-oauth") is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"target,expected",
|
||||
[
|
||||
("https://upstream.example.com/other", False), # same origin
|
||||
("https://upstream.example.com:443/other", False), # explicit default port
|
||||
("https://attacker.example.com/collect", True), # different host
|
||||
("http://upstream.example.com/collect", True), # scheme downgrade, same host
|
||||
("https://upstream.example.com:8443/other", True), # different port, same host
|
||||
("https://sub.upstream.example.com/x", True), # different host
|
||||
],
|
||||
)
|
||||
def test_origin_is_scheme_host_and_port_not_host_alone(target: str, expected: bool) -> None:
|
||||
assert crosses_origin("https://upstream.example.com/mcp", target) is expected
|
||||
|
||||
|
||||
def test_an_https_upgrade_of_the_same_host_is_not_crossing() -> None:
|
||||
# HTTP clients exempt this when deciding to keep Authorization, so a credential slot that did
|
||||
# not would lose the credential on every such redirect.
|
||||
assert crosses_origin("http://upstream.example.com/mcp", "https://upstream.example.com/x") is False
|
||||
assert crosses_origin("http://upstream.example.com/mcp", "http://upstream.example.com/x") is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_hook_drops_the_slot_only_once_the_origin_changes() -> None:
|
||||
import httpx
|
||||
|
||||
hook = credential_redirect_hook("https://upstream.example.com/mcp", "esb-oauth")
|
||||
|
||||
same = httpx.Request("GET", "https://upstream.example.com/other", headers={"esb-oauth": "Bearer x"})
|
||||
await hook(same)
|
||||
assert same.headers["esb-oauth"] == "Bearer x"
|
||||
|
||||
foreign = httpx.Request("GET", "https://attacker.example.com/x", headers={"esb-oauth": "Bearer x"})
|
||||
await hook(foreign)
|
||||
assert "esb-oauth" not in foreign.headers
|
||||
|
|
@ -3,6 +3,7 @@ import React from "react";
|
|||
import { SimpleTooltip } from "@/components/ui/tooltip";
|
||||
|
||||
import { MountedFormField } from "@/components/common_components/MountedFormField";
|
||||
import UpstreamTokenHeaderField from "./UpstreamTokenHeaderField";
|
||||
import { requiredRule } from "@/components/common_components/formRules";
|
||||
import { MultiSelect } from "@/components/shared/MultiSelect";
|
||||
import { PasswordInput } from "@/components/shared/PasswordInput";
|
||||
|
|
@ -205,6 +206,7 @@ const IdJagFormFields: React.FC<IdJagFormFieldsProps> = ({ isEditing = false })
|
|||
>
|
||||
{(control) => <MultiSelect {...tagsControl(control)} placeholder="Add scopes" className="rounded-lg" />}
|
||||
</MountedFormField>
|
||||
<UpstreamTokenHeaderField />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -286,4 +286,42 @@ describe("OAuthFormFields", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("token header field", () => {
|
||||
it("renders on the M2M flow", () => {
|
||||
render(
|
||||
<WithForm>
|
||||
<OAuthFormFields isM2M={true} />
|
||||
</WithForm>,
|
||||
);
|
||||
expect(screen.getByPlaceholderText("Authorization")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders on the interactive flow", () => {
|
||||
render(
|
||||
<WithForm>
|
||||
<OAuthFormFields isM2M={false} />
|
||||
</WithForm>,
|
||||
);
|
||||
expect(screen.getByPlaceholderText("Authorization")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submits its value under credentials.upstream_token_header", async () => {
|
||||
const onFinish = vi.fn();
|
||||
render(
|
||||
<WithForm onFinish={onFinish}>
|
||||
<OAuthFormFields isM2M={true} />
|
||||
</WithForm>,
|
||||
);
|
||||
fireEvent.change(screen.getByPlaceholderText("Authorization"), { target: { value: "esb-oauth" } });
|
||||
fireEvent.click(screen.getByText("Submit"));
|
||||
await waitFor(() => {
|
||||
expect(onFinish).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
credentials: expect.objectContaining({ upstream_token_header: "esb-oauth" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { OAUTH_FLOW } from "@/components/mcp_tools/types";
|
|||
import { MountedFormField } from "@/components/common_components/MountedFormField";
|
||||
import { requiredRule } from "@/components/common_components/formRules";
|
||||
import TokenEndpointAuthMethodField from "./TokenEndpointAuthMethodField";
|
||||
import UpstreamTokenHeaderField from "./UpstreamTokenHeaderField";
|
||||
import {
|
||||
numberControl,
|
||||
parsesAsJson,
|
||||
|
|
@ -175,6 +176,7 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
|
|||
{(control) => <MultiSelect {...tagsControl(control)} placeholder="Add scopes" className="rounded-lg" />}
|
||||
</MountedFormField>
|
||||
<UpstreamResourceField />
|
||||
<UpstreamTokenHeaderField />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -237,6 +239,7 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
|
|||
{(control) => <MultiSelect {...tagsControl(control)} placeholder="Add scopes" className="rounded-lg" />}
|
||||
</MountedFormField>
|
||||
<UpstreamResourceField />
|
||||
<UpstreamTokenHeaderField />
|
||||
<MountedFormField
|
||||
label={
|
||||
<FieldLabel
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { SimpleTooltip } from "@/components/ui/tooltip";
|
|||
import { useWatch } from "react-hook-form";
|
||||
|
||||
import { MountedFormField } from "@/components/common_components/MountedFormField";
|
||||
import UpstreamTokenHeaderField from "./UpstreamTokenHeaderField";
|
||||
import { requiredRule } from "@/components/common_components/formRules";
|
||||
import { PasswordInput } from "@/components/shared/PasswordInput";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -184,6 +185,7 @@ const TokenExchangeFormFields: React.FC<TokenExchangeFormFieldsProps> = ({ isEdi
|
|||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<UpstreamTokenHeaderField />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
import { Info } from "lucide-react";
|
||||
import React from "react";
|
||||
import { SimpleTooltip } from "@/components/ui/tooltip";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
import { MountedFormField } from "@/components/common_components/MountedFormField";
|
||||
import { textControl } from "./mcpFieldRules";
|
||||
|
||||
const UpstreamTokenHeaderField: React.FC = () => (
|
||||
<MountedFormField
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground flex items-center">
|
||||
Token Header (optional)
|
||||
<SimpleTooltip content="Which upstream header carries the token LiteLLM resolves for this server. Leave blank to send it as 'Authorization: Bearer <token>', which is the default and what most servers expect. Set a header name when the upstream expects it elsewhere, for example an API gateway that terminates its own credential on 'esb-oauth' while a separate Authorization from Static Headers passes through to the server behind it.">
|
||||
<Info className="ml-2 size-4 text-info hover:text-info/80 cursor-help" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
name={["credentials", "upstream_token_header"]}
|
||||
>
|
||||
{(control) => (
|
||||
<Input
|
||||
{...textControl(control)}
|
||||
placeholder="Authorization"
|
||||
className="rounded-lg border-border focus:border-info focus:ring-ring"
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
);
|
||||
|
||||
export default UpstreamTokenHeaderField;
|
||||
|
|
@ -255,13 +255,28 @@ export const CASES: readonly DifferentialCase[] = [
|
|||
},
|
||||
|
||||
// --- credentials filtering ---
|
||||
// ADMIN_CONFIG_CREDENTIAL_KEYS is exactly ["upstream_resource"], so only that key
|
||||
// takes the blank-to-explicit-null branch. A blank client_id is dropped instead.
|
||||
// Only a key in ADMIN_CONFIG_CREDENTIAL_KEYS takes the blank-to-explicit-null branch, which is
|
||||
// what makes it clearable: the backend merge preserves an omitted key forever. A blank client_id
|
||||
// is dropped instead.
|
||||
{
|
||||
label: "blank upstream_resource becomes an explicit null",
|
||||
values: { ...ROOT, auth_type: "oauth2", credentials: { upstream_resource: "", client_secret: "keep" } },
|
||||
ui: {},
|
||||
},
|
||||
{
|
||||
label: "blank upstream_token_header becomes an explicit null",
|
||||
values: { ...ROOT, auth_type: "oauth2", credentials: { upstream_token_header: "", client_secret: "keep" } },
|
||||
ui: {},
|
||||
},
|
||||
{
|
||||
label: "a set upstream_token_header rides the credentials blob",
|
||||
values: {
|
||||
...ROOT,
|
||||
auth_type: "oauth2",
|
||||
credentials: { upstream_token_header: "esb-oauth", client_secret: "keep" },
|
||||
},
|
||||
ui: {},
|
||||
},
|
||||
{
|
||||
label: "blank non-admin credential is dropped, not nulled",
|
||||
values: { ...ROOT, auth_type: "oauth2", credentials: { client_id: "", client_secret: "keep", scopes: [] } },
|
||||
|
|
|
|||
|
|
@ -262,7 +262,14 @@ describe("edit root: exact mounted set per auth configuration", () => {
|
|||
...PERMS,
|
||||
"delegate_auth_to_upstream",
|
||||
],
|
||||
credentials: ["client_id", "client_secret", "token_endpoint_auth_method", "scopes", "upstream_resource"],
|
||||
credentials: [
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"token_endpoint_auth_method",
|
||||
"scopes",
|
||||
"upstream_resource",
|
||||
"upstream_token_header",
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -286,7 +293,14 @@ describe("edit root: exact mounted set per auth configuration", () => {
|
|||
...PERMS,
|
||||
"delegate_auth_to_upstream",
|
||||
],
|
||||
credentials: ["client_id", "client_secret", "scopes", "upstream_resource", "token_endpoint_auth_method"],
|
||||
credentials: [
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"scopes",
|
||||
"upstream_resource",
|
||||
"token_endpoint_auth_method",
|
||||
"upstream_token_header",
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -306,7 +320,7 @@ describe("edit root: exact mounted set per auth configuration", () => {
|
|||
"env_vars",
|
||||
...PERMS,
|
||||
],
|
||||
credentials: ["client_id", "client_secret", "scopes"],
|
||||
credentials: ["client_id", "client_secret", "scopes", "upstream_token_header"],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -324,7 +338,7 @@ describe("edit root: exact mounted set per auth configuration", () => {
|
|||
"env_vars",
|
||||
...PERMS,
|
||||
],
|
||||
credentials: ["client_id", "client_secret", "scopes"],
|
||||
credentials: ["client_id", "client_secret", "scopes", "upstream_token_header"],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -344,6 +358,7 @@ describe("edit root: exact mounted set per auth configuration", () => {
|
|||
...PERMS,
|
||||
],
|
||||
credentials: [
|
||||
"upstream_token_header",
|
||||
"id_jag_resource_token_endpoint",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
|
|
@ -434,7 +449,14 @@ describe("create root: exact mounted set per configuration", () => {
|
|||
...PERMS,
|
||||
"delegate_auth_to_upstream",
|
||||
],
|
||||
credentials: ["client_id", "client_secret", "scopes", "upstream_resource", "token_endpoint_auth_method"],
|
||||
credentials: [
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"scopes",
|
||||
"upstream_resource",
|
||||
"token_endpoint_auth_method",
|
||||
"upstream_token_header",
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ const OAUTH_M2M_CREDENTIALS = [
|
|||
"token_endpoint_auth_method",
|
||||
"scopes",
|
||||
"upstream_resource",
|
||||
"upstream_token_header",
|
||||
] as const;
|
||||
|
||||
const OAUTH_INTERACTIVE_CREDENTIALS = [
|
||||
|
|
@ -32,6 +33,7 @@ const OAUTH_INTERACTIVE_CREDENTIALS = [
|
|||
"scopes",
|
||||
"upstream_resource",
|
||||
"token_endpoint_auth_method",
|
||||
"upstream_token_header",
|
||||
] as const;
|
||||
|
||||
const OAUTH_INTERACTIVE_ROOT = [
|
||||
|
|
@ -44,6 +46,7 @@ const OAUTH_INTERACTIVE_ROOT = [
|
|||
] as const;
|
||||
|
||||
const ID_JAG_CREDENTIALS = [
|
||||
"upstream_token_header",
|
||||
"id_jag_resource_token_endpoint",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
|
|
@ -100,7 +103,7 @@ const authSubtreeCredentials = ({ authType, oauthFlowType }: AuthSubtreeGates):
|
|||
];
|
||||
}
|
||||
if (authType === AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE) {
|
||||
return [...authValue, "client_id", "client_secret", "scopes"];
|
||||
return [...authValue, "client_id", "client_secret", "scopes", "upstream_token_header"];
|
||||
}
|
||||
if (authType === AUTH_TYPE.OAUTH2_ID_JAG) {
|
||||
return [...authValue, ...ID_JAG_CREDENTIALS];
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ const DECLARED_APP_CREDENTIAL_KEYS = ["client_id", "client_secret"] as const;
|
|||
// would destroy admin input), but it must stay OUT of the declared-app set: whether an app exists is
|
||||
// a distinct question that gates the "app may not match upstream" warning, and a server using dynamic
|
||||
// client registration can set a resource indicator while having no app at all.
|
||||
export const ADMIN_CONFIG_CREDENTIAL_KEYS = ["upstream_resource"] as const;
|
||||
export const ADMIN_CONFIG_CREDENTIAL_KEYS = ["upstream_resource", "upstream_token_header"] as const;
|
||||
|
||||
// Minted token material the oauth2 authorize path writes beside the app keys; stripped from restored
|
||||
// snapshots and from any credentials that transit to the temp-session preview so a stale token never
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue