Merge pull request #32828 from BerriAI/litellm_lit4338_delegate_token_mint

feat(mcp): mint gateway-bound envelope at the token endpoint for dcr_bridge oauth_delegate
This commit is contained in:
tin-berri 2026-07-13 10:36:37 -07:00 committed by GitHub
commit 3d400b5be9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 1039 additions and 41 deletions

View file

@ -1,8 +1,10 @@
import asyncio
import html as _html
import json
import math
import secrets
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Tuple
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
@ -10,7 +12,8 @@ from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
import httpx
from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
from pydantic import BaseModel, ValidationError
from pydantic import BaseModel, SecretStr, ValidationError
from typing_extensions import assert_never
from litellm._logging import verbose_logger
from litellm.llms.custom_httpx.http_handler import (
@ -37,6 +40,10 @@ from litellm.types.mcp import MCPAuth, MCPCredentials
from litellm.types.mcp_server.mcp_server_manager import MCPServer
if TYPE_CHECKING:
from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import (
EnvelopeKeys,
UpstreamTokenGrant,
)
from litellm.proxy._types import LiteLLM_MCPServerTable, UserAPIKeyAuth
# TTL cache for upstream OAuth metadata fetched from pass-through MCP servers.
@ -326,66 +333,136 @@ def _litellm_key_from_request(request: Request) -> Optional[str]:
return None
def _active_key_user_id(key_obj: "UserAPIKeyAuth") -> Optional[str]:
"""The key's ``user_id``, or ``None`` if the key is blocked or expired.
def _key_is_active(key_obj: "UserAPIKeyAuth") -> bool:
"""``True`` when the presented key is neither blocked nor past its expiry.
The OAuth token endpoint is unauthenticated, so the presented key is validated here before its
identity is trusted to key a stored credential; a revoked or expired key must not be able to
write or overwrite the per-user OAuth token. ``get_key_object`` resolves a row without these
checks (the main ``user_api_key_auth`` pipeline enforces them downstream, which this endpoint
bypasses), so they are applied here. Deleted keys are already rejected upstream, where
``get_key_object`` raises on a row that no longer exists.
The OAuth token endpoint is unauthenticated, so the presented key is validated here before it is
trusted; a revoked or expired key must not mint a bridge envelope or write a stored credential.
``get_key_object`` resolves a row without these checks (the main ``user_api_key_auth`` pipeline
enforces them downstream, which this endpoint bypasses), so they are applied here. Deleted keys
are already rejected upstream, where ``get_key_object`` raises on a row that no longer exists.
This is an active-state gate only; it deliberately does not require a ``user_id``. A valid
team-scoped or service-account key has no ``user_id`` yet is a legitimate credential, so gating
on ``user_id`` presence would wrongly reject it. Callers that need the user (the per-user token
store) derive it separately via :func:`_active_key_user_id`.
Total by design: ``expires`` is typed ``str | datetime``, and an unparseable string would make
``datetime.fromisoformat`` raise. Since the callers run this outside their key-resolution
``try``, an uncaught parse error would surface as a 500 instead of the endpoint's fail-closed
behavior, so a malformed expiry is treated as inactive (return ``False``) rather than raising.
"""
if key_obj.blocked is True:
return None
return False
expires = key_obj.expires
if expires is not None:
expiry = expires if isinstance(expires, datetime) else datetime.fromisoformat(expires)
if isinstance(expires, datetime):
expiry = expires
else:
try:
expiry = datetime.fromisoformat(expires)
except (ValueError, TypeError):
return False
if expiry.tzinfo is None or expiry.tzinfo.utcoffset(expiry) is None:
expiry = expiry.replace(tzinfo=timezone.utc)
if expiry < datetime.now(timezone.utc):
return None
return key_obj.user_id
return False
return True
async def _extract_user_id_from_request(request: Request) -> Optional[str]:
"""Resolve the LiteLLM ``user_id`` at the OAuth token endpoint so a per-user token is stored
under the same identity the egress later reads it by (``user_api_key_auth.user_id``).
def _active_key_user_id(key_obj: "UserAPIKeyAuth") -> str | None:
"""The active key's ``user_id``, or ``None`` when the key is blocked/expired or simply has no
``user_id`` (a team-scoped or service-account key). Used only by the per-user token store, which
needs a user to key the stored credential; the bridge mint uses the key hash and does not."""
return key_obj.user_id if _key_is_active(key_obj) else None
Resolves authoritatively via ``get_key_object`` (cache first, then DB) instead of a raw cache
peek. On a multi-replica gateway the token-exchange request can land on a worker whose in-memory
cache never saw the key, and a cross-replica Redis hit deserializes to a plain ``dict`` rather
than a ``UserAPIKeyAuth``; the previous code read only ``Authorization`` and did
``getattr(cached, "user_id")`` with no ``model_type`` rehydration and no DB fallback, so it
silently returned ``None`` and the token was never persisted, which makes the egress 401 on every
reconnect. The resolved key is validated (``_active_key_user_id``) before its identity is trusted,
so a blocked or expired key cannot write. Returns ``None`` when no key is present, the key cannot
be resolved, or it is blocked/expired.
"""
@dataclass(frozen=True, slots=True)
class _ResolvedKey:
"""An active litellm key resolved from the token request: its hash (the value ``get_key_object``
and the cache/DB layer key the record by) and the live record."""
key_hash: str
key: "UserAPIKeyAuth"
_KeyResolutionFailure = Literal["no_active_key", "unavailable", "unresolvable"]
"""Why a token request yielded no active litellm key, kept distinct so a caller statuses each truthfully
instead of blaming the client for a gateway problem:
- ``no_active_key``: none was presented, or the presented key is unknown / blocked / expired (the
caller's request is at fault)
- ``unavailable``: the auth database was transiently unreachable while resolving (retryable)
- ``unresolvable``: the gateway cannot resolve identity right now (no DB connection, or an unexpected
error) -- a gateway fault, not the caller's
The classification mirrors admission's ``_reload_admitted_key`` so the mint (ingress) and admission
(egress) never disagree on the status of the same outage."""
async def _resolve_active_litellm_key(request: Request) -> "_ResolvedKey | _KeyResolutionFailure":
"""Resolve the presented litellm key to an active key record, or say precisely why not.
Single resolution path the OAuth token endpoint reuses, resolving authoritatively via
``get_key_object`` (cache first, then DB). The failure is a value, not a bare ``None``, so a caller
can tell "the client sent no usable credential" (a request error) apart from "the gateway could not
check" (an infrastructure error) and status each truthfully; collapsing both to ``None`` is what let
a DB outage read as a 400. A resolved key is still gated by ``_key_is_active``, so a blocked or
expired key is ``no_active_key`` while a valid team-scoped or service-account key (no ``user_id``)
resolves. Classification mirrors admission's ``_reload_admitted_key``: no DB connection is a gateway
fault, a ``ProxyException`` / ``HTTPException`` from ``get_key_object`` is an unknown or invalid key,
a database-service-unavailable error is a retryable outage, and anything else is an unexpected
gateway fault."""
token = _litellm_key_from_request(request)
if not token:
return None
try:
from litellm.proxy._types import hash_token # noqa: PLC0415
from litellm.proxy.auth.auth_checks import get_key_object # noqa: PLC0415
from litellm.proxy.proxy_server import ( # noqa: PLC0415
prisma_client,
user_api_key_cache,
)
return "no_active_key"
from litellm.proxy._types import ( # noqa: PLC0415 # inline import avoids a module-load circular import
ProxyException,
hash_token,
)
from litellm.proxy.auth.auth_checks import ( # noqa: PLC0415 # inline import avoids a module-load circular import
get_key_object,
)
from litellm.proxy.db.exception_handler import ( # noqa: PLC0415 # inline import avoids a module-load circular import
PrismaDBExceptionHandler,
)
from litellm.proxy.proxy_server import ( # noqa: PLC0415 # inline import avoids a module-load circular import
prisma_client,
user_api_key_cache,
)
if prisma_client is None:
return "unresolvable"
key_hash = hash_token(token)
try:
key_obj = await get_key_object(
hashed_token=hash_token(token),
hashed_token=key_hash,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
)
return _active_key_user_id(key_obj)
except Exception as exc:
except (ProxyException, HTTPException):
return "no_active_key"
except Exception as exc: # noqa: BLE001 # classify: a DB outage is retryable, anything else is an opaque gateway fault
if PrismaDBExceptionHandler.is_database_service_unavailable_error(exc):
return "unavailable"
verbose_logger.debug(
"_extract_user_id_from_request: could not resolve a LiteLLM user_id for the presented "
"key (%s); per-user token will not be stored server-side.",
"_resolve_active_litellm_key: unexpected key-resolution error (%s)",
type(exc).__name__,
)
return "unresolvable"
if not _key_is_active(key_obj):
return "no_active_key"
return _ResolvedKey(key_hash=key_hash, key=key_obj)
async def _extract_user_id_from_request(request: Request) -> str | None:
"""The litellm ``user_id`` for the token request, so a per-user token is stored under the same
identity the egress later reads it by. Storage is best-effort, so every non-resolved outcome
(including a transient DB outage) collapses to ``None`` here and the caller simply skips the store;
the bridge mint, which must status those outcomes differently, consumes
:func:`_resolve_active_litellm_key` directly."""
resolved = await _resolve_active_litellm_key(request)
if not isinstance(resolved, _ResolvedKey):
return None
return _active_key_user_id(resolved.key)
async def _store_per_user_token_server_side(
@ -654,6 +731,255 @@ async def authorize_with_server(
return response
_UpstreamGrantRejection = Literal["no_access_token", "expired_lifetime"]
"""Why an upstream token response cannot back a bridge envelope:
- ``no_access_token``: the response carries no usable ``access_token``
- ``expired_lifetime``: the response reports a parseable, non-positive ``expires_in``, i.e. an upstream
token that is already dead, so sealing it would forward a bearer the edge cannot use
An absent or unparseable ``expires_in`` is NOT a rejection; the lifetime is merely unknown and the
envelope caps it, the by-design behaviour for an upstream that omits the field."""
def _classify_upstream_lifetime(raw_expires_in: object) -> "int | Literal['unspecified', 'expired']":
"""Classify an upstream ``expires_in`` into a positive number of seconds, ``"unspecified"`` (absent
or unparseable, so the envelope caps it), or ``"expired"`` (a non-positive value the upstream reports
as already elapsed). Telling "we do not know the lifetime" apart from "the upstream says it is
already dead" is what stops an explicitly-expired token from silently receiving the envelope's 1h
cap. The expired decision is made on the parsed numeric value, not on ``int(...)`` of it, so a
positive sub-second lifetime in ``(0, 1)`` is not truncated to ``0`` and misread as elapsed; the
envelope works in whole seconds, so such a lifetime clamps up to its 1s floor. ``bool`` is excluded
(an ``int`` subclass but never a real lifetime), and the conversions can raise on ``NaN`` /
``Infinity`` / oversized input, which reads as unparseable rather than surfacing as a 500."""
if raw_expires_in is None or isinstance(raw_expires_in, bool) or not isinstance(raw_expires_in, (int, float, str)):
return "unspecified"
try:
numeric = float(raw_expires_in)
seconds = int(numeric)
except (ValueError, TypeError, OverflowError):
return "unspecified"
if numeric <= 0:
return "expired"
return max(1, seconds)
def _bridge_grant_from_token_response(token_response: object) -> "UpstreamTokenGrant | _UpstreamGrantRejection":
"""Validate an upstream OAuth token response into a typed grant, or say why it cannot back an
envelope. Each field is isinstance-checked so nothing untyped from ``response.json()`` reaches the
grant. ``expires_in`` is read three ways (see :func:`_classify_upstream_lifetime`): an unknown
lifetime leaves the grant ``expires_in`` ``None`` for the envelope to cap, a positive value is
honoured, and an explicit already-elapsed value is a rejection rather than a silent fall-through to
the cap."""
from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( # noqa: PLC0415 # inline import avoids a module-load circular import
UpstreamTokenGrant,
)
if not isinstance(token_response, dict):
return "no_access_token"
access = token_response.get("access_token")
if not isinstance(access, str) or not access:
return "no_access_token"
lifetime = _classify_upstream_lifetime(token_response.get("expires_in"))
if lifetime == "expired":
return "expired_lifetime"
token_type = token_response.get("token_type")
scope = token_response.get("scope")
return UpstreamTokenGrant(
access_token=SecretStr(access),
token_type=token_type if isinstance(token_type, str) and token_type else "Bearer",
# The upstream refresh_token is deliberately NOT sealed: the edge never consumes it (it forwards
# only token_type + access_token), so it would be dead weight embedding a long-lived upstream
# credential in the client-held bearer, and it enlarges the envelope. Refresh support is a
# follow-up (a dedicated refresh-envelope); the client re-runs authorization_code at the cap.
refresh_token=None,
scope=scope if isinstance(scope, str) and scope else None,
expires_in=lifetime if isinstance(lifetime, int) else None,
)
# ---------------------------------------------------------------------------
# DCR-bridge oauth_delegate mint: a three-phase pipeline whose failures are values.
#
# prepare (before the upstream exchange) -> validate every precondition and resolve identity+keys
# exchange (the single-use upstream code is consumed here, in exchange_token_with_server)
# finish (after the exchange) -> seal the upstream grant into the client-held envelope
#
# Every precondition lives in ``prepare``, which runs BEFORE the exchange, so no failure can burn the
# single-use code or rotate a refresh token, for either grant type -- that whole class of bug is gone
# by construction rather than guarded case by case. Failures are values mapped to an OAuth-shaped
# response in one place (``_bridge_mint_error_response``), so status codes and the RFC 6749 §5.2 body
# shape are uniform. Adding a failure mode is a new literal plus a match arm the type checker forces.
# ---------------------------------------------------------------------------
_BridgeMintError = Literal[
"no_identity",
"unsupported_grant",
"identity_unavailable",
"identity_unresolvable",
"not_configured",
"no_upstream_token",
"upstream_token_expired",
"too_large",
]
@dataclass(frozen=True, slots=True)
class _BridgeMintReady:
"""Everything the seal needs, resolved once before the exchange: the authorizing key hash and the
master-key-derived envelope keys. Passing this forward means identity resolution and key derivation
happen exactly once, and ``_finish_bridge_mint`` has no preconditions left that could fail."""
key_hash: str
keys: "EnvelopeKeys"
def _bridge_mint_error_response(error: _BridgeMintError) -> JSONResponse:
"""Map a bridge-mint failure value to its token-endpoint response: one place, RFC 6749 §5.2 shape
(top-level ``error``, no-store headers) for every case, with a status truthful about where the
failure is. The caller's request is 400, a transient gateway outage is 503, a gateway
misconfiguration is 500, and an upstream problem is 502. The identity-resolution statuses match how
admission statuses the same conditions on the egress side, so mint and admit never disagree under
one outage."""
match error:
case "no_identity":
status, code, desc = (
400,
"invalid_request",
"this server issues a gateway-bound credential; send a litellm credential "
"(x-litellm-api-key or Authorization) on the token request",
)
case "unsupported_grant":
status, code, desc = (
400,
"unsupported_grant_type",
"this server issues a gateway-bound credential and supports only the authorization_code "
"grant; re-run authorization_code to renew rather than refresh_token",
)
case "identity_unavailable":
status, code, desc = (
503,
"temporarily_unavailable",
"the authentication database is temporarily unreachable; retry shortly",
)
case "identity_unresolvable":
status, code, desc = (
500,
"server_error",
"the gateway could not resolve the litellm identity for this request",
)
case "not_configured":
status, code, desc = (
500,
"server_error",
"the gateway is not configured to mint a gateway-bound credential (master_key is not set)",
)
case "no_upstream_token":
status, code, desc = (
502,
"server_error",
"the upstream token response has no usable access_token",
)
case "upstream_token_expired":
status, code, desc = (
502,
"server_error",
"the upstream token response reports an already-expired lifetime",
)
case "too_large":
status, code, desc = (
502,
"server_error",
"the upstream token is too large to seal into a gateway-bound credential",
)
case _:
assert_never(error)
return JSONResponse(
status_code=status, content={"error": code, "error_description": desc}, headers=TOKEN_NO_CACHE_HEADERS
)
def _key_resolution_failure_to_mint_error(failure: _KeyResolutionFailure) -> _BridgeMintError:
"""Lift an identity-resolution failure into the mint taxonomy, preserving origin so the status stays
truthful: the caller's missing credential is 400, a transient DB outage is 503, and a gateway that
cannot resolve identity is 500."""
match failure:
case "no_active_key":
return "no_identity"
case "unavailable":
return "identity_unavailable"
case "unresolvable":
return "identity_unresolvable"
case _:
assert_never(failure)
def _upstream_rejection_to_mint_error(rejection: _UpstreamGrantRejection) -> _BridgeMintError:
"""Lift an upstream-response rejection into the mint taxonomy; both are upstream faults (502)."""
match rejection:
case "no_access_token":
return "no_upstream_token"
case "expired_lifetime":
return "upstream_token_expired"
case _:
assert_never(rejection)
async def _prepare_bridge_mint(request: Request, grant_type: str) -> "_BridgeMintReady | _BridgeMintError":
"""Phase 1, BEFORE the upstream exchange: reject a grant this mint does not support, confirm the
gateway can mint (master_key set), resolve the litellm identity, and derive the envelope keys.
Returns a ready context or a precise failure value. Running before the exchange is what makes every
failure here fail closed without consuming the single-use code or rotating a refresh token. A bridge
server issues only envelopes and seals no upstream refresh_token, so the client holds none to
present: the refresh_token grant is rejected up front rather than exchanged (which could rotate the
upstream credential) and its result then discarded. Identity-resolution failures keep their origin
so the mapper statuses each truthfully."""
from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import ( # noqa: PLC0415 # inline import avoids a module-load circular import
envelope_keys_from_master_key,
)
from litellm.proxy.proxy_server import ( # noqa: PLC0415 # inline import avoids a module-load circular import
master_key,
)
if grant_type != "authorization_code":
return "unsupported_grant"
if not master_key:
return "not_configured"
resolved = await _resolve_active_litellm_key(request)
if not isinstance(resolved, _ResolvedKey):
return _key_resolution_failure_to_mint_error(resolved)
return _BridgeMintReady(key_hash=resolved.key_hash, keys=envelope_keys_from_master_key(master_key))
def _finish_bridge_mint(
ready: "_BridgeMintReady", mcp_server: MCPServer, token_response: object, now: datetime
) -> "JSONResponse | _BridgeMintError":
"""Phase 3, AFTER the upstream exchange: seal the upstream grant into the client-held envelope using
the pre-resolved identity and keys, so the client holds one bearer that admits it and forwards the
upstream token with nothing stored server-side. The only failures here are properties of the
upstream response (no usable token, an already-expired lifetime, or a token too large to seal),
returned as values."""
from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import ( # noqa: PLC0415 # inline import avoids a module-load circular import
build_bridge_token_response,
)
from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( # noqa: PLC0415 # inline import avoids a module-load circular import
EnvelopeIdentity,
SealedEnvelope,
UpstreamTokenGrant,
)
grant = _bridge_grant_from_token_response(token_response)
if not isinstance(grant, UpstreamTokenGrant):
return _upstream_rejection_to_mint_error(grant)
identity = EnvelopeIdentity(server_id=mcp_server.server_id, key_hash=ready.key_hash)
sealed = build_bridge_token_response(identity, grant, ready.keys, now)
if not isinstance(sealed, SealedEnvelope):
return "too_large"
# Report expires_in from the JWT's own second-truncated exp, rounding the elapsed portion up, so the
# client is never told the bearer lives past the point admission (which uses that exp) rejects it.
expires_in = max(0, int(sealed.expires_at.timestamp()) - math.ceil(now.timestamp()))
body = {"access_token": sealed.token.get_secret_value(), "token_type": "Bearer", "expires_in": expires_in}
return JSONResponse(body, headers=TOKEN_NO_CACHE_HEADERS)
async def exchange_token_with_server(
request: Request,
mcp_server: MCPServer,
@ -727,6 +1053,16 @@ async def exchange_token_with_server(
if code_verifier:
token_data["code_verifier"] = code_verifier
# Phase 1: for a bridge oauth_delegate mint, validate all preconditions and resolve identity+keys
# BEFORE the exchange below consumes the single-use upstream code, and carry the ready context to
# phase 3. A failure here returns without ever touching the upstream credential.
bridge_mint_ready: _BridgeMintReady | None = None
if mcp_server.is_oauth_delegate and mcp_server.is_dcr_bridge:
prepared = await _prepare_bridge_mint(request, grant_type)
if not isinstance(prepared, _BridgeMintReady):
return _bridge_mint_error_response(prepared)
bridge_mint_ready = prepared
async_client = get_async_httpx_client(llm_provider=httpxSpecialProvider.Oauth2Check)
response = await async_client.post(
mcp_server.token_url,
@ -751,7 +1087,6 @@ async def exchange_token_with_server(
)
raise
token_response = response.json()
access_token = token_response["access_token"]
# Validate token response against server-configured rules before any storage.
# This rejects tokens from wrong Slack workspaces, Atlassian orgs, etc.
@ -791,8 +1126,17 @@ async def exchange_token_with_server(
mcp_server.server_id,
)
# A DCR-bridge oauth_delegate server hands the client a gateway-bound envelope (identity plus the
# upstream token) instead of the raw upstream token, so the one bearer both admits the caller and
# forwards the upstream credential. Only this mode mints; every other server returns the raw token.
if bridge_mint_ready is not None:
# Phase 3: seal the upstream grant into the client-held envelope; failures map through the same
# OAuth-shaped response as the phase-1 preconditions.
minted = _finish_bridge_mint(bridge_mint_ready, mcp_server, token_response, datetime.now(timezone.utc))
return minted if isinstance(minted, JSONResponse) else _bridge_mint_error_response(minted)
result = {
"access_token": access_token,
"access_token": token_response["access_token"],
"token_type": token_response.get("token_type", "Bearer"),
}

View file

@ -4362,6 +4362,501 @@ async def test_register_bridge_relay_never_persists():
mock_persist.assert_not_called()
_BRIDGE_MASTER_KEY = "sk-bridge-producer-master-key-0123456789abcdef"
async def _exchange_for_bridge_server(server, upstream_body, key_hash, fake_client_out=None):
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
_ResolvedKey,
exchange_token_with_server,
)
fake_http_response = MagicMock()
fake_http_response.json.return_value = upstream_body
fake_http_response.raise_for_status = MagicMock()
fake_http_client = MagicMock()
fake_http_client.post = AsyncMock(return_value=fake_http_response)
# The mint consumes _resolve_active_litellm_key's tagged result: an active key resolves to a
# _ResolvedKey carrying its hash; a request with no usable credential resolves to "no_active_key".
resolution = _ResolvedKey(key_hash=key_hash, key=MagicMock()) if key_hash is not None else "no_active_key"
key_resolver = AsyncMock(return_value=resolution)
if fake_client_out is not None:
fake_client_out["client"] = fake_http_client
with (
patch(
"litellm.proxy._experimental.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=fake_http_client,
),
patch(
"litellm.proxy._experimental.mcp_server.discoverable_endpoints._resolve_active_litellm_key",
new=key_resolver,
),
patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY),
):
response = await exchange_token_with_server(
request=_bridge_mock_request(),
mcp_server=server,
grant_type="authorization_code",
code="auth-code",
redirect_uri="https://claude.ai/api/mcp/auth_callback",
client_id="dcr-client-123",
client_secret=None,
code_verifier="verifier",
)
if server.is_oauth_delegate and server.is_dcr_bridge:
key_resolver.assert_awaited_once()
else:
key_resolver.assert_not_awaited()
return response
@pytest.mark.asyncio
async def test_oauth_delegate_bridge_token_exchange_mints_envelope_not_raw_token():
"""A dcr_bridge oauth_delegate token exchange returns a gateway-bound envelope, not the raw
upstream token: the response access_token opens (under the same master-key-derived keys and the
server_id) to the caller's identity and the upstream Authorization, and the raw upstream token
never appears in the bearer the client receives."""
from datetime import datetime, timezone
from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import (
BridgeEnvelopeAdmitted,
envelope_keys_from_master_key,
resolve_bridge_envelope,
)
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
upstream = {"access_token": "UPSTREAM-SECRET-TOKEN", "token_type": "Bearer", "expires_in": 3600}
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
body = json.loads(response.body)
token = body["access_token"]
assert body["token_type"] == "Bearer"
assert body["expires_in"] > 0
assert token.startswith("llm_env_")
assert "UPSTREAM-SECRET-TOKEN" not in token
assert "refresh_token" not in body
keys = envelope_keys_from_master_key(_BRIDGE_MASTER_KEY)
opened = resolve_bridge_envelope(token, keys, datetime.now(timezone.utc), server.server_id)
assert isinstance(opened, BridgeEnvelopeAdmitted)
assert opened.identity.key_hash == "hashed-litellm-key-77"
assert opened.upstream_authorization.get_secret_value() == "Bearer UPSTREAM-SECRET-TOKEN"
@pytest.mark.asyncio
async def test_oauth_delegate_bridge_token_exchange_fails_closed_without_litellm_identity():
"""Without a resolvable litellm identity on the token request, the exchange must not mint an
identity-less envelope. It returns an RFC 6749 §5.2-shaped invalid_request (error at the top
level, not wrapped in detail) BEFORE exchanging the upstream code, so the single-use code is not
burned and the client can retry."""
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
upstream = {"access_token": "UPSTREAM-SECRET-TOKEN", "token_type": "Bearer", "expires_in": 3600}
captured: dict = {}
response = await _exchange_for_bridge_server(server, upstream, key_hash=None, fake_client_out=captured)
assert response.status_code == 400
assert json.loads(response.body)["error"] == "invalid_request"
# identity resolution failed first, so the upstream single-use code was never exchanged (not burned)
captured["client"].post.assert_not_called()
@pytest.mark.asyncio
async def test_bridge_envelope_too_large_upstream_token_is_502():
"""An upstream token too large to seal into the envelope is an upstream-payload condition, so the
mint surfaces a 502 (as an RFC 6749 §5.2 error body, not a raised HTTPException) rather than a 500:
build_bridge_token_response returns EnvelopeTooLarge as a value, _finish_bridge_mint returns the
"too_large" failure, and _bridge_mint_error_response maps it to a truthful status."""
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
upstream = {"access_token": "x" * 40000, "token_type": "Bearer", "expires_in": 3600}
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
assert response.status_code == 502
assert json.loads(response.body)["error"] == "server_error"
@pytest.mark.asyncio
async def test_bridge_envelope_does_not_seal_upstream_refresh_token():
"""The upstream refresh_token is never sealed into the client-held envelope: the edge never
consumes it and a long-lived upstream credential should not live in the client bearer. The opened
envelope's grant carries no refresh token even when the upstream returned one, and neither does
the response body."""
from datetime import datetime, timezone
from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import (
envelope_keys_from_master_key,
)
from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import (
OpenedEnvelope,
open_envelope,
)
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
upstream = {
"access_token": "UP",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "UPSTREAM-REFRESH",
}
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
body = json.loads(response.body)
assert "refresh_token" not in body
assert "UPSTREAM-REFRESH" not in body["access_token"]
keys = envelope_keys_from_master_key(_BRIDGE_MASTER_KEY)
opened = open_envelope(body["access_token"], keys, datetime.now(timezone.utc))
assert isinstance(opened, OpenedEnvelope)
assert opened.grant.refresh_token is None
@pytest.mark.asyncio
async def test_bridge_refresh_grant_is_rejected_before_upstream():
"""A bridge oauth_delegate server issues only envelopes and seals no upstream refresh_token, so the
client never holds one to present. _prepare_bridge_mint rejects the refresh_token grant up front
with unsupported_grant_type, BEFORE any upstream exchange, so a stray refresh request can never
rotate or consume the client's upstream refresh credential; renewal is re-running
authorization_code. This is checked before identity resolution, so it holds even with a valid key."""
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import exchange_token_with_server
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
fake_http_client = MagicMock()
fake_http_client.post = AsyncMock()
with (
patch(
"litellm.proxy._experimental.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=fake_http_client,
),
patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY),
):
response = await exchange_token_with_server(
request=_bridge_mock_request(),
mcp_server=server,
grant_type="refresh_token",
code=None,
redirect_uri=None,
client_id="dcr-client-123",
client_secret=None,
code_verifier=None,
refresh_token="client-refresh-token",
)
assert response.status_code == 400
assert json.loads(response.body)["error"] == "unsupported_grant_type"
fake_http_client.post.assert_not_called()
@pytest.mark.asyncio
async def test_bridge_mint_fails_closed_before_upstream_when_master_key_unset():
"""master_key is validated BEFORE the upstream exchange (in _prepare_bridge_mint), so a
misconfigured gateway returns a 500 server_error without consuming the single-use code, avoiding
the burn-then-fail the pre-exchange phase exists to prevent. The failure is returned as an RFC 6749
error body, not raised."""
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import exchange_token_with_server
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
fake_http_client = MagicMock()
fake_http_client.post = AsyncMock()
with (
patch(
"litellm.proxy._experimental.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=fake_http_client,
),
patch(
"litellm.proxy._experimental.mcp_server.discoverable_endpoints._resolve_active_litellm_key",
new=AsyncMock(return_value="no_active_key"),
),
patch("litellm.proxy.proxy_server.master_key", None),
):
response = await exchange_token_with_server(
request=_bridge_mock_request(),
mcp_server=server,
grant_type="authorization_code",
code="auth-code",
redirect_uri="https://claude.ai/api/mcp/auth_callback",
client_id="dcr-client-123",
client_secret=None,
code_verifier="verifier",
)
assert response.status_code == 500
assert json.loads(response.body)["error"] == "server_error"
fake_http_client.post.assert_not_called()
async def _prepare_only_bridge_exchange(resolver_result):
"""Drive exchange_token_with_server for a bridge oauth_delegate authorization_code request with the
identity resolver stubbed to a given tagged result, returning (response, post_mock) so a test can
assert the mapped status and that the single-use code was never exchanged."""
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import exchange_token_with_server
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
fake_http_client = MagicMock()
fake_http_client.post = AsyncMock()
with (
patch(
"litellm.proxy._experimental.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=fake_http_client,
),
patch(
"litellm.proxy._experimental.mcp_server.discoverable_endpoints._resolve_active_litellm_key",
new=AsyncMock(return_value=resolver_result),
),
patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY),
):
response = await exchange_token_with_server(
request=_bridge_mock_request(),
mcp_server=server,
grant_type="authorization_code",
code="auth-code",
redirect_uri="https://claude.ai/api/mcp/auth_callback",
client_id="dcr-client-123",
client_secret=None,
code_verifier="verifier",
)
return response, fake_http_client.post
@pytest.mark.asyncio
async def test_bridge_mint_db_outage_is_503_before_upstream():
"""A DB outage while resolving identity is a retryable gateway failure, so the mint returns 503
temporarily_unavailable WITHOUT consuming the single-use code, matching how admission statuses the
same outage on the egress side. Collapsing every resolution failure to None used to blame the
client with 400 invalid_request for an infrastructure problem."""
response, post = await _prepare_only_bridge_exchange("unavailable")
assert response.status_code == 503
assert json.loads(response.body)["error"] == "temporarily_unavailable"
post.assert_not_called()
@pytest.mark.asyncio
async def test_bridge_mint_unresolvable_identity_is_500_before_upstream():
"""An unresolvable identity (no DB connection, or an unexpected resolution error) is a gateway
fault, so the mint returns 500 server_error before the exchange, a status distinct from both the
caller's 400 and the transient 503, matching admission's 500-vs-503 split for the same conditions."""
response, post = await _prepare_only_bridge_exchange("unresolvable")
assert response.status_code == 500
assert json.loads(response.body)["error"] == "server_error"
post.assert_not_called()
@pytest.mark.asyncio
async def test_bridge_mint_upstream_expired_lifetime_is_502():
"""An upstream token response reporting an already-elapsed lifetime (a parseable non-positive
expires_in) is rejected with 502 rather than sealed into an hour-long envelope around a dead
bearer. Regression for expires_in<=0 silently falling through to the 1h cap."""
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
upstream = {"access_token": "UP", "token_type": "Bearer", "expires_in": 0}
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
assert response.status_code == 502
assert json.loads(response.body)["error"] == "server_error"
@pytest.mark.asyncio
async def test_bridge_mint_positive_sub_second_lifetime_mints_not_502():
"""A positive fractional expires_in in (0, 1) is a live token, not an elapsed one, so it mints a
(1s-floored) envelope rather than being truncated to 0 and rejected with 502 after the single-use
code was already consumed. Regression for classifying a sub-second remaining lifetime as expired."""
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
upstream = {"access_token": "UP", "token_type": "Bearer", "expires_in": 0.5}
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
assert response.status_code == 200
body = json.loads(response.body)
assert body["access_token"].startswith("llm_env_")
assert body["expires_in"] >= 0
@pytest.mark.asyncio
async def test_bridge_mint_unknown_lifetime_is_capped_not_rejected():
"""An absent or unparseable expires_in leaves the lifetime unknown, which the envelope caps (never
inventing a longer life than the upstream stated); it is NOT rejected. Only an explicitly-dead
lifetime fails, so a metadata glitch on an otherwise-valid token still mints a bounded envelope."""
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
upstream = {"access_token": "UP", "token_type": "Bearer", "expires_in": "not-a-number"}
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
assert response.status_code == 200
body = json.loads(response.body)
assert body["access_token"].startswith("llm_env_")
assert 0 < body["expires_in"] <= 3600
@pytest.mark.asyncio
async def test_bridge_reported_expires_in_does_not_overstate_jwt_exp():
"""The reported expires_in is derived from the envelope JWT's second-truncated exp (rounding the
elapsed portion up), so the client is never told the bearer lives past the point admission expires
it. Regression for the sub-second overstatement of the raw (expires_at - now) delta."""
import time
import jwt as _jwt
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
upstream = {"access_token": "UP", "token_type": "Bearer", "expires_in": 300}
before = int(time.time())
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
body = json.loads(response.body)
claims = _jwt.decode(body["access_token"].removeprefix("llm_env_"), options={"verify_signature": False})
# projecting the reported lifetime from a time no later than the mint must not exceed the JWT exp
assert before + body["expires_in"] <= claims["exp"]
def test_bridge_reported_expires_in_can_be_zero_at_jwt_exp_boundary():
from datetime import datetime, timezone
from fastapi.responses import JSONResponse
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
_BridgeMintReady,
_finish_bridge_mint,
)
from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import (
envelope_keys_from_master_key,
)
from litellm.types.mcp import MCPAuth
ready = _BridgeMintReady(
key_hash="hashed-litellm-key-77",
keys=envelope_keys_from_master_key(_BRIDGE_MASTER_KEY),
)
response = _finish_bridge_mint(
ready=ready,
mcp_server=_bridge_server(auth_type=MCPAuth.oauth_delegate),
token_response={"access_token": "UP", "expires_in": 1},
now=datetime.fromtimestamp(100.25, tz=timezone.utc),
)
assert isinstance(response, JSONResponse)
assert json.loads(response.body)["expires_in"] == 0
def test_classify_upstream_lifetime():
"""expires_in from an IdP may be an int, a float (3600.0), or a numeric string ("3600"); each
coerces to a positive number of seconds. Absent or unparseable input (bool, non-numeric, NaN/inf,
oversized) is "unspecified" so the envelope caps it, while a parseable non-positive value is
"expired": the upstream reporting an already-dead token, which the mint must reject rather than
silently give the 1h cap."""
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import _classify_upstream_lifetime
assert _classify_upstream_lifetime(300) == 300
assert _classify_upstream_lifetime(300.0) == 300
assert _classify_upstream_lifetime("300") == 300
assert _classify_upstream_lifetime(" 300 ") == 300
# explicit, parseable, non-positive -> the upstream says the token is already dead
assert _classify_upstream_lifetime(0) == "expired"
assert _classify_upstream_lifetime(-5) == "expired"
assert _classify_upstream_lifetime(-0.5) == "expired"
# a positive sub-second lifetime is alive, not elapsed; it clamps up to the envelope's 1s floor
# rather than truncating to 0 and being misread as expired
assert _classify_upstream_lifetime(0.5) == 1
assert _classify_upstream_lifetime(0.001) == 1
# a positive value >= 1 truncates toward zero (never overstating the stated lifetime)
assert _classify_upstream_lifetime(1.9) == 1
# unknown lifetime -> cap (never invent a longer life than the upstream stated)
assert _classify_upstream_lifetime(None) == "unspecified"
assert _classify_upstream_lifetime(True) == "unspecified"
assert _classify_upstream_lifetime("nope") == "unspecified"
# hostile numerics must not raise (int(float(...)) can OverflowError) -> unspecified
assert _classify_upstream_lifetime("inf") == "unspecified"
assert _classify_upstream_lifetime("1e999") == "unspecified"
assert _classify_upstream_lifetime("-inf") == "unspecified"
assert _classify_upstream_lifetime("nan") == "unspecified"
assert _classify_upstream_lifetime(float("inf")) == "unspecified"
assert _classify_upstream_lifetime(10**400) == "unspecified"
def test_bridge_grant_honors_and_rejects_upstream_lifetime():
"""The grant validator honors a positive lifetime, leaves an unknown one None for the envelope to
cap, and rejects an explicitly-expired one with "expired_lifetime" so a dead upstream token is
never sealed into an hour-long envelope."""
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import _bridge_grant_from_token_response
def grant(v):
return _bridge_grant_from_token_response({"access_token": "x", "expires_in": v})
assert grant(300).expires_in == 300
assert grant(120.0).expires_in == 120
# unknown lifetime backs a grant whose expires_in the envelope caps; it is not a rejection
assert grant("nope").expires_in is None
assert _bridge_grant_from_token_response({"access_token": "x"}).expires_in is None
# an explicitly already-dead lifetime is rejected, not silently capped at 1h
assert grant(0) == "expired_lifetime"
assert grant(-5) == "expired_lifetime"
@pytest.mark.asyncio
async def test_bridge_token_exchange_honors_short_float_expires_in_ttl():
"""A short float expires_in from the upstream caps the envelope TTL, so the client-held envelope
does not outlive the upstream token. Before coercion a float was dropped and the envelope
defaulted to the 1h cap (3600), which would forward a stale bearer after the upstream token
expired."""
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
upstream = {"access_token": "UP", "token_type": "Bearer", "expires_in": 120.0}
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
assert json.loads(response.body)["expires_in"] <= 120
@pytest.mark.asyncio
async def test_oauth_delegate_bridge_token_exchange_missing_access_token_is_502_not_keyerror():
"""When the upstream token response has no access_token, a dcr_bridge oauth_delegate exchange
returns a clean 502 error body rather than raising a KeyError. _finish_bridge_mint asks
_bridge_grant_from_token_response for a typed grant, gets None, and returns the "no_upstream_token"
failure, which maps to 502; nothing indexes token_response["access_token"] on the bridge path."""
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
upstream = {"token_type": "Bearer", "expires_in": 3600}
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
assert response.status_code == 502
assert json.loads(response.body)["error"] == "server_error"
@pytest.mark.asyncio
async def test_true_passthrough_bridge_token_exchange_returns_raw_upstream_token():
"""Only oauth_delegate mints. A true_passthrough dcr_bridge server relays the raw upstream token
to the client, since that mode has no litellm identity to bind and the caller owns the token."""
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.true_passthrough)
upstream = {"access_token": "UPSTREAM-SECRET-TOKEN", "token_type": "Bearer", "expires_in": 3600}
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
body = json.loads(response.body)
assert body["access_token"] == "UPSTREAM-SECRET-TOKEN"
assert not body["access_token"].startswith("llm_env_")
@pytest.mark.asyncio
async def test_non_bridge_oauth_delegate_token_exchange_returns_raw_upstream_token():
"""An oauth_delegate server without dcr_bridge keeps the pre-change contract: the raw upstream
token is returned, so flag-off behavior is byte-identical."""
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate, dcr_bridge=None)
upstream = {"access_token": "UPSTREAM-SECRET-TOKEN", "token_type": "Bearer", "expires_in": 3600}
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
body = json.loads(response.body)
assert body["access_token"] == "UPSTREAM-SECRET-TOKEN"
async def _exchange_persistence_attempted_for_auth_type(auth_type) -> bool:
"""Run exchange_token_with_server for a server of ``auth_type`` and report whether it attempted
to persist the exchanged token server-side. The client-forwarded token modes must not persist:
@ -4706,6 +5201,165 @@ async def test_extract_user_id_rejects_expired_key(proxy_globals):
assert await _extract_user_id_from_request(request) is None
@pytest.mark.asyncio
async def test_resolve_active_litellm_key_returns_resolved_key_for_active_key(proxy_globals):
"""The dcr_bridge mint seals the hash of the authorizing key so admission can reload the live
record. For an active key the resolver returns exactly hash_token(key), the same value
get_key_object and the whole cache/DB layer key the record by, so the sealed reference resolves
back to this key at admission."""
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
_resolve_active_litellm_key,
_ResolvedKey,
)
from litellm.proxy._types import UserAPIKeyAuth, hash_token
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
key = "sk-alice-key"
cache = UserApiKeyCache()
await cache.async_set_cache(
hash_token(key),
UserAPIKeyAuth(token=hash_token(key), user_id="alice"),
model_type=UserAPIKeyAuth,
)
proxy_globals.user_api_key_cache = cache
proxy_globals.prisma_client = object()
request = _token_request({"x-litellm-api-key": f"Bearer {key}"})
resolved = await _resolve_active_litellm_key(request)
assert isinstance(resolved, _ResolvedKey)
assert resolved.key_hash == hash_token(key)
@pytest.mark.asyncio
async def test_resolve_active_litellm_key_resolves_key_without_user_id(proxy_globals):
"""A valid team-scoped or service-account key has no user_id but is a legitimate credential, so it
must still resolve to a hash and be able to mint a bridge envelope. Gating the resolver on user_id
presence wrongly rejected these keys with invalid_request; the active-state gate now checks only
blocked and expiry, and the key hash (not the user) is what the mint seals. The per-user token
store still gets no user for such a key, since there is none to key a stored credential by."""
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
_extract_user_id_from_request,
_resolve_active_litellm_key,
_ResolvedKey,
)
from litellm.proxy._types import UserAPIKeyAuth, hash_token
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
key = "sk-team-scoped-key"
cache = UserApiKeyCache()
await cache.async_set_cache(
hash_token(key),
UserAPIKeyAuth(token=hash_token(key), user_id=None, team_id="team-x"),
model_type=UserAPIKeyAuth,
)
proxy_globals.user_api_key_cache = cache
proxy_globals.prisma_client = object()
request = _token_request({"x-litellm-api-key": f"Bearer {key}"})
resolved = await _resolve_active_litellm_key(request)
assert isinstance(resolved, _ResolvedKey)
assert resolved.key_hash == hash_token(key)
assert await _extract_user_id_from_request(request) is None
@pytest.mark.asyncio
async def test_resolve_active_litellm_key_rejects_blocked_key(proxy_globals):
"""A blocked key must not yield a hash, so no gateway-bound envelope is minted for a revoked key;
the mint fails closed with invalid_request instead."""
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
_resolve_active_litellm_key,
)
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
class _FakePrisma:
async def get_data(self, token, table_name, parent_otel_span=None, proxy_logging_obj=None):
return UserAPIKeyAuth(token=token, user_id="blocked-user", blocked=True)
proxy_globals.user_api_key_cache = UserApiKeyCache()
proxy_globals.prisma_client = _FakePrisma()
request = _token_request({"x-litellm-api-key": "sk-blocked-key"})
assert await _resolve_active_litellm_key(request) == "no_active_key"
@pytest.mark.asyncio
async def test_resolve_active_litellm_key_fails_closed_on_malformed_expiry(proxy_globals):
"""A key whose stored expires string does not parse must fail closed to no-hash (the mint then
returns invalid_request), not surface an unhandled 500. The active-state check runs outside the
resolver's try, so it must be total over a bad expires rather than letting datetime.fromisoformat
raise. Before the fix this raised a ValueError instead of returning None."""
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
_resolve_active_litellm_key,
)
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
class _FakePrisma:
async def get_data(self, token, table_name, parent_otel_span=None, proxy_logging_obj=None):
return UserAPIKeyAuth(token=token, user_id="u", expires="not-a-parseable-date")
proxy_globals.user_api_key_cache = UserApiKeyCache()
proxy_globals.prisma_client = _FakePrisma()
request = _token_request({"x-litellm-api-key": "sk-bad-expiry-key"})
assert await _resolve_active_litellm_key(request) == "no_active_key"
@pytest.mark.asyncio
async def test_resolve_active_litellm_key_no_active_key_without_litellm_key(proxy_globals):
"""No LiteLLM key on the request yields no hash without consulting the resolver."""
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
_resolve_active_litellm_key,
)
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
proxy_globals.user_api_key_cache = UserApiKeyCache()
proxy_globals.prisma_client = object()
request = _token_request({"content-type": "application/json"})
assert await _resolve_active_litellm_key(request) == "no_active_key"
@pytest.mark.asyncio
async def test_resolve_active_litellm_key_db_outage_is_unavailable(proxy_globals):
"""A database outage while resolving the presented key is a retryable infrastructure failure, not
the caller's fault, so the resolver reports "unavailable" (the mint statuses it 503) rather than
collapsing it to the same value as a missing credential. is_database_service_unavailable_error
classifies a connection error (an OSError) as an outage, matching admission's egress-side handling."""
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
_resolve_active_litellm_key,
)
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
class _OutagePrisma:
async def get_data(self, token, table_name, parent_otel_span=None, proxy_logging_obj=None):
raise ConnectionError("connection refused")
proxy_globals.user_api_key_cache = UserApiKeyCache()
proxy_globals.prisma_client = _OutagePrisma()
request = _token_request({"x-litellm-api-key": "sk-during-outage"})
assert await _resolve_active_litellm_key(request) == "unavailable"
@pytest.mark.asyncio
async def test_resolve_active_litellm_key_no_database_is_unresolvable(proxy_globals):
"""With no database connection configured the gateway cannot verify the presented key at all, so
the resolver reports "unresolvable" (the mint statuses it 500) instead of blaming the caller.
Mirrors admission, which 500s a missing prisma_client on the egress side."""
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
_resolve_active_litellm_key,
)
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
proxy_globals.user_api_key_cache = UserApiKeyCache()
proxy_globals.prisma_client = None
request = _token_request({"x-litellm-api-key": "sk-no-db"})
assert await _resolve_active_litellm_key(request) == "unresolvable"
@pytest.mark.asyncio
async def test_token_endpoint_uses_client_secret_basic_when_configured():
"""LIT-4091: a server with token_endpoint_auth_method=client_secret_basic must send the