feat(mcp): accept an ID-JAG as an RFC 7523 jwt-bearer grant on the gateway token endpoint (#46748)

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Yassin Kortam 2026-10-16 12:16:55 -07:00 • committed by GitHub
parent 1dfceccf10
commit bca7650e8f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 2535 additions and 126 deletions

View file

@ -1345,7 +1345,9 @@ class MCPRequestHandler:
if auth is None:
return None
refreshed = auth.model_copy()
if auth.mcp_admitted_user_subject and auth.user_id:
if (auth.mcp_admitted_user_subject or auth.is_session_token) and auth.user_id:
# A session credential (``lite login`` or a redeemed ID-JAG) has no key row, its authority is
# sealed in the token: refresh the live user policy and keep the token's team, routes and models.
current = await MCPRequestHandler.reload_admitted_user(auth.user_id, requires_fresh_policy=True)
refreshed.object_permission = current.object_permission
refreshed.object_permission_id = current.object_permission_id

View file

@ -56,6 +56,7 @@ from litellm.proxy._experimental.mcp_server.faults import (
render_token_fault,
)
from litellm.proxy._experimental.mcp_server.gateway_dcr_flow import (
JWT_BEARER_GRANT_TYPE,
VendorCredentialState,
aggregate_authorize,
aggregate_token,
@ -71,6 +72,11 @@ from litellm.proxy._experimental.mcp_server.gateway_dcr_flow import (
revoke_refresh_token,
supported_grant_types,
)
from litellm.proxy._experimental.mcp_server.id_jag_grant import (
basic_client_id,
jwt_bearer_available,
redeem_id_jag_assertion,
)
from litellm.proxy._experimental.mcp_server.idp_token_exchange import (
exchange_idp_subject_token,
token_exchange_available,
@ -2396,7 +2402,7 @@ async def token_endpoint(
grant_type: str = Form(...),
code: str = Form(None),
redirect_uri: str = Form(None),
client_id: str = Form(...),
client_id: str | None = Form(None),
client_secret: str | None = Form(None),
code_verifier: str = Form(None),
refresh_token: str | None = Form(None),
@ -2405,6 +2411,7 @@ async def token_endpoint(
subject_token: str | None = Form(None),
subject_token_type: str | None = Form(None),
requested_token_type: str | None = Form(None),
assertion: str | None = Form(None),
mcp_server_name: str | None = None,
):
"""
@ -2416,7 +2423,14 @@ async def token_endpoint(
3. Return the token
4. Return a virtual key in this response
"""
if mcp_server_name is None and is_gateway_dcr_client_id(client_id):
presented_client_id: Final = client_id or basic_client_id(request.headers.get("authorization"))
if presented_client_id is None:
raise HTTPException(
status_code=400, detail={"error": "invalid_request", "error_description": "client_id is required"}
)
if mcp_server_name is None and (
is_gateway_dcr_client_id(presented_client_id) or grant_type == JWT_BEARER_GRANT_TYPE
):
from litellm.proxy.proxy_server import ( # noqa: PLC0415 # circular import at module load
master_key,
user_api_key_cache,
@ -2427,7 +2441,7 @@ async def token_endpoint(
grant_type=grant_type,
code=code,
redirect_uri=redirect_uri,
client_id=client_id,
client_id=presented_client_id,
code_verifier=code_verifier,
refresh_token=refresh_token,
master_key=master_key,
@ -2439,9 +2453,12 @@ async def token_endpoint(
subject_token_type=subject_token_type,
requested_token_type=requested_token_type,
exchange_subject_token=exchange_idp_subject_token,
assertion=assertion,
client_secret=client_secret,
redeem_assertion=redeem_id_jag_assertion,
)
lookup_name: Final = mcp_server_name or client_id
lookup_name: Final = mcp_server_name or presented_client_id
client_ip: Final = IPAddressUtils.get_mcp_client_ip(request)
mcp_server = _resolve_mcp_server_by_name_or_id(lookup_name, client_ip)
if mcp_server is None and mcp_server_name is None:
@ -2454,7 +2471,7 @@ async def token_endpoint(
grant_type=grant_type,
code=code,
redirect_uri=redirect_uri,
client_id=client_id,
client_id=presented_client_id,
client_secret=client_secret,
code_verifier=code_verifier,
refresh_token=refresh_token,
@ -2568,7 +2585,8 @@ async def native_client_auth_discovery(request: Request) -> JSONResponse:
"""The versioned contract a native client (``lite login --pkce``, or a CLI in any other
language) reads to sign a user in through the browser and obtain a proxy credential."""
return JSONResponse(
native_client_auth_contract(request, token_exchange_available()), headers=TOKEN_NO_CACHE_HEADERS
native_client_auth_contract(request, token_exchange_available(), jwt_bearer_available()),
headers=TOKEN_NO_CACHE_HEADERS,
)
@ -3135,7 +3153,7 @@ def _aggregate_header_scopes(request: Request) -> tuple[str, ...]:
def _build_aggregate_authorization_server_response(
request: Request, token_exchange_available: bool
request: Request, token_exchange_available: bool, jwt_bearer_available: bool = False
) -> dict[str, object]:
"""RFC 8414 metadata for the gateway as the aggregate authorization server.
@ -3155,9 +3173,13 @@ def _build_aggregate_authorization_server_response(
"registration_endpoint": f"{request_base_url}/register",
"response_types_supported": ["code"],
"scopes_supported": [],
"grant_types_supported": supported_grant_types(token_exchange_available),
"grant_types_supported": supported_grant_types(token_exchange_available, jwt_bearer_available),
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none", "client_secret_post"],
"token_endpoint_auth_methods_supported": (
["none", "client_secret_post", "client_secret_basic"]
if jwt_bearer_available
else ["none", "client_secret_post"]
),
}
@ -3193,7 +3215,7 @@ async def oauth_authorization_server_aggregate(request: Request):
per-server row win here instead would serve an issuer of {base} against a resource that
advertised {base}/mcp, which fails the RFC 8414 issuer check and breaks the front door.
"""
return _build_aggregate_authorization_server_response(request, token_exchange_available())
return _build_aggregate_authorization_server_response(request, token_exchange_available(), jwt_bearer_available())
# Standard MCP pattern: /.well-known/oauth-protected-resource/mcp/{server_name}
@ -3273,6 +3295,10 @@ def _build_oauth_authorization_server_response(
if explicitly_named
else request_base_url
)
# With no upstream resolved the document is the gateway's own: its issuer is the bare origin
# and its /token serves every grant the gateway does, including the ID-JAG jwt-bearer grant.
gateway_owned: Final = mcp_server_name is None and issuer_path is None
jwt_bearer: Final = gateway_owned and jwt_bearer_available()
return {
"issuer": issuer,
@ -3280,9 +3306,15 @@ def _build_oauth_authorization_server_response(
"token_endpoint": token_endpoint,
"response_types_supported": ["code"],
"scopes_supported": (mcp_server.scopes if mcp_server and mcp_server.scopes else []),
"grant_types_supported": ["authorization_code", "refresh_token"],
"grant_types_supported": (
list(supported_grant_types(token_exchange_available(), jwt_bearer))
if gateway_owned
else ["authorization_code", "refresh_token"]
),
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["client_secret_post"],
"token_endpoint_auth_methods_supported": (
["none", "client_secret_post", "client_secret_basic"] if jwt_bearer else ["client_secret_post"]
),
# Claude expects a registration endpoint, even if we just fake it
"registration_endpoint": (
f"{request_base_url}/{mcp_server_name}/register" if mcp_server_name else f"{request_base_url}/register"

View file

@ -198,20 +198,26 @@ class MintProxyCredential(Protocol):
and mints the same credential ``lite login`` mints."""
def __call__(
self, user_id: str, team_id: str | None, /
self, user_id: str, team_id: str | None, /, *, allowed_routes: Sequence[str] | None = None
) -> Awaitable[MintedProxyCredential | ProxyCredentialMintFailure]: ...
TOKEN_EXCHANGE_GRANT_TYPE: Final = "urn:ietf:params:oauth:grant-type:token-exchange"
JWT_BEARER_GRANT_TYPE: Final = "urn:ietf:params:oauth:grant-type:jwt-bearer"
def supported_grant_types(token_exchange_available: bool) -> tuple[str, ...]:
def supported_grant_types(token_exchange_available: bool, jwt_bearer_available: bool = False) -> tuple[str, ...]:
"""The grants ``/token`` can serve on this deployment. The RFC 8693 exchange is listed
only where the JWT auth that proves a subject token is on, backed by a database, and
licensed, so a client never selects a grant the gateway would then refuse."""
if token_exchange_available:
return ("authorization_code", "refresh_token", TOKEN_EXCHANGE_GRANT_TYPE)
return ("authorization_code", "refresh_token")
licensed, so a client never selects a grant the gateway would then refuse. The RFC 7523
jwt-bearer grant (an ID-JAG from the identity provider) is listed only where, on top of
that, the operator has allowlisted at least one requesting app."""
return (
"authorization_code",
"refresh_token",
*((TOKEN_EXCHANGE_GRANT_TYPE,) if token_exchange_available else ()),
*((JWT_BEARER_GRANT_TYPE,) if jwt_bearer_available else ()),
)
"""RFC 8693: a native client that already holds a token from the customer's identity
@ -236,10 +242,29 @@ class SubjectIdentity(LiteLLMBaseModel):
class SubjectTokenRefusal(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True)
error: Literal["unsupported_grant_type", "invalid_request", "temporarily_unavailable"]
error: Literal[
"unsupported_grant_type",
"invalid_request",
"invalid_grant",
"invalid_client",
"invalid_scope",
"temporarily_unavailable",
]
description: str = Field(min_length=1)
class AssertionIdentity(SubjectIdentity):
"""The litellm user an RFC 7523 assertion (an ID-JAG) stands for, plus what the grant
tail needs from the assertion itself: the single-use key for its ``jti``, how long that
marker must outlive it, the ``scope`` the credential is issued for, and the route groups that
scope narrows the credential to (``None`` leaves it the full ``lite login`` credential)."""
replay_key: str = Field(min_length=1)
replay_ttl_seconds: int = Field(gt=0)
scope: str | None = None
allowed_routes: tuple[str, ...] | None = None
class ExchangeSubjectToken(Protocol):
"""Injected RFC 8693 subject-token verifier ``(subject_token, request)``: proves the
IdP token the way the proxy's own JWT auth does and names the litellm user and team it
@ -272,7 +297,9 @@ class LookupConsentTeams(Protocol):
def __call__(self, user_id: str, /) -> Awaitable[tuple[ConsentTeam, ...] | ReloadUserFailure]: ...
async def _refuse_proxy_credential(user_id: str, team_id: str | None) -> ProxyCredentialMintFailure:
async def _refuse_proxy_credential(
user_id: str, team_id: str | None, *, allowed_routes: Sequence[str] | None = None
) -> ProxyCredentialMintFailure:
return "unresolvable"
@ -282,6 +309,25 @@ async def _refuse_subject_token(subject_token: str, request: Request) -> Subject
)
class RedeemAssertion(Protocol):
"""Injected RFC 7523 assertion verifier ``(assertion, client_id, client_secret, request)``:
proves an ID-JAG the identity provider minted for the requesting app ``client_id``,
authenticates that app, and names the litellm user the assertion stands for, or says why
this gateway will not take it."""
def __call__(
self, assertion: str, client_id: str, client_secret: str | None, request: Request, /
) -> Awaitable[AssertionIdentity | SubjectTokenRefusal]: ...
async def _refuse_assertion(
assertion: str, client_id: str, client_secret: str | None, request: Request
) -> SubjectTokenRefusal:
return SubjectTokenRefusal(
error="unsupported_grant_type", description="this gateway is not configured to redeem identity assertions"
)
async def _unavailable_vendor_credential(
user_id: str, server_id: str, requested_scopes: tuple[str, ...] = ()
) -> VendorCredentialState:
@ -797,7 +843,9 @@ class NativeClientAuthContract(TypedDict):
revocation_endpoint_auth_methods_supported: ReadOnly[tuple[str, ...]]
def native_client_auth_contract(request: Request, token_exchange_available: bool) -> NativeClientAuthContract:
def native_client_auth_contract(
request: Request, token_exchange_available: bool, jwt_bearer_available: bool = False
) -> NativeClientAuthContract:
"""The versioned discovery document at ``/.well-known/litellm-cli-auth``: everything a
native client (in any language) needs to run the sign-in without reading LiteLLM
source. ``resource`` is the exact value to send as the RFC 8707 ``resource`` parameter
@ -812,7 +860,7 @@ def native_client_auth_contract(request: Request, token_exchange_available: bool
"revocation_endpoint": f"{base_url}/revoke",
"resource": base_url,
"response_types_supported": ("code",),
"grant_types_supported": supported_grant_types(token_exchange_available),
"grant_types_supported": supported_grant_types(token_exchange_available, jwt_bearer_available),
"code_challenge_methods_supported": ("S256",),
"token_endpoint_auth_methods_supported": ("none",),
"revocation_endpoint_auth_methods_supported": ("none",),
@ -1403,10 +1451,11 @@ class _ProxyCredentialTokenResponse(TypedDict):
access_token: ReadOnly[str]
token_type: ReadOnly[Literal["Bearer"]]
expires_in: ReadOnly[int]
refresh_token: ReadOnly[str]
refresh_token: NotRequired[ReadOnly[str]]
user_id: ReadOnly[str]
team_id: ReadOnly[str | None]
issued_token_type: NotRequired[ReadOnly[_IssuedTokenType]]
scope: NotRequired[ReadOnly[str]]
def proxy_credential_response(
@ -1415,6 +1464,7 @@ def proxy_credential_response(
signing: SessionSigning,
issued_token_type: _IssuedTokenType | None = None,
family: str | None = None,
scope: str | None = None,
) -> Response:
"""The proxy-API token response: the access token is the very credential ``lite
login`` stores (accepted on every proxy route with user and team attribution), and
@ -1426,7 +1476,7 @@ def proxy_credential_response(
refresh: Final = mint_session_refresh_token(bound_principal, signing.keys, signing.now, family=family)
if not isinstance(refresh, MintedSessionToken):
return _oauth_error(500, "server_error", "failed to mint the session credential")
credential: Final[_ProxyCredentialTokenResponse] = {
renewable: Final[_ProxyCredentialTokenResponse] = {
"access_token": minted.key,
"token_type": "Bearer",
"expires_in": minted.expires_in,
@ -1434,12 +1484,27 @@ def proxy_credential_response(
"user_id": minted.user_id,
"team_id": minted.team_id,
}
credential: Final[_ProxyCredentialTokenResponse] = renewable if scope is None else {**renewable, "scope": scope}
if issued_token_type is None:
return JSONResponse(status_code=200, content=credential, headers=TOKEN_NO_CACHE_HEADERS)
exchanged: Final[_ProxyCredentialTokenResponse] = {**credential, "issued_token_type": issued_token_type}
return JSONResponse(status_code=200, content=exchanged, headers=TOKEN_NO_CACHE_HEADERS)
def assertion_credential_response(minted: MintedProxyCredential, scope: str | None) -> Response:
"""The proxy-API token response for an assertion grant: the same access token, with the
scope the assertion carried, and no refresh token."""
unscoped: Final[_ProxyCredentialTokenResponse] = {
"access_token": minted.key,
"token_type": "Bearer",
"expires_in": minted.expires_in,
"user_id": minted.user_id,
"team_id": minted.team_id,
}
credential: Final[_ProxyCredentialTokenResponse] = unscoped if scope is None else {**unscoped, "scope": scope}
return JSONResponse(status_code=200, content=credential, headers=TOKEN_NO_CACHE_HEADERS)
def _reload_failure_response(failure: ReloadUserFailure) -> Response:
"""Map the live-user revalidation failure onto its OAuth error, exhaustively, so a new
``ReloadUserFailure`` member is a type error here rather than silently 400ing."""
@ -1460,7 +1525,9 @@ def _subject_token_refusal_response(refusal: SubjectTokenRefusal) -> Response:
match refusal.error:
case "temporarily_unavailable":
return _oauth_error(503, refusal.error, refusal.description)
case "unsupported_grant_type" | "invalid_request":
case "invalid_client":
return _oauth_error(401, refusal.error, refusal.description)
case "unsupported_grant_type" | "invalid_request" | "invalid_grant" | "invalid_scope":
return _oauth_error(400, refusal.error, refusal.description)
case _:
assert_never(refusal.error)
@ -1515,6 +1582,9 @@ async def aggregate_token(
subject_token_type: str | None = None,
requested_token_type: str | None = None,
exchange_subject_token: ExchangeSubjectToken = _refuse_subject_token,
assertion: str | None = None,
client_secret: str | None = None,
redeem_assertion: RedeemAssertion = _refuse_assertion,
) -> Response:
"""The aggregate token verb: authorization_code and refresh_token grants for the
identity-only session pair, or for the proxy-API credential when the grant was issued
@ -1561,10 +1631,19 @@ async def aggregate_token(
exchange_subject_token=exchange_subject_token,
issue=issue,
)
if grant_type == JWT_BEARER_GRANT_TYPE:
return await _jwt_bearer_grant(
request=request,
assertion=assertion,
client_id=client_id,
client_secret=client_secret,
redeem_assertion=redeem_assertion,
issue=issue,
)
return _oauth_error(
400,
"unsupported_grant_type",
f"grant_type must be authorization_code, refresh_token, or {TOKEN_EXCHANGE_GRANT_TYPE}",
f"grant_type must be authorization_code, refresh_token, {TOKEN_EXCHANGE_GRANT_TYPE}, or {JWT_BEARER_GRANT_TYPE}",
)
@ -1690,6 +1769,48 @@ class _GrantIssuer:
return _mint_failure_response(minted)
return proxy_credential_response(minted, principal, self._signing, issued_token_type=ACCESS_TOKEN_TOKEN_TYPE)
async def redeem_assertion(
self, assertion: str, client_id: str, client_secret: str | None, redeem_assertion: RedeemAssertion
) -> Response:
"""The RFC 7523 tail: prove the assertion and the requesting app, claim the assertion's
``jti`` before anything is minted (RFC 7523 section 3 makes a replayed ``jti`` a
rejection, and the identity provider mints a fresh assertion cheaply, so a mint that
fails after the claim costs the client one more round trip, never a replay window),
then mint the proxy credential the user is revalidated for. No refresh token comes
with it (RFC 7521 section 4.1): the app renews by asking its identity provider for a
fresh assertion, which keeps the user's standing there in the loop."""
target_refusal: Final = self._assertion_target_refusal()
if target_refusal is not None:
return target_refusal
identity: Final = await redeem_assertion(assertion, client_id, client_secret, self._request)
if isinstance(identity, SubjectTokenRefusal):
return _subject_token_refusal_response(identity)
refused: Final = _claim_refusal(
await self._guard.claim(identity.replay_key, identity.replay_ttl_seconds),
_oauth_error(400, "invalid_grant", "the assertion has already been redeemed"),
)
if refused is not None:
return refused
minted: Final = await self._mint_proxy_credential(
identity.user_id, identity.team_id, allowed_routes=identity.allowed_routes
)
if not isinstance(minted, MintedProxyCredential):
return _mint_failure_response(minted)
return assertion_credential_response(minted, identity.scope)
def _assertion_target_refusal(self) -> Response | None:
"""An ID-JAG names the proxy origin or its MCP resource (``{base}/mcp``) as ``aud``, and an RFC
8707 client sends that same value as ``resource``, so both are this grant's targets."""
if self._resource is None or is_proxy_api_resource(self._request, self._resource):
return None
if self._resource.rstrip("/") == f"{get_request_base_url(self._request)}/mcp":
return None
return _oauth_error(
400,
"invalid_target",
"resource does not match the proxy API or MCP resource this gateway issues credentials for",
)
def _proxy_api_target_refusal(self) -> Response | None:
if self._resource is None or is_proxy_api_resource(self._request, self._resource):
return None
@ -1807,6 +1928,19 @@ async def refresh_proxy_credential(
)
async def _jwt_bearer_grant(
request: Request,
assertion: str | None,
client_id: str,
client_secret: str | None,
redeem_assertion: RedeemAssertion,
issue: _GrantIssuer,
) -> Response:
if not assertion:
return _oauth_error(400, "invalid_request", "assertion is required")
return await issue.redeem_assertion(assertion, client_id, client_secret, redeem_assertion)
async def _token_exchange_grant(
subject_token: str | None,
subject_token_type: str | None,

View file

@ -0,0 +1,540 @@
"""The RFC 7523 jwt-bearer grant on ``POST /token``: a requesting app that obtained an
ID-JAG (Identity Assertion Authorization Grant, the cross-app access profile) from the
customer's identity provider redeems it for the same proxy-API credential ``lite login``
stores. The assertion is proven with the JWT auth issuer and JWKS plumbing the proxy
already trusts, its claims are checked the way the ID-JAG draft prescribes, the requesting
app must be one the operator allowlisted, and the ``jti`` is single-use until the
assertion expires."""
from __future__ import annotations
import base64
import binascii
import hashlib
import hmac
import math
from collections.abc import Awaitable, Iterator, Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from functools import partial
from types import MappingProxyType
from typing import Final, Protocol
from urllib.parse import unquote_plus
import jwt
from fastapi import Request
from litellm._logging import verbose_proxy_logger
from litellm.models.user import LiteLLM_UserTable
from litellm.proxy._experimental.mcp_server.gateway_dcr_flow import (
AssertionIdentity,
ConsentTeam,
SubjectIdentity,
SubjectTokenRefusal,
)
from litellm.proxy._experimental.mcp_server.idp_token_exchange import (
gateway_outage_refusal,
log_line,
read_token_exchange_prerequisites,
)
from litellm.proxy._experimental.mcp_server.oauth_utils import proxy_base_url_from_env
from litellm.proxy._types import IdJagRequestingApp
from litellm.proxy.auth.handle_jwt import ID_JAG_TYP, AssertionRejection, JWTHandler
ID_JAG_REQUIRED_CLAIMS: Final = ("iss", "sub", "aud", "exp", "iat", "jti", "client_id")
REJECTED_ASSERTION: Final = "assertion was rejected"
TEAM_HEADER: Final = "x-litellm-team-id"
_INACTIVE_SUBJECT: Final = frozenset(("no_active_key", "unresolvable"))
_REPLAY_KEY_PREFIX: Final = "mcp_gateway_dcr:id_jag:"
SCOPE_MCP: Final = "litellm:mcp"
SCOPE_LLM: Final = "litellm:llm"
SCOPE_ROUTE_GROUPS: Final = MappingProxyType(
{SCOPE_MCP: ("mcp_inference_routes",), SCOPE_LLM: ("llm_api_routes_without_mcp",)}
)
@dataclass(frozen=True, slots=True)
class GrantedScope:
"""What an assertion's ``scope`` claim buys: the scope echoed in the token response, exactly the
tokens enforced, and the route groups the credential is narrowed to. No claim means the full
credential, so both are ``None``."""
scope: str | None
allowed_routes: tuple[str, ...] | None
def granted_scope(claim: object, *, mcp_resource: bool) -> GrantedScope | SubjectTokenRefusal:
"""The routes the assertion buys. With ``aud`` the bare origin the ``scope`` claim decides and
no claim is the full credential. With ``aud`` the MCP resource (``{base}/mcp``) the identity
provider named that resource, so the credential is ceilinged to the MCP routes whatever the
claim says: ``litellm:llm`` cannot be granted and is refused."""
if claim is not None and not isinstance(claim, str):
return SubjectTokenRefusal(error="invalid_scope", description=_SCOPE_DESCRIPTION)
requested: Final = frozenset(claim.split()) if claim else frozenset()
if not requested <= SCOPE_ROUTE_GROUPS.keys():
return SubjectTokenRefusal(error="invalid_scope", description=_SCOPE_DESCRIPTION)
if mcp_resource:
if SCOPE_LLM in requested:
return SubjectTokenRefusal(error="invalid_scope", description=_MCP_RESOURCE_DESCRIPTION)
return GrantedScope(scope=SCOPE_MCP, allowed_routes=SCOPE_ROUTE_GROUPS[SCOPE_MCP])
if not requested:
return GrantedScope(scope=None, allowed_routes=None)
granted: Final = tuple(scope for scope in SCOPE_ROUTE_GROUPS if scope in requested)
return GrantedScope(scope=" ".join(granted), allowed_routes=tuple(_route_groups(granted)))
def names_mcp_resource(aud: object, base_url: str | None) -> bool:
"""Whether the verified ``aud`` names only the MCP resource, ``{base}/mcp``, and not the
authorization server itself (the bare origin)."""
audiences: Final = frozenset((aud,) if isinstance(aud, str) else aud if isinstance(aud, list) else ())
return base_url is not None and f"{base_url}/mcp" in audiences and base_url not in audiences
def _route_groups(scopes: Sequence[str]) -> Iterator[str]:
for scope in scopes:
yield from SCOPE_ROUTE_GROUPS[scope]
_SCOPE_DESCRIPTION: Final = (
f"scope must be {SCOPE_MCP} (MCP gateway routes), {SCOPE_LLM} (LLM API routes), or both, space separated"
)
_MCP_RESOURCE_DESCRIPTION: Final = (
f"the /mcp audience only grants MCP routes ({SCOPE_MCP}); use the bare origin as audience for LLM routes"
)
@dataclass(frozen=True, slots=True)
class IdJagPrerequisites:
"""The deployment-level gates for redeeming an ID-JAG: the JWT auth whose issuer plumbing
proves the assertion must be on, backed by a database, and licensed, not mapping IdP tokens to
virtual keys (whose blocks and budgets a minted credential would sidestep), with a shared Redis
so an assertion is redeemed once across every worker and replica (the in-memory marker is per
process, so a second worker would mint again), ``PROXY_BASE_URL``
must fix the gateway URL the assertion's ``aud`` has to name (a request's ``Host`` is the
caller's to choose), and the operator must have allowlisted the requesting apps. Discovery advertises the grant only when every gate
holds, and a redemption is refused naming the first one that does not."""
jwt_auth_enabled: bool
has_database: bool
licensed: bool
maps_jwts_to_virtual_keys: bool
shared_replay_store: bool
base_url: str | None
requesting_apps: tuple[IdJagRequestingApp, ...]
@property
def available(self) -> bool:
return (
self.jwt_auth_enabled
and self.has_database
and self.licensed
and not self.maps_jwts_to_virtual_keys
and self.shared_replay_store
and self.base_url is not None
and bool(self.requesting_apps)
)
def refusal(self) -> SubjectTokenRefusal | None:
if not self.jwt_auth_enabled:
return SubjectTokenRefusal(
error="unsupported_grant_type",
description="JWT auth is not enabled on this gateway, so it cannot redeem identity assertions",
)
if not self.has_database:
return SubjectTokenRefusal(
error="unsupported_grant_type",
description="this gateway has no database, so it cannot redeem identity assertions",
)
if not self.licensed:
return SubjectTokenRefusal(
error="unsupported_grant_type",
description="JWT auth is an enterprise only feature; no license is set",
)
if self.maps_jwts_to_virtual_keys:
return SubjectTokenRefusal(
error="unsupported_grant_type",
description="this gateway maps IdP tokens to virtual keys, which the jwt-bearer grant does not serve",
)
if not self.shared_replay_store:
return SubjectTokenRefusal(
error="unsupported_grant_type",
description="configure Redis (cache_params or coordination_redis) so this gateway can redeem an identity assertion exactly once across its workers",
)
if self.base_url is None:
return SubjectTokenRefusal(
error="unsupported_grant_type",
description="set PROXY_BASE_URL to this gateway's public URL, the audience an identity assertion must name",
)
if not self.requesting_apps:
return SubjectTokenRefusal(
error="unsupported_grant_type",
description="this gateway has no requesting apps allowlisted for identity assertions",
)
return None
def read_id_jag_prerequisites() -> IdJagPrerequisites:
from litellm.proxy.proxy_server import ( # noqa: PLC0415 # rebound after startup, so read them per call
jwt_handler,
redis_usage_cache,
)
shared: Final = read_token_exchange_prerequisites()
return IdJagPrerequisites(
jwt_auth_enabled=shared.jwt_auth_enabled,
has_database=shared.has_database,
licensed=shared.licensed,
maps_jwts_to_virtual_keys=shared.maps_jwts_to_virtual_keys,
shared_replay_store=redis_usage_cache is not None,
base_url=proxy_base_url_from_env(),
requesting_apps=_requesting_apps(jwt_handler),
)
def _requesting_apps(jwt_handler: JWTHandler) -> tuple[IdJagRequestingApp, ...]:
if not hasattr(jwt_handler, "litellm_jwtauth"):
return ()
return tuple(jwt_handler.litellm_jwtauth.id_jag_requesting_apps or ())
def jwt_bearer_available() -> bool:
return read_id_jag_prerequisites().available
def gateway_audiences(base_url: str | None) -> tuple[str, ...]:
"""The issuer values this gateway answers to, which an ID-JAG's ``aud`` must name: its MCP
resource and its authorization server, both under the fixed ``PROXY_BASE_URL``."""
return () if base_url is None else (f"{base_url}/mcp", base_url)
class DecodeAssertion(Protocol):
"""Injected signature and claim verification ``(assertion, audience, required_claims)``:
``JWTHandler.decode_trusted_assertion`` in production, which returns the verified claims,
a rejection, or raises when the gateway could not reach the issuer's keys."""
def __call__(
self, assertion: str, audience: Sequence[str], required_claims: Sequence[str], /
) -> Awaitable[Mapping[str, object] | AssertionRejection]: ...
class ClaimReader(Protocol):
"""The JWT auth user mapping as ``JWTHandler`` exposes it: the litellm user id and email the
verified claims name through ``user_id_jwt_field`` and ``user_email_jwt_field``."""
def get_user_id(self, token: dict[str, object], default_value: str | None) -> str | None: ...
def get_user_email(self, token: dict[str, object], default_value: str | None) -> str | None: ...
def get_all_jwt_team_ids(self, token: dict[str, object]) -> list[str]: ...
def is_allowed_domain(self, user_email: str) -> bool: ...
class ResolveSubject(Protocol):
"""Injected subject lookup ``(claims, team_header)``: the litellm user the verified claims
name through the JWT auth user mapping (``user_id_jwt_field`` or ``user_email_jwt_field``,
else ``sub``) and the team the credential is minted for (see ``select_team``), ``None`` when
the gateway knows no such user, or a refusal when the team cannot be settled. Raises on a
database outage."""
def __call__(
self, claims: Mapping[str, object], team_header: str | None, /
) -> Awaitable[SubjectIdentity | SubjectTokenRefusal | None]: ...
class LookupUser(Protocol):
"""Injected ``(user_id, user_email)`` lookup: the stored id of the existing litellm user the
claims name, ``None`` when the gateway knows no such user. Raises on a database outage."""
def __call__(self, user_id: str, user_email: str | None, /) -> Awaitable[str | None]: ...
class LookupTeams(Protocol):
"""Injected lookup of the teams a user is on, or the failure text when they cannot be read."""
def __call__(self, user_id: str, /) -> Awaitable[Sequence[ConsentTeam] | str]: ...
class GetUser(Protocol):
def __call__(
self, *, user_id: str, user_email: str | None, sso_user_id: str
) -> Awaitable[LiteLLM_UserTable | None]: ...
def proxy_user_lookup(get_user: GetUser) -> LookupUser:
"""``LookupUser`` over the proxy's user table. The assertion's subject is matched as the
local ``user_id`` and as the ``sso_user_id`` an SSO login linked, the way the JWT and SSO
login paths match it, so a linked account needs no email claim."""
async def lookup_user(user_id: str, user_email: str | None) -> str | None:
user: Final = await get_user(user_id=user_id, user_email=user_email, sso_user_id=user_id)
return None if user is None else user.user_id
return lookup_user
def subject_resolver(reader: ClaimReader, lookup_user: LookupUser, lookup_teams: LookupTeams) -> ResolveSubject:
"""``ResolveSubject`` over the JWT auth claim mapping and the proxy's user and team lookups."""
async def resolve_subject(
claims: Mapping[str, object], team_header: str | None
) -> SubjectIdentity | SubjectTokenRefusal | None:
token: Final = dict(claims)
subject: Final = claims.get("sub")
claimed_user_id: Final = reader.get_user_id(token=token, default_value=None) or (
subject if isinstance(subject, str) else None
)
if claimed_user_id is None:
return None
user_email: Final = reader.get_user_email(token=token, default_value=None)
if not reader.is_allowed_domain(user_email or ""):
return SubjectTokenRefusal(
error="invalid_grant",
description="the assertion's subject email is missing or outside the allowed domain",
)
user_id: Final = await lookup_user(claimed_user_id, user_email)
if user_id is None:
return None
teams: Final = await lookup_teams(user_id)
if teams in _INACTIVE_SUBJECT:
return SubjectTokenRefusal(
error="invalid_grant", description="the assertion's subject is not an active user"
)
if isinstance(teams, str):
return SubjectTokenRefusal(
error="temporarily_unavailable", description="the gateway could not read this user's teams; retry"
)
team: Final = select_team(team_header, tuple(reader.get_all_jwt_team_ids(token=token)), teams)
if isinstance(team, SubjectTokenRefusal):
return team
return SubjectIdentity(user_id=user_id, team_id=team)
return resolve_subject
def select_team(
team_header: str | None, claim_teams: Sequence[str], teams: Sequence[ConsentTeam]
) -> str | None | SubjectTokenRefusal:
"""The team an ID-JAG redemption mints for, settled before the assertion is consumed since
the minter refuses a teamless credential for a user who has a team. The ``x-litellm-team-id``
header (a team id or alias) wins and must name one of the user's teams, then the assertion's
mapped team claim, then the user's only team. A user on several teams must name one."""
if team_header is not None:
named: Final = next((team for team in teams if team_header in (team.team_id, team.team_alias)), None)
if named is None:
return SubjectTokenRefusal(
error="invalid_request", description=f"{TEAM_HEADER} does not name one of this user's teams"
)
return named.team_id
asserted: Final = tuple(team.team_id for team in teams if team.team_id in claim_teams)
if len(asserted) == 1:
return asserted[0]
if len(asserted) > 1:
return SubjectTokenRefusal(
error="invalid_request",
description=f"the assertion names several of this user's teams; send the {TEAM_HEADER} header naming the team for this credential",
)
if len(teams) == 1:
return teams[0].team_id
if teams:
return SubjectTokenRefusal(
error="invalid_request",
description=f"this user belongs to several teams; send the {TEAM_HEADER} header naming the team for this credential",
)
return None
@dataclass(frozen=True, slots=True)
class AssertionRedemption:
"""One ``jwt-bearer`` request as the grant sees it: the assertion, how the requesting app
identified and authenticated itself, the team the credential is asked for, the issuer
values this gateway answers to (what the assertion's ``aud`` must name), the clock, and the
leeway the verifier grants ``exp``, so the replay marker outlives every moment the assertion
would still verify."""
assertion: str
client_id: str
client_secret: str | None
authorization_header: str | None
team_id: str | None
audiences: tuple[str, ...]
now: float
leeway_seconds: float
async def redeem_id_jag_assertion(
assertion: str, client_id: str, client_secret: str | None, request: Request
) -> AssertionIdentity | SubjectTokenRefusal:
from litellm.proxy._experimental.mcp_server.proxy_api_credentials import ( # noqa: PLC0415 # circular import at module load
lookup_consent_teams_from_database,
)
from litellm.proxy.auth.auth_checks import get_user_object # noqa: PLC0415 # circular import at module load
from litellm.proxy.proxy_server import ( # noqa: PLC0415 # rebound after startup, so read them per call
jwt_handler,
prisma_client,
proxy_logging_obj,
user_api_key_cache,
)
resolve_subject: Final = subject_resolver(
_claim_reader(jwt_handler),
proxy_user_lookup(
partial(
get_user_object,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
user_id_upsert=False,
proxy_logging_obj=proxy_logging_obj,
check_db_only=True,
)
),
lookup_consent_teams_from_database,
)
prerequisites: Final = read_id_jag_prerequisites()
return await identity_from_id_jag(
AssertionRedemption(
assertion=assertion,
client_id=client_id,
client_secret=client_secret,
authorization_header=request.headers.get("authorization"),
team_id=request.headers.get(TEAM_HEADER),
audiences=gateway_audiences(prerequisites.base_url),
now=datetime.now(timezone.utc).timestamp(),
leeway_seconds=jwt_handler.leeway,
),
prerequisites=prerequisites,
decode=jwt_handler.decode_trusted_assertion,
resolve_subject=resolve_subject,
)
def _claim_reader(handler: ClaimReader) -> ClaimReader:
return handler
async def identity_from_id_jag(
redemption: AssertionRedemption,
prerequisites: IdJagPrerequisites,
decode: DecodeAssertion,
resolve_subject: ResolveSubject,
) -> AssertionIdentity | SubjectTokenRefusal:
"""Prove an ID-JAG for the grant tail. The requesting app is checked first, the way a token
endpoint authenticates its client before it looks at the grant, so an app the operator never
allowlisted learns nothing about the assertion. Everything wrong with the assertion itself is
``invalid_grant`` (RFC 7523 section 3.1), a check the gateway could not complete is
``temporarily_unavailable``, and the reason stays in the proxy log because this endpoint is
public and the verifier's wording can quote the assertion."""
unmet: Final = prerequisites.refusal()
if unmet is not None:
return unmet
app: Final = next((app for app in prerequisites.requesting_apps if app.client_id == redemption.client_id), None)
if app is None:
return SubjectTokenRefusal(error="invalid_client", description="client_id is not an allowlisted requesting app")
if not _client_authenticated(app, redemption.client_secret, redemption.authorization_header):
return SubjectTokenRefusal(error="invalid_client", description="client authentication failed")
try:
header: Final = jwt.get_unverified_header(redemption.assertion)
except jwt.PyJWTError:
return SubjectTokenRefusal(error="invalid_grant", description="assertion is not a JWT")
if header.get("typ") != ID_JAG_TYP:
return SubjectTokenRefusal(error="invalid_grant", description=f"assertion typ must be {ID_JAG_TYP}")
try:
verified: Final = await decode(redemption.assertion, redemption.audiences, ID_JAG_REQUIRED_CLAIMS)
except Exception as denied: # noqa: BLE001 # the verifier raises its 503 for a JWKS outage, and anything else is a bad assertion
return gateway_outage_refusal(denied, denied, what="an assertion", parameter="assertion") or _rejected(
str(denied)
)
if isinstance(verified, AssertionRejection):
return _rejected(verified.reason)
marker: Final = _replay_marker(verified, redemption)
if isinstance(marker, SubjectTokenRefusal):
return marker
replay_key, replay_ttl_seconds = marker
scope: Final = granted_scope(
verified.get("scope"), mcp_resource=names_mcp_resource(verified.get("aud"), prerequisites.base_url)
)
if isinstance(scope, SubjectTokenRefusal):
return scope
try:
subject: Final = await resolve_subject(verified, redemption.team_id)
except Exception as failed: # noqa: BLE001 # the user lookup raises for a missing user and for a database outage alike
return gateway_outage_refusal(
failed, failed, what="an assertion's subject", parameter="assertion"
) or _rejected(f"subject lookup failed: {failed}")
if subject is None:
return _rejected("the assertion names no user the gateway knows")
if isinstance(subject, SubjectTokenRefusal):
return subject
return AssertionIdentity(
user_id=subject.user_id,
team_id=subject.team_id,
replay_key=replay_key,
replay_ttl_seconds=replay_ttl_seconds,
scope=scope.scope,
allowed_routes=scope.allowed_routes,
)
def _replay_marker(
verified: Mapping[str, object], redemption: AssertionRedemption
) -> tuple[str, int] | SubjectTokenRefusal:
"""The single-use key and its lifetime for a verified assertion, once the assertion is bound to
the presenting app, carries no proof-of-possession the gateway cannot check, and is still alive."""
if verified.get("client_id") != redemption.client_id:
return _rejected("the assertion's client_id is not the requesting app that presented it")
if "cnf" in verified:
return _rejected("the assertion is proof-bound and this gateway does not check proof of possession")
issuer: Final = verified.get("iss")
jti: Final = verified.get("jti")
exp: Final = verified.get("exp")
if not isinstance(issuer, str) or not isinstance(jti, str) or not jti or not isinstance(exp, (int, float)):
return _rejected("the assertion's iss, jti, or exp claim has the wrong shape")
replay_ttl_seconds: Final = math.ceil(exp + redemption.leeway_seconds - redemption.now)
if replay_ttl_seconds <= 0:
return _rejected("the assertion has expired")
return _REPLAY_KEY_PREFIX + hashlib.sha256(f"{issuer}\n{jti}".encode()).hexdigest(), replay_ttl_seconds
def _rejected(reason: str) -> SubjectTokenRefusal:
verbose_proxy_logger.warning("jwt-bearer grant refused an assertion: %s", log_line(reason))
return SubjectTokenRefusal(error="invalid_grant", description=REJECTED_ASSERTION)
def _client_authenticated(app: IdJagRequestingApp, form_secret: str | None, authorization_header: str | None) -> bool:
"""A public app (no secret configured) is identified by ``client_id`` alone. A confidential
app must present its secret, by HTTP Basic (RFC 6749 section 2.3.1, the credentials
form-urlencoded inside the base64) or the ``client_secret`` form field, compared in constant
time."""
if app.client_secret is None:
return True
basic: Final = _basic_credentials(authorization_header)
if basic is not None:
return basic[0] == app.client_id and _same_secret(basic[1], app.client_secret)
return form_secret is not None and _same_secret(form_secret, app.client_secret)
def _same_secret(presented: str, expected: str) -> bool:
return hmac.compare_digest(presented.encode(), expected.encode())
def basic_client_id(authorization_header: str | None) -> str | None:
"""The ``client_id`` an HTTP Basic ``Authorization`` header carries, for a confidential app
that leaves the form field out as RFC 6749 section 2.3.1 allows."""
credentials: Final = _basic_credentials(authorization_header)
return None if credentials is None else credentials[0]
def _basic_credentials(authorization_header: str | None) -> tuple[str, str] | None:
if authorization_header is None:
return None
scheme, _, encoded = authorization_header.strip().partition(" ")
if scheme.lower() != "basic" or not encoded:
return None
try:
decoded: Final = base64.b64decode(encoded.strip(), validate=True).decode()
except (binascii.Error, UnicodeDecodeError):
return None
client_id, separator, secret = decoded.partition(":")
if not separator:
return None
return unquote_plus(client_id), unquote_plus(secret)

View file

@ -20,13 +20,15 @@ from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler
EXCHANGE_ROUTE: Final = "/token"
REJECTED_SUBJECT_TOKEN: Final = "subject_token was rejected by the gateway's JWT auth"
SUBJECT_TOKEN_CHECK_UNAVAILABLE: Final = (
"the gateway could not verify subject_token because its identity provider or database is unavailable; retry"
_CHECK_UNAVAILABLE: Final = (
"the gateway could not verify {parameter} because its identity provider or database is unavailable; retry"
)
SUBJECT_TOKEN_CHECK_FAULTED: Final = (
"the gateway could not verify subject_token because its database reported a fault that is not a transient "
_CHECK_FAULTED: Final = (
"the gateway could not verify {parameter} because its database reported a fault that is not a transient "
"outage; retrying will not help until the gateway deployment is repaired"
)
SUBJECT_TOKEN_CHECK_UNAVAILABLE: Final = _CHECK_UNAVAILABLE.format(parameter="subject_token")
SUBJECT_TOKEN_CHECK_FAULTED: Final = _CHECK_FAULTED.format(parameter="subject_token")
GatewayOutage = Literal["retryable", "faulted"]
@ -182,20 +184,37 @@ async def identity_from_subject_token(
def _refusal_for(denied: Exception, reason: object) -> SubjectTokenRefusal:
outage: Final = gateway_outage_refusal(denied, reason, what="a subject_token", parameter="subject_token")
if outage is not None:
return outage
verbose_proxy_logger.warning("token exchange refused a subject_token: %s", log_line(reason))
return SubjectTokenRefusal(error="invalid_request", description=REJECTED_SUBJECT_TOKEN)
def gateway_outage_refusal(denied: Exception, reason: object, what: str, parameter: str) -> SubjectTokenRefusal | None:
"""The ``temporarily_unavailable`` refusal when ``denied`` is the gateway failing to check
``what`` (its database or the IdP's JWKS down), naming the grant ``parameter`` it could not verify,
else ``None`` because the token itself was bad."""
outage: Final = _gateway_could_not_verify(denied)
if outage is None:
verbose_proxy_logger.warning("token exchange refused a subject_token: %s", reason)
return SubjectTokenRefusal(error="invalid_request", description=REJECTED_SUBJECT_TOKEN)
verbose_proxy_logger.error("token exchange could not verify a subject_token, %s: %s", outage, reason)
return SubjectTokenRefusal(error="temporarily_unavailable", description=_check_unavailable_description(outage))
return None
verbose_proxy_logger.error("token endpoint could not verify %s, %s: %s", what, outage, log_line(reason))
return SubjectTokenRefusal(
error="temporarily_unavailable", description=_check_unavailable_description(outage, parameter)
)
def _check_unavailable_description(outage: GatewayOutage) -> str:
def log_line(reason: object) -> str:
"""``reason`` as one log line, so text a caller controls cannot forge log entries."""
return str(reason).replace("\r", " ").replace("\n", " ")
def _check_unavailable_description(outage: GatewayOutage, parameter: str) -> str:
match outage:
case "retryable":
return SUBJECT_TOKEN_CHECK_UNAVAILABLE
return _CHECK_UNAVAILABLE.format(parameter=parameter)
case "faulted":
return SUBJECT_TOKEN_CHECK_FAULTED
return _CHECK_FAULTED.format(parameter=parameter)
case _:
assert_never(outage)

View file

@ -128,6 +128,11 @@ def build_upstream_authorize_url(authorization_url: str, params: Mapping[str, st
return urlunparse(parsed._replace(query=urlencode(merged)))
def proxy_base_url_from_env() -> str | None:
"""The gateway's fixed public URL from ``PROXY_BASE_URL``, ``None`` when unset or malformed."""
return _resolve_proxy_base_url_env()
def _resolve_proxy_base_url_env() -> str | None:
global _warned_invalid_proxy_base_url
configured: Final = os.environ.get("PROXY_BASE_URL", "").strip()

View file

@ -25,7 +25,16 @@ from litellm.proxy.management_endpoints.ui_sso import (
async def lookup_consent_teams(user_id: str) -> tuple[ConsentTeam, ...] | ReloadUserFailure:
user: Final = await load_active_user_by_id(user_id)
return await _consent_teams(await load_active_user_by_id(user_id))
async def lookup_consent_teams_from_database(user_id: str) -> tuple[ConsentTeam, ...] | ReloadUserFailure:
"""The same teams read from the database row, for a grant that is consumed once it is settled: the
membership the minter will re-check must be the membership the team was picked from."""
return await _consent_teams(await load_active_user_by_id(user_id, source="database"))
async def _consent_teams(user: LiteLLM_UserTable | ReloadUserFailure) -> tuple[ConsentTeam, ...] | ReloadUserFailure:
if isinstance(user, str):
return user
details: Final = await _team_details(user.teams)
@ -39,7 +48,7 @@ async def lookup_consent_teams(user_id: str) -> tuple[ConsentTeam, ...] | Reload
async def mint_proxy_credential(
user_id: str, team_id: str | None
user_id: str, team_id: str | None, *, allowed_routes: Sequence[str] | None = None
) -> MintedProxyCredential | ProxyCredentialMintFailure:
"""Mint the ``lite login`` credential for a consented grant. Membership is checked
live against the database row, so a team the user left between consent and redemption (or between refreshes)
@ -53,7 +62,9 @@ async def mint_proxy_credential(
user row handed to the minter carries no team list, exactly like ``lite login``'s, so
the minter's own first-team fallback stays inert. The credential carries the role the
proxy already enforces for the user on every request, so a row with no role (JWT auth's
upsert writes none) mints as an internal user instead of being refused."""
upsert writes none) mints as an internal user instead of being refused. ``allowed_routes``
narrows the credential to those route groups, the way a virtual key's ``allowed_routes`` does,
for a grant whose scope asked for less than the full credential."""
user: Final = await load_active_user_by_id(user_id, source="database")
if isinstance(user, str):
return user
@ -75,6 +86,7 @@ async def mint_proxy_credential(
team_alias=selected.team_alias,
team_models=selected.team_models,
team_model_aliases=selected.team_model_aliases,
allowed_routes=allowed_routes,
)
return MintedProxyCredential(
key=key,

View file

@ -155,18 +155,13 @@ LAZY_FEATURES: Final[tuple[LazyFeature, ...]] = (
module_path="litellm.proxy.management_endpoints.mcp_management_endpoints",
path_prefixes=("/v1/mcp/",),
),
LazyFeature(
# Also serves /.well-known/oauth-* (OAuth metadata discovery).
# No /mcp/oauth prefix here: the mounted /mcp sub-app would
# shadow it, and there are no actual routes there anyway.
name="mcp_byok_oauth",
module_path="litellm.proxy._experimental.mcp_server.byok_oauth_endpoints",
path_prefixes=("/v1/mcp/oauth", "/.well-known/oauth-"),
),
LazyFeature(
# Serves OAuth dance endpoints (/authorize, /token, /callback,
# /register) plus several /.well-known/ discovery URLs at the proxy
# root — needed for MCP-over-OAuth flows even before /mcp is hit.
# Registered before mcp_byok_oauth, which also claims the root
# /.well-known/oauth-authorization-server: the root document is the
# gateway's own authorization server metadata (token endpoint /token).
name="mcp_discoverable",
module_path="litellm.proxy._experimental.mcp_server.discoverable_endpoints",
path_prefixes=(
@ -185,6 +180,14 @@ LAZY_FEATURES: Final[tuple[LazyFeature, ...]] = (
# Catches the /{mcp_server_name}/authorize|token|register variants.
path_suffixes=("/authorize", "/token", "/register"),
),
LazyFeature(
# Also serves /.well-known/oauth-* (OAuth metadata discovery).
# No /mcp/oauth prefix here: the mounted /mcp sub-app would
# shadow it, and there are no actual routes there anyway.
name="mcp_byok_oauth",
module_path="litellm.proxy._experimental.mcp_server.byok_oauth_endpoints",
path_prefixes=("/v1/mcp/oauth", "/.well-known/oauth-"),
),
LazyFeature(
name="mcp_rest",
module_path="litellm.proxy._experimental.mcp_server.rest_endpoints",

View file

@ -35771,7 +35771,7 @@
"/.well-known/oauth-authorization-server": {
"get": {
"description": "OAuth authorization server discovery endpoint.\n\nSupports both legacy pattern (/{server_name}) and root endpoint.",
"operationId": "oauth_authorization_server_mcp__well_known_oauth_authorization_server_get",
"operationId": "oauth_authorization_server_mcp__well_known_oauth_authorization_server_get_2",
"parameters": [
{
"in": "query",
@ -35819,7 +35819,7 @@
"/.well-known/oauth-authorization-server/mcp": {
"get": {
"description": "OAuth authorization server discovery for the aggregate /mcp endpoint, the RFC 8414\npath-inserted form for a client that treats {base}/mcp as its authorization base URL.\n\nThe single-segment /mcp is reserved for the aggregate so the discovery chain stays\nconsistent: the aggregate protected-resource document advertises {base}/mcp as its\nauthorization server, so the document served here must have issuer {base}/mcp. A server\nliterally named ``mcp`` therefore does not take this route; it keeps its standard\ntwo-segment discovery at /.well-known/oauth-authorization-server/mcp/mcp. Letting the\nper-server row win here instead would serve an issuer of {base} against a resource that\nadvertised {base}/mcp, which fails the RFC 8414 issuer check and breaks the front door.",
"operationId": "oauth_authorization_server_aggregate__well_known_oauth_authorization_server_mcp_get",
"operationId": "oauth_authorization_server_aggregate__well_known_oauth_authorization_server_mcp_get_2",
"responses": {
"200": {
"content": {
@ -35839,7 +35839,7 @@
"/.well-known/oauth-authorization-server/mcp/{mcp_server_name}": {
"get": {
"description": "OAuth authorization server discovery endpoint using standard MCP URL pattern.\n\nStandard pattern: /mcp/{server_name}\nDiscovery path: /.well-known/oauth-authorization-server/mcp/{server_name}",
"operationId": "oauth_authorization_server_mcp_standard__well_known_oauth_authorization_server_mcp__mcp_server_name__get",
"operationId": "oauth_authorization_server_mcp_standard__well_known_oauth_authorization_server_mcp__mcp_server_name__get_2",
"parameters": [
{
"in": "path",
@ -35880,7 +35880,7 @@
"/.well-known/oauth-authorization-server/{mcp_server_name}": {
"get": {
"description": "OAuth authorization server discovery endpoint.\n\nSupports both legacy pattern (/{server_name}) and root endpoint.",
"operationId": "oauth_authorization_server_mcp__well_known_oauth_authorization_server__mcp_server_name__get",
"operationId": "oauth_authorization_server_mcp__well_known_oauth_authorization_server__mcp_server_name__get_2",
"parameters": [
{
"in": "path",
@ -35928,7 +35928,7 @@
"/.well-known/oauth-authorization-server/{mcp_server_name}/mcp": {
"get": {
"description": "OAuth authorization server discovery for legacy /{server_name}/mcp pattern.",
"operationId": "oauth_authorization_server_legacy__well_known_oauth_authorization_server__mcp_server_name__mcp_get",
"operationId": "oauth_authorization_server_legacy__well_known_oauth_authorization_server__mcp_server_name__mcp_get_2",
"parameters": [
{
"in": "path",
@ -35968,7 +35968,7 @@
},
"/.well-known/oauth-protected-resource": {
"get": {
"operationId": "oauth_protected_resource_root__well_known_oauth_protected_resource_get",
"operationId": "oauth_protected_resource_root__well_known_oauth_protected_resource_get_2",
"responses": {
"200": {
"content": {
@ -36004,7 +36004,7 @@
"/.well-known/oauth-protected-resource/mcp": {
"get": {
"description": "OAuth protected resource discovery for the aggregate /mcp endpoint.\n\nThe single-segment ``/mcp`` path does not collide with any per-server PRM pattern\n(those are two-segment: ``/mcp/{server}`` or ``/{server}/mcp``), so this unambiguously\ndescribes the aggregate resource.",
"operationId": "oauth_protected_resource_aggregate__well_known_oauth_protected_resource_mcp_get",
"operationId": "oauth_protected_resource_aggregate__well_known_oauth_protected_resource_mcp_get_2",
"responses": {
"200": {
"content": {
@ -36024,7 +36024,7 @@
"/.well-known/oauth-protected-resource/mcp/{mcp_server_name}": {
"get": {
"description": "OAuth protected resource discovery endpoint using standard MCP URL pattern.\n\nStandard pattern: /mcp/{server_name}\nDiscovery path: /.well-known/oauth-protected-resource/mcp/{server_name}\n\nThis endpoint is compliant with MCP specification and works with standard\nMCP clients like mcp-inspector and VSCode Copilot.",
"operationId": "oauth_protected_resource_mcp_standard__well_known_oauth_protected_resource_mcp__mcp_server_name__get",
"operationId": "oauth_protected_resource_mcp_standard__well_known_oauth_protected_resource_mcp__mcp_server_name__get_2",
"parameters": [
{
"in": "path",
@ -36065,7 +36065,7 @@
"/.well-known/oauth-protected-resource/{mcp_server_name}/mcp": {
"get": {
"description": "OAuth protected resource discovery endpoint using LiteLLM legacy URL pattern.\n\nLegacy pattern: /{server_name}/mcp\nDiscovery path: /.well-known/oauth-protected-resource/{server_name}/mcp\n\nThis endpoint is kept for backward compatibility. New integrations should\nuse the standard MCP pattern (/mcp/{server_name}) instead.",
"operationId": "oauth_protected_resource_mcp__well_known_oauth_protected_resource__mcp_server_name__mcp_get",
"operationId": "oauth_protected_resource_mcp__well_known_oauth_protected_resource__mcp_server_name__mcp_get_2",
"parameters": [
{
"in": "path",
@ -36194,9 +36194,27 @@
},
"Body_token_endpoint__mcp_server_name__token_post": {
"properties": {
"assertion": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Assertion"
},
"client_id": {
"title": "Client Id",
"type": "string"
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Client Id"
},
"client_secret": {
"anyOf": [
@ -36293,17 +36311,34 @@
}
},
"required": [
"grant_type",
"client_id"
"grant_type"
],
"title": "Body_token_endpoint__mcp_server_name__token_post",
"type": "object"
},
"Body_token_endpoint_token_post": {
"properties": {
"assertion": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Assertion"
},
"client_id": {
"title": "Client Id",
"type": "string"
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Client Id"
},
"client_secret": {
"anyOf": [
@ -36400,8 +36435,7 @@
}
},
"required": [
"grant_type",
"client_id"
"grant_type"
],
"title": "Body_token_endpoint_token_post",
"type": "object"
@ -38151,7 +38185,7 @@
"/.well-known/oauth-authorization-server": {
"get": {
"description": "OAuth authorization server discovery endpoint.\n\nSupports both legacy pattern (/{server_name}) and root endpoint.",
"operationId": "oauth_authorization_server_mcp__well_known_oauth_authorization_server_get_2",
"operationId": "oauth_authorization_server_mcp__well_known_oauth_authorization_server_get",
"parameters": [
{
"in": "query",
@ -38199,7 +38233,7 @@
"/.well-known/oauth-authorization-server/mcp": {
"get": {
"description": "OAuth authorization server discovery for the aggregate /mcp endpoint, the RFC 8414\npath-inserted form for a client that treats {base}/mcp as its authorization base URL.\n\nThe single-segment /mcp is reserved for the aggregate so the discovery chain stays\nconsistent: the aggregate protected-resource document advertises {base}/mcp as its\nauthorization server, so the document served here must have issuer {base}/mcp. A server\nliterally named ``mcp`` therefore does not take this route; it keeps its standard\ntwo-segment discovery at /.well-known/oauth-authorization-server/mcp/mcp. Letting the\nper-server row win here instead would serve an issuer of {base} against a resource that\nadvertised {base}/mcp, which fails the RFC 8414 issuer check and breaks the front door.",
"operationId": "oauth_authorization_server_aggregate__well_known_oauth_authorization_server_mcp_get_2",
"operationId": "oauth_authorization_server_aggregate__well_known_oauth_authorization_server_mcp_get",
"responses": {
"200": {
"content": {
@ -38219,7 +38253,7 @@
"/.well-known/oauth-authorization-server/mcp/{mcp_server_name}": {
"get": {
"description": "OAuth authorization server discovery endpoint using standard MCP URL pattern.\n\nStandard pattern: /mcp/{server_name}\nDiscovery path: /.well-known/oauth-authorization-server/mcp/{server_name}",
"operationId": "oauth_authorization_server_mcp_standard__well_known_oauth_authorization_server_mcp__mcp_server_name__get_2",
"operationId": "oauth_authorization_server_mcp_standard__well_known_oauth_authorization_server_mcp__mcp_server_name__get",
"parameters": [
{
"in": "path",
@ -38260,7 +38294,7 @@
"/.well-known/oauth-authorization-server/{mcp_server_name}": {
"get": {
"description": "OAuth authorization server discovery endpoint.\n\nSupports both legacy pattern (/{server_name}) and root endpoint.",
"operationId": "oauth_authorization_server_mcp__well_known_oauth_authorization_server__mcp_server_name__get_2",
"operationId": "oauth_authorization_server_mcp__well_known_oauth_authorization_server__mcp_server_name__get",
"parameters": [
{
"in": "path",
@ -38308,7 +38342,7 @@
"/.well-known/oauth-authorization-server/{mcp_server_name}/mcp": {
"get": {
"description": "OAuth authorization server discovery for legacy /{server_name}/mcp pattern.",
"operationId": "oauth_authorization_server_legacy__well_known_oauth_authorization_server__mcp_server_name__mcp_get_2",
"operationId": "oauth_authorization_server_legacy__well_known_oauth_authorization_server__mcp_server_name__mcp_get",
"parameters": [
{
"in": "path",
@ -38348,7 +38382,7 @@
},
"/.well-known/oauth-protected-resource": {
"get": {
"operationId": "oauth_protected_resource_root__well_known_oauth_protected_resource_get_2",
"operationId": "oauth_protected_resource_root__well_known_oauth_protected_resource_get",
"responses": {
"200": {
"content": {
@ -38384,7 +38418,7 @@
"/.well-known/oauth-protected-resource/mcp": {
"get": {
"description": "OAuth protected resource discovery for the aggregate /mcp endpoint.\n\nThe single-segment ``/mcp`` path does not collide with any per-server PRM pattern\n(those are two-segment: ``/mcp/{server}`` or ``/{server}/mcp``), so this unambiguously\ndescribes the aggregate resource.",
"operationId": "oauth_protected_resource_aggregate__well_known_oauth_protected_resource_mcp_get_2",
"operationId": "oauth_protected_resource_aggregate__well_known_oauth_protected_resource_mcp_get",
"responses": {
"200": {
"content": {
@ -38404,7 +38438,7 @@
"/.well-known/oauth-protected-resource/mcp/{mcp_server_name}": {
"get": {
"description": "OAuth protected resource discovery endpoint using standard MCP URL pattern.\n\nStandard pattern: /mcp/{server_name}\nDiscovery path: /.well-known/oauth-protected-resource/mcp/{server_name}\n\nThis endpoint is compliant with MCP specification and works with standard\nMCP clients like mcp-inspector and VSCode Copilot.",
"operationId": "oauth_protected_resource_mcp_standard__well_known_oauth_protected_resource_mcp__mcp_server_name__get_2",
"operationId": "oauth_protected_resource_mcp_standard__well_known_oauth_protected_resource_mcp__mcp_server_name__get",
"parameters": [
{
"in": "path",
@ -38445,7 +38479,7 @@
"/.well-known/oauth-protected-resource/{mcp_server_name}/mcp": {
"get": {
"description": "OAuth protected resource discovery endpoint using LiteLLM legacy URL pattern.\n\nLegacy pattern: /{server_name}/mcp\nDiscovery path: /.well-known/oauth-protected-resource/{server_name}/mcp\n\nThis endpoint is kept for backward compatibility. New integrations should\nuse the standard MCP pattern (/mcp/{server_name}) instead.",
"operationId": "oauth_protected_resource_mcp__well_known_oauth_protected_resource__mcp_server_name__mcp_get_2",
"operationId": "oauth_protected_resource_mcp__well_known_oauth_protected_resource__mcp_server_name__mcp_get",
"parameters": [
{
"in": "path",

View file

@ -551,6 +551,7 @@ class LiteLLMRoutes(enum.Enum):
+ list(agent_inference_routes)
+ model_info_routes
)
llm_api_routes_without_mcp = sorted(set(llm_api_routes) - set(mcp_inference_routes))
info_routes = [
"/key/info",
"/key/health",
@ -4762,6 +4763,26 @@ class JWTIssuerConfig(LiteLLMBaseModel):
DEFAULT_JWKS_STALE_TTL: Final = 3600
class IdJagRequestingApp(LiteLLMBaseModel):
"""A requesting application the operator lets redeem an ID-JAG (Identity Assertion
Authorization Grant) at the MCP gateway's ``POST /token``. ``client_id`` is the value
the identity provider puts in the assertion's ``client_id`` claim and the app sends as
the ``client_id`` form field. A ``client_secret`` makes the app a confidential client
that must authenticate with it (HTTP Basic or the ``client_secret`` form field); without
one the app is a public client."""
model_config = ConfigDict(frozen=True)
client_id: str = Field(
min_length=1, description="The requesting app's client_id as the identity provider asserts it."
)
client_secret: str | None = Field(
default=None,
min_length=1,
description="Shared secret the app must present at the gateway's token endpoint. Omit for a public client.",
)
class LiteLLM_JWTAuth(LiteLLMPydanticObjectBase):
"""
A class to define the roles and permissions for a LiteLLM Proxy w/ JWT Auth.
@ -4942,6 +4963,10 @@ class LiteLLM_JWTAuth(LiteLLMPydanticObjectBase):
default=None,
description="Optional issuer-bound JWT validation rules. When a token's `iss` matches a configured issuer, validation uses that issuer's JWKS, audience, and claim mappings. Tokens with an unlisted `iss` fall back to the global JWT_AUDIENCE/JWT_ISSUER validation path — this is additive routing, not an allow-list.",
)
id_jag_requesting_apps: tuple[IdJagRequestingApp, ...] | None = Field(
default=None,
description="Requesting apps allowed to redeem an ID-JAG at the MCP gateway's POST /token with the RFC 7523 jwt-bearer grant. Unset or empty turns that grant off.",
)
#########################################################
def __init__(self, **kwargs: Any) -> None:

View file

@ -3960,6 +3960,7 @@ class ExperimentalUIJWTToken:
team_models: Sequence[str] | None = None,
team_model_aliases: Mapping[str, str] | None = None,
max_budget: float | None = None,
allowed_routes: Sequence[str] | None = None,
) -> str:
"""
Generate a JWT token for CLI authentication with configurable expiration.
@ -3973,6 +3974,8 @@ class ExperimentalUIJWTToken:
team_alias: Team alias for the selected team, if available
team_models: Model allowlist granted by the selected team
team_model_aliases: Team model aliases for the selected team
allowed_routes: Route groups the session may call, enforced like a virtual key's
``allowed_routes`` on every route; None leaves every route the role allows
Returns:
Encrypted JWT token string
@ -4017,6 +4020,7 @@ class ExperimentalUIJWTToken:
max_parallel_requests=None,
user_role=LitellmUserRoles(user_info.user_role),
is_session_token=True,
allowed_routes=list(allowed_routes) if allowed_routes is not None else None,
)
return encrypt_bearer_token(valid_token.model_dump_json(exclude_none=True), prefix=LITELLM_SESSION_TOKEN_PREFIX)

View file

@ -35,6 +35,7 @@ from litellm.llms.custom_httpx.httpx_handler import HTTPHandler
from litellm.proxy._types import (
DEFAULT_JWKS_STALE_TTL,
RBAC_ROLES,
IdJagRequestingApp,
JWKKeyValue,
JWTAuthBuilderResult,
JWTIssuerConfig,
@ -99,6 +100,10 @@ class NoMatchingJWTPublicKeyError(Exception):
"""Raised when a JWKS endpoint returns no key matching the requested ``kid``."""
class IdJagIsNotABearerTokenError(Exception):
"""Raised when bearer auth is handed an ID-JAG, which only the token endpoint may redeem."""
class JWKSUnreachableError(Exception):
"""Raised when an IdP's JWKS / OIDC discovery endpoint is unreachable and no cached copy is left to fall back on."""
@ -114,9 +119,23 @@ _CACHED_VALUE: Final = TypeAdapter(object)
_CachedValueT = TypeVar("_CachedValueT", bound=JWKKeyValue | str)
ID_JAG_TYP: Final = "oauth-id-jag+jwt"
@dataclass(frozen=True, slots=True)
class AssertionRejection:
"""Why a JWT assertion failed verification. The reason is for the proxy log only, since
PyJWT's wording can quote the token's claims."""
reason: str
class _JWTAuthSettings(Protocol):
"""The JWT auth settings block this handler reads back through ``getattr``, when one is configured."""
@property
def id_jag_requesting_apps(self) -> Sequence[IdJagRequestingApp] | None: ...
@property
def issuers(self) -> Sequence[JWTIssuerConfig] | None: ...
@ -282,7 +301,7 @@ class JWTHandler:
return is_jwt_token(token=token)
@staticmethod
def get_unverified_claims(token: str) -> dict | None:
def get_unverified_claims(token: str) -> dict[str, object] | None:
"""
Decode JWT claims without signature verification.
Used for routing decisions before selecting validation path.
@ -949,7 +968,7 @@ class JWTHandler:
keys: Final = response_json["keys"] if "keys" in response_json else response_json
return cast(JWKKeyValue, keys) # cast-ok: JWTKeyItem declares only `kid`, validating would drop key material
async def _get_public_key_from_jwks_url(self, jwks_url: str, kid: str | None) -> dict:
async def _get_public_key_from_jwks_url(self, jwks_url: str, kid: str | None) -> JWTKeyItem:
resolved_jwks_url: Final = await self._resolve_jwks_url(jwks_url)
keys: Final = await self._cached_with_stale_fallback(
cache_key=f"litellm_jwt_auth_keys_{resolved_jwks_url}",
@ -960,11 +979,11 @@ class JWTHandler:
public_key: Final = self.parse_keys(keys=keys, kid=kid)
if public_key is not None:
return cast(dict, public_key)
return public_key
raise NoMatchingJWTPublicKeyError(f"No matching public key found. keys={resolved_jwks_url}, kid={kid}")
async def get_public_key(self, kid: str | None) -> dict:
async def get_public_key(self, kid: str | None) -> JWTKeyItem:
keys_url: Final = os.getenv("JWT_PUBLIC_KEY_URL")
if keys_url is None:
@ -1151,7 +1170,7 @@ class JWTHandler:
# its jwks_uri, matching JWTIssuerConfig.jwks_url's documented fallback.
return f"{issuer_config.issuer.rstrip('/')}/.well-known/openid-configuration"
def _get_claim_value_for_issuer_mapping(self, token: dict, claim_field: str) -> object:
def _get_claim_value_for_issuer_mapping(self, token: Mapping[str, object], claim_field: str) -> object:
"""Resolve a mapped claim from ``token``.
Returns ``None`` when the field is absent or empty so that mapped claims
@ -1168,8 +1187,10 @@ class JWTHandler:
return None
return claim_value
def _apply_issuer_claim_mappings(self, token: dict, issuer_config: JWTIssuerConfig) -> dict:
normalized: Final[dict] = {k: v for k, v in token.items() if k not in self.LITELLM_INTERNAL_CLAIMS}
def _apply_issuer_claim_mappings(
self, token: Mapping[str, object], issuer_config: JWTIssuerConfig
) -> dict[str, object]:
normalized: Final[dict[str, object]] = {k: v for k, v in token.items() if k not in self.LITELLM_INTERNAL_CLAIMS}
normalized[self.LITELLM_JWT_ISSUER_CLAIM] = issuer_config.issuer
claim_mappings: Final = [
(issuer_config.user_id_jwt_field, self.LITELLM_USER_ID_CLAIM),
@ -1192,8 +1213,8 @@ class JWTHandler:
return normalized
def _get_jwk_from_public_key(self, public_key: dict) -> dict:
jwk: Final = {}
def _get_jwk_from_public_key(self, public_key: Mapping[str, object]) -> dict[str, object]:
jwk: Final[dict[str, object]] = {}
for key in ["kty", "kid", "n", "e", "x", "y", "crv"]:
if key in public_key:
jwk[key] = public_key[key]
@ -1223,12 +1244,12 @@ class JWTHandler:
def _decode_jwt_with_public_key(
self,
token: str,
public_key: dict | str,
public_key: JWTKeyItem | str,
audience: str | list[str] | None,
issuer: str | None = None,
options: dict | None = None,
disable_audience_validation: bool = False,
) -> dict:
) -> dict[str, object]:
decode_options: Final = (
options
if options is not None
@ -1298,11 +1319,64 @@ class JWTHandler:
issuer_config=issuer_config,
)
async def decode_trusted_assertion(
self, token: str, audience: Sequence[str], required_claims: Sequence[str]
) -> Mapping[str, object] | AssertionRejection:
"""Verify a JWT assertion handed to the gateway's token endpoint with the issuer plumbing
JWT auth already trusts, a matching ``issuers`` entry or ``JWT_ISSUER`` with
``JWT_PUBLIC_KEY_URL``, but against the caller's audience instead of the API audience.
An assertion whose ``iss`` nothing trusts is refused rather than checked unscoped. A JWKS
outage propagates as the 503 ``ProxyException`` so the caller does not call the token bad."""
try:
header: Final = jwt.get_unverified_header(token)
except jwt.PyJWTError as malformed:
return AssertionRejection(reason=f"malformed assertion: {malformed}")
claims: Final = self.get_unverified_claims(token=token)
issuer_config: Final = self._get_configured_issuer(token=token)
trusted_issuer: Final = issuer_config.issuer if issuer_config is not None else os.getenv("JWT_ISSUER")
if not trusted_issuer or claims is None or claims.get("iss") != trusted_issuer:
return AssertionRejection(reason="iss is not an issuer this gateway trusts")
kid: Final = header.get("kid")
try:
public_key: Final = (
await self._get_public_key_from_jwks_url(self._get_jwks_url_for_issuer(issuer_config), kid)
if issuer_config is not None
else await self.get_public_key(kid=kid)
)
except JWKSUnreachableError as e:
raise jwks_unavailable_exception(e) from e
except Exception as no_key: # noqa: BLE001 # the key lookups raise plain Exceptions for a missing key
return AssertionRejection(reason=f"no signing key for the assertion: {no_key}")
try:
payload: Final = self._decode_jwt_with_public_key(
token=token,
public_key=public_key,
audience=list(audience),
issuer=trusted_issuer,
options={"require": list(required_claims)},
)
except jwt.PyJWTError as invalid:
return AssertionRejection(reason=f"assertion failed verification: {invalid}")
verified: Final = (
payload
if issuer_config is None
else self._apply_issuer_claim_mappings(token=payload, issuer_config=issuer_config)
)
validate: Final = self.litellm_jwtauth.custom_validate
if validate is not None and not validate(dict(verified)):
return AssertionRejection(reason="custom_validate rejected the assertion's claims")
return verified
async def auth_jwt(self, token: str) -> dict:
header: Final = jwt.get_unverified_header(token)
verbose_proxy_logger.debug("header: %s", header)
if header.get("typ") == ID_JAG_TYP:
raise IdJagIsNotABearerTokenError(
"an ID-JAG is an authorization grant, not a bearer token; redeem it at the token endpoint"
)
kid: Final = header.get("kid", None)
issuer_config: Final = self._get_configured_issuer(token=token)

View file

@ -69,7 +69,8 @@ _PROXY_ADMIN_VIEW_ONLY_BLOCKED_ROUTES: Final = frozenset(
# paths directly because the request route carries the resolved key id.
_PROXY_ADMIN_VIEW_ONLY_BLOCKED_KEY_SUFFIXES: Final = ("/regenerate", "/reset_spend")
_AUTH_ENFORCED_PASS_THROUGH_ROUTE_GROUPS: Final = frozenset(("openai_routes", "llm_api_routes"))
_LLM_API_ROUTE_GROUPS: Final = frozenset(("llm_api_routes", "llm_api_routes_without_mcp"))
_AUTH_ENFORCED_PASS_THROUGH_ROUTE_GROUPS: Final = frozenset(("openai_routes", *_LLM_API_ROUTE_GROUPS))
# Method-scoped because the generic credential CRUD handlers share these paths:
# the route check cannot admit PATCH/DELETE on a name like "user_connections"
@ -154,7 +155,7 @@ class RouteChecks:
################################################
# For llm_api_routes, also check registered pass-through endpoints
################################################
if allowed_route == "llm_api_routes":
if allowed_route in _LLM_API_ROUTE_GROUPS:
if route == "/auto_router/session" and RouteChecks.get_request_method(request) == "GET":
return True
@ -185,7 +186,9 @@ class RouteChecks:
# headers, env, credentials). POST/PUT/DELETE on
# these paths are admin-only management writes and
# are intentionally not covered.
if RouteChecks._is_get_mcp_server_discovery_route(route=route, request=request):
if allowed_route == "llm_api_routes" and RouteChecks._is_get_mcp_server_discovery_route(
route=route, request=request
):
return True
# Agent registry CRUD moved from llm_api_routes into
@ -195,7 +198,7 @@ class RouteChecks:
# could reach these paths, so keep them reachable here;
# the handlers in agent_endpoints/endpoints.py still
# enforce proxy-admin on writes and scope reads by role.
if RouteChecks.check_route_access(
if allowed_route == "llm_api_routes" and RouteChecks.check_route_access(
route=route,
allowed_routes=LiteLLMRoutes.agent_management_routes.value,
):

View file

@ -4,6 +4,7 @@ import os
from collections.abc import Sequence
from collections.abc import Set as AbstractSet
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from typing import Final, Literal
from unittest.mock import AsyncMock, MagicMock, patch
@ -12,6 +13,7 @@ from fastapi import HTTPException
from fastapi.testclient import TestClient
from starlette.datastructures import Headers
from litellm.caching.caching import DualCache
from litellm.constants import MCP_ALL_TOOLS_WILDCARD
from litellm.models.access_group import LiteLLM_AccessGroupTable
from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
@ -23,6 +25,7 @@ from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
from litellm.proxy._types import (
LiteLLM_ObjectPermissionTable,
LiteLLM_TeamTable,
LiteLLM_UserTable,
ProxyException,
SpecialHeaders,
SpecialMCPServerNames,
@ -11549,3 +11552,117 @@ class TestAccessGroupToolOwnershipAndFaults:
patch("litellm.proxy.auth.auth_checks.get_access_object", self._loader(groups, missing={"typo-group"})),
):
assert await MCPRequestHandler.get_allowed_tools_for_server("srv", auth) == ["read"]
class _EmptyTables:
"""Storage edge for a session credential's catalog refresh: the user row the real
``reload_admitted_user`` reads, and every other table (keys, MCP servers, config) empty, so the
real ``_reload_admitted_key`` fails closed, since a session credential has no key row."""
def __init__(self, live_user: LiteLLM_UserTable) -> None:
self.litellm_usertable = SimpleNamespace(find_unique=AsyncMock(return_value=live_user))
def __getattr__(self, table: str) -> SimpleNamespace:
empty: Final = SimpleNamespace(
find_unique=AsyncMock(return_value=None),
find_first=AsyncMock(return_value=None),
find_many=AsyncMock(return_value=[]),
count=AsyncMock(return_value=0),
)
setattr(self, table, empty)
return empty
def _session_credential_storage(live_user: LiteLLM_UserTable) -> SimpleNamespace:
tables: Final = _EmptyTables(live_user)
return SimpleNamespace(db=tables, writer_db=tables)
@pytest.mark.asyncio
async def test_catalog_refresh_reloads_session_credential_through_its_user_and_keeps_token_grants(monkeypatch):
"""A ``/token``-minted or ``lite login`` credential has no key row: the catalog refresh must read the live
user policy, never the key table, and keep the team, routes and models sealed in the token."""
from litellm.proxy import proxy_server
database = _session_credential_storage(
LiteLLM_UserTable(user_id="session-user", organization_id="live-org", user_role="internal_user", teams=[])
)
monkeypatch.setattr(proxy_server, "prisma_client", database)
monkeypatch.setattr(proxy_server, "user_api_key_cache", DualCache())
caller = UserAPIKeyAuth(
api_key="session-token-hash",
user_id="session-user",
team_id="token-team",
org_id="stale-org",
user_role="proxy_admin",
models=["token-model"],
allowed_routes=["mcp_inference_routes"],
is_session_token=True,
)
caller.via_virtual_key = True
refreshed = await MCPRequestHandler.refresh_catalog_authority(caller)
assert database.writer_db.litellm_usertable.find_unique.await_count >= 1
database.writer_db.litellm_verificationtoken.find_unique.assert_not_awaited()
assert (refreshed.object_permission, refreshed.object_permission_id) == (None, None)
assert (refreshed.user_role, refreshed.org_id) == ("internal_user", "live-org")
assert (refreshed.team_id, refreshed.models, refreshed.allowed_routes) == (
"token-team",
["token-model"],
["mcp_inference_routes"],
)
assert refreshed.is_session_token and refreshed.requires_fresh_policy
assert caller.org_id == "stale-org" and not caller.requires_fresh_policy
@pytest.mark.asyncio
@pytest.mark.parametrize("scope", ["litellm:mcp litellm:llm", "litellm:mcp", None])
async def test_token_minted_credential_lists_tools_for_every_scope_shape(monkeypatch, scope):
"""``tools/list`` with the credential ``/token`` mints (a session token the shared auth marks
``via_virtual_key`` at the key-cache boundary) returns the tool list instead of the JSON-RPC
credential error, for the combined, MCP-only and unscoped ID-JAG shapes."""
from mcp.types import ListToolsRequest, ListToolsResult, Tool
from litellm.proxy import proxy_server
from litellm.proxy._experimental.mcp_server import operations
from litellm.proxy._experimental.mcp_server.id_jag_grant import SubjectTokenRefusal, granted_scope
from litellm.proxy._experimental.mcp_server.operations import GatewayOperations, prepare_context
from litellm.proxy._types import MCPTransport
from litellm.proxy.auth.auth_checks import ExperimentalUIJWTToken
from litellm.types.mcp_server.mcp_server_manager import MCPServer
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-session-credential-tools-list")
granted = granted_scope(scope, mcp_resource=False)
assert not isinstance(granted, SubjectTokenRefusal)
user = LiteLLM_UserTable(user_id="okta-user-1", user_email="okta@example.com", user_role="internal_user")
minted = ExperimentalUIJWTToken.get_cli_jwt_auth_token(
user, team_id="team-eng", team_alias="eng", allowed_routes=granted.allowed_routes
)
credential = ExperimentalUIJWTToken.get_key_object_from_ui_hash_key(minted)
assert credential is not None and credential.is_session_token
credential.api_key = "hash-of-" + minted[:16]
credential.via_virtual_key = True
server = MCPServer(
server_id="eng-server",
name="eng_server",
server_name="eng_server",
alias="eng_server",
url="https://upstream.example.com/mcp",
transport=MCPTransport.http,
)
database = _session_credential_storage(user)
monkeypatch.setattr(proxy_server, "prisma_client", database)
monkeypatch.setattr(proxy_server, "user_api_key_cache", DualCache())
tool = Tool(name="eng_server-list_repos", description="list repos", inputSchema={"type": "object"})
with (
patch.object(operations, "_get_allowed_mcp_servers", AsyncMock(return_value=[server])),
patch.object(
operations.global_mcp_server_manager,
"get_tools_page",
AsyncMock(return_value=ListToolsResult(tools=[tool])),
),
):
result = await GatewayOperations().execute(ListToolsRequest(), prepare_context(user_api_key_auth=credential))
assert [listed.name for listed in result.tools] == ["eng_server-list_repos"]
assert database.writer_db.litellm_usertable.find_unique.await_count >= 1
database.writer_db.litellm_verificationtoken.find_unique.assert_not_awaited()
assert credential.team_id == "team-eng" and credential.allowed_routes == list(granted.allowed_routes or ())

View file

@ -102,9 +102,8 @@ def isolate_global_mcp_registry(monkeypatch):
restore, entries leak into other test modules sharing the same worker and break
assertions over the full registry contents.
"""
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
from litellm.proxy._experimental.mcp_server.catalog import CatalogSnapshots
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
monkeypatch.setattr(global_mcp_server_manager, "catalog", CatalogSnapshots(global_mcp_server_manager))
snapshot = dict(global_mcp_server_manager.registry)
@ -8133,9 +8132,9 @@ async def test_resolve_active_litellm_key_resolves_key_without_user_id(proxy_glo
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.bridge_token_flow import (
extract_user_id_from_request,
_ResolvedKey,
_resolve_active_litellm_key,
_ResolvedKey,
extract_user_id_from_request,
)
from litellm.proxy._types import UserAPIKeyAuth, hash_token
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
@ -8417,8 +8416,8 @@ async def test_load_active_user_by_id_serves_a_cached_row_without_a_database_rea
cached row answers without a database read, and only a caller that asks for the database row pays for
one."""
from litellm.proxy._experimental.mcp_server.bridge_token_flow import (
reload_active_user_by_id,
load_active_user_by_id,
reload_active_user_by_id,
)
from litellm.proxy._types import LiteLLM_UserTable
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
@ -11428,11 +11427,10 @@ async def test_upstream_resource_sent_on_dcr_bridge_relay_authorize():
"""The DCR-bridge relay arm builds its own upstream authorize params, so it needs the resource
too. Without it the relayed authorize would omit the resource while the gateway's token leg
still sent one, which is itself an invalid_target."""
from litellm.types.mcp import MCPAuth
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
_dcr_bridge_relays_client_registration,
)
from litellm.types.mcp import MCPAuth
server = _resource_server(
client_id=None,
@ -11693,7 +11691,7 @@ def _native_client_app(monkeypatch):
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", _NATIVE_CLIENT_MASTER_KEY, raising=False)
minted = []
async def fake_mint(user_id, team_id):
async def fake_mint(user_id, team_id, allowed_routes=None):
minted.append((user_id, team_id))
return MintedProxyCredential(key=f"sk-cli-{len(minted)}", expires_in=3600, user_id=user_id, team_id=team_id)
@ -12136,7 +12134,9 @@ def test_introspect_route_answers_for_authenticated_caller(monkeypatch):
async def test_identity_bound_authorization_carries_nonce_and_caller_through_callback(monkeypatch):
from http.cookies import SimpleCookie
from urllib.parse import parse_qs, urlparse
from fastapi import Request
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
_oauth_state_cookie_name,
authorize_with_server,
@ -12211,6 +12211,7 @@ async def test_identity_bound_authorization_carries_nonce_and_caller_through_cal
@pytest.mark.asyncio
async def test_enforced_login_warms_verified_token_readable_without_database_lookup(monkeypatch):
from types import SimpleNamespace
from litellm.caching.caching import DualCache
from litellm.proxy import proxy_server
from litellm.proxy._experimental.mcp_server import db, mcp_server_manager
@ -12279,9 +12280,10 @@ async def test_enforced_login_warms_verified_token_readable_without_database_loo
async def test_dcr_refusal_is_actionable_without_upstream_body(
upstream_status: int, auth_type: MCPAuth, dcr_bridge: bool, flow: str, monkeypatch: pytest.MonkeyPatch
) -> None:
import httpx
from typing import Final
import httpx
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
mint_ephemeral_dcr_client,
register_client_with_server,
@ -12522,8 +12524,8 @@ async def test_oauth_exchange_stores_token_for_validated_jwt_user(
import litellm
from litellm.caching.llm_caching_handler import LLMClientCache
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.proxy import proxy_server
from litellm.models.user import LiteLLM_UserTable
from litellm.proxy import proxy_server
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper
from litellm.types.llms.custom_http import httpxSpecialProvider
@ -12618,8 +12620,8 @@ async def test_oauth_jwt_identity_rejects_untrusted_or_inactive_owner(
from litellm.proxy import proxy_server
from litellm.proxy._experimental.mcp_server import mcp_server_manager
from litellm.proxy._experimental.mcp_server.bridge_token_flow import (
extract_user_id_from_request,
authorize_oauth_credential_request,
extract_user_id_from_request,
)
allowed_servers: Final = AsyncMock(return_value=["server-a"])
@ -12688,7 +12690,7 @@ async def test_oauth_jwt_uses_configured_virtual_key_owner(
) -> None:
from litellm.models.user import LiteLLM_UserTable
from litellm.proxy._experimental.mcp_server.bridge_token_flow import extract_user_id_from_request
from litellm.proxy._types import UserAPIKeyAuth, UnregisteredJWTClientBehavior, hash_token
from litellm.proxy._types import UnregisteredJWTClientBehavior, UserAPIKeyAuth, hash_token
from litellm.proxy.auth.auth_checks import jwt_key_mapping_cache_key
handler, signing_key = jwt_oauth_identity
@ -13069,8 +13071,8 @@ async def test_signed_oauth_callback_honors_credential_write_policy(
cimd: bool,
) -> None:
import httpx
import litellm
import litellm
from litellm.caching.llm_caching_handler import LLMClientCache
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.proxy import proxy_server
@ -13186,17 +13188,16 @@ async def test_identity_bound_authorize_preserves_presented_jwt_permissions(
allowed: bool,
credential: str,
) -> None:
import jwt
from datetime import datetime, timedelta, timezone
from urllib.parse import parse_qs, urlparse
import jwt
from litellm.models.user import LiteLLM_UserTable
from litellm.proxy import proxy_server
from litellm.proxy._types import JWTIssuerConfig, UserAPIKeyAuth, hash_token
from litellm.proxy.auth.auth_checks import ExperimentalUIJWTToken
from litellm.proxy._experimental.mcp_server import discoverable_endpoints, mcp_server_manager
from litellm.proxy._types import MCPTransport
from litellm.proxy._types import JWTIssuerConfig, MCPTransport, UserAPIKeyAuth, hash_token
from litellm.proxy.auth.auth_checks import ExperimentalUIJWTToken
from litellm.types.mcp_server.mcp_server_manager import MCPOAuthIdentityBinding, MCPServer
handler, signing_key = jwt_oauth_identity
@ -13330,9 +13331,10 @@ async def test_identity_bound_authorize_unrelated_bearer_uses_browser_session(
credential: str,
cookie_state: str,
) -> None:
import jwt
from urllib.parse import parse_qs, urlparse
import jwt
from litellm.models.user import LiteLLM_UserTable
from litellm.proxy import proxy_server
from litellm.proxy._experimental.mcp_server import discoverable_endpoints, mcp_server_manager
@ -13664,6 +13666,7 @@ def test_persisted_client_is_not_applied_to_another_upstream(issuer, url):
async def test_registration_does_not_write_client_after_upstream_edit(monkeypatch):
monkeypatch.setenv("LITELLM_SALT_KEY", "test-mcp-registration-salt")
from prisma import models
from litellm.proxy import proxy_server
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import _persist_dcr_client_registration
@ -13750,6 +13753,7 @@ async def test_callback_does_not_forward_error_from_another_issuer(monkeypatch):
@pytest.mark.parametrize("operation", ["authorize", "token"])
async def test_bound_client_cannot_be_sent_to_a_different_issuer(monkeypatch, operation):
from fastapi import Request
from litellm.proxy._experimental.mcp_server import discoverable_endpoints as endpoints
from litellm.types.mcp_server.mcp_server_manager import MCPServer
@ -13796,6 +13800,7 @@ async def test_bound_client_cannot_be_sent_to_a_different_issuer(monkeypatch, op
@pytest.mark.asyncio
async def test_failed_registration_preserves_cached_credentials(monkeypatch):
from fastapi import Request
from litellm.proxy._experimental.mcp_server import discoverable_endpoints as endpoints
server = _dcr_redirect_test_server("old-client").model_copy(
@ -13819,8 +13824,13 @@ async def test_failed_registration_preserves_cached_credentials(monkeypatch):
request.headers = {}
with pytest.raises(HTTPException) as error:
await endpoints.register_client_with_server(
request=request, mcp_server=server, client_name="app", grant_types=["authorization_code"],
response_types=["code"], token_endpoint_auth_method="none", replace_stale_client=True,
request=request,
mcp_server=server,
client_name="app",
grant_types=["authorization_code"],
response_types=["code"],
token_endpoint_auth_method="none",
replace_stale_client=True,
)
assert error.value.status_code == 503
assert "could not be saved" in error.value.detail
@ -13835,6 +13845,7 @@ async def test_failed_registration_preserves_cached_credentials(monkeypatch):
@pytest.mark.asyncio
async def test_registration_without_database_keeps_client_in_temporary_server(monkeypatch):
from fastapi import Request
from litellm.proxy import proxy_server
from litellm.proxy._experimental.mcp_server import discoverable_endpoints as endpoints
@ -13850,8 +13861,13 @@ async def test_registration_without_database_keeps_client_in_temporary_server(mo
request.base_url = "https://gateway.example/"
request.headers = {}
response = await endpoints.register_client_with_server(
request=request, mcp_server=server, client_name="app", grant_types=["authorization_code"],
response_types=["code"], token_endpoint_auth_method="none", replace_stale_client=True,
request=request,
mcp_server=server,
client_name="app",
grant_types=["authorization_code"],
response_types=["code"],
token_endpoint_auth_method="none",
replace_stale_client=True,
)
assert response.status_code == 200
assert json.loads(response.body)["client_id"] == "temporary-client"
@ -13864,9 +13880,10 @@ async def test_registration_without_database_keeps_client_in_temporary_server(mo
@pytest.mark.asyncio
@pytest.mark.parametrize("config_store", [False, True])
async def test_registration_does_not_overwrite_credentials_after_failed_identity_read(monkeypatch, config_store):
from litellm.proxy._experimental.mcp_server import discoverable_endpoints as endpoints, db
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
from litellm.proxy import utils
from litellm.proxy._experimental.mcp_server import db
from litellm.proxy._experimental.mcp_server import discoverable_endpoints as endpoints
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
server = _dcr_redirect_test_server(None)
monkeypatch.setattr(utils, "get_prisma_client_or_throw", lambda _: MagicMock())
@ -13895,9 +13912,10 @@ async def test_registration_does_not_overwrite_credentials_after_failed_identity
@pytest.mark.asyncio
@pytest.mark.parametrize("winner_available", [False, True])
async def test_registration_losing_conditional_write_reuses_only_a_matching_winner(monkeypatch, winner_available):
from litellm.proxy._experimental.mcp_server import discoverable_endpoints as endpoints, db
from litellm.proxy._types import LiteLLM_MCPServerTable
from litellm.proxy import utils
from litellm.proxy._experimental.mcp_server import db
from litellm.proxy._experimental.mcp_server import discoverable_endpoints as endpoints
from litellm.proxy._types import LiteLLM_MCPServerTable
server = _dcr_redirect_test_server(None).model_copy(update={"url": "https://upstream.example/mcp"})
row = LiteLLM_MCPServerTable.model_validate(server.model_dump(exclude_none=True))
@ -14503,7 +14521,8 @@ async def test_managed_authorize_retains_the_callers_previous_grant(
from urllib.parse import parse_qs, urlsplit
from litellm.proxy import proxy_server
from litellm.proxy._experimental.mcp_server import db, discoverable_endpoints as endpoints
from litellm.proxy._experimental.mcp_server import db
from litellm.proxy._experimental.mcp_server import discoverable_endpoints as endpoints
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
_, signing_key = jwt_oauth_identity
@ -14548,7 +14567,8 @@ async def test_managed_code_exchange_preserves_requested_scope_only_when_omitted
from urllib.parse import parse_qs, urlsplit
from litellm.proxy import proxy_server
from litellm.proxy._experimental.mcp_server import db, discoverable_endpoints as endpoints
from litellm.proxy._experimental.mcp_server import db
from litellm.proxy._experimental.mcp_server import discoverable_endpoints as endpoints
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
_, signing_key = jwt_oauth_identity
@ -14692,7 +14712,8 @@ async def test_managed_refresh_retains_only_the_actual_grant(
from types import SimpleNamespace
from litellm.proxy import proxy_server
from litellm.proxy._experimental.mcp_server import db, discoverable_endpoints as endpoints
from litellm.proxy._experimental.mcp_server import db
from litellm.proxy._experimental.mcp_server import discoverable_endpoints as endpoints
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
_, signing_key = jwt_oauth_identity
@ -14774,7 +14795,8 @@ async def test_managed_resource_metadata_excludes_refresh_permission(monkeypatch
],
)
async def test_connect_requires_the_requested_vendor_grant(monkeypatch, granted, required, expected):
from litellm.proxy._experimental.mcp_server import db, discoverable_endpoints as endpoints
from litellm.proxy._experimental.mcp_server import db
from litellm.proxy._experimental.mcp_server import discoverable_endpoints as endpoints
credential = {**_stored_grant(), "scopes": granted}
read = AsyncMock(return_value=credential)
@ -14836,3 +14858,137 @@ def test_aggregate_protected_resource_advertises_header_server_scopes(
)
with patch.object(mcp_server_manager, "global_mcp_server_manager", manager):
assert _build_aggregate_protected_resource_response(request)["scopes_supported"] == expected
@pytest.mark.asyncio
async def test_token_endpoint_routes_the_jwt_bearer_grant_to_the_aggregate_gateway():
"""A requesting app presents its identity provider's client_id, never a gateway DCR one, so
the aggregate dispatch keys on the grant type for RFC 7523 and hands the assertion to the
ID-JAG redeemer. A per-server token endpoint never serves that grant."""
from litellm.proxy._experimental.mcp_server import discoverable_endpoints
from litellm.proxy._experimental.mcp_server.gateway_dcr_flow import JWT_BEARER_GRANT_TYPE
from litellm.proxy._experimental.mcp_server.id_jag_grant import IdJagPrerequisites
request = _mock_callback_request("https://litellm.example.com/")
with patch("litellm.proxy.proxy_server.master_key", "sk-test-salt-for-lit9670"):
response = await discoverable_endpoints.token_endpoint(
request=request,
grant_type=JWT_BEARER_GRANT_TYPE,
client_id="0oa-requesting-app",
client_secret="app-secret",
assertion="eyJ.assertion.sig",
resource=None,
)
assert response.status_code == 400
refused = json.loads(bytes(response.body))
assert refused["error"] == "unsupported_grant_type"
unmet = IdJagPrerequisites(
jwt_auth_enabled=False,
has_database=False,
licensed=False,
maps_jwts_to_virtual_keys=False,
shared_replay_store=False,
base_url=None,
requesting_apps=(),
).refusal()
assert unmet is not None and refused["error_description"] == unmet.description
with pytest.raises(HTTPException):
await discoverable_endpoints.token_endpoint(
request=request,
grant_type=JWT_BEARER_GRANT_TYPE,
client_id="0oa-requesting-app",
assertion="eyJ.assertion.sig",
mcp_server_name="no-such-server",
)
@pytest.mark.asyncio
async def test_token_endpoint_takes_the_client_id_from_http_basic_when_the_form_leaves_it_out():
"""RFC 6749 section 2.3.1: a confidential app may authenticate with HTTP Basic alone, so the
aggregate dispatch reads client_id from that header. With neither the form field nor the
header the request is a 400 invalid_request, not a 422."""
from base64 import b64encode
from litellm.proxy._experimental.mcp_server import discoverable_endpoints
from litellm.proxy._experimental.mcp_server.gateway_dcr_flow import JWT_BEARER_GRANT_TYPE
request = _mock_callback_request("https://litellm.example.com/")
request.headers = {"authorization": "Basic " + b64encode(b"0oa-requesting-app:app-secret").decode()}
with patch("litellm.proxy.proxy_server.master_key", "sk-test-salt-for-lit9670"):
response = await discoverable_endpoints.token_endpoint(
request=request,
grant_type=JWT_BEARER_GRANT_TYPE,
client_id=None,
assertion="eyJ.assertion.sig",
resource=None,
)
assert response.status_code == 400
assert json.loads(bytes(response.body))["error"] == "unsupported_grant_type"
request.headers = {}
with pytest.raises(HTTPException) as refused:
await discoverable_endpoints.token_endpoint(
request=request, grant_type=JWT_BEARER_GRANT_TYPE, client_id=None, assertion="eyJ.assertion.sig"
)
assert refused.value.status_code == 400
assert refused.value.detail["error"] == "invalid_request"
@pytest.mark.parametrize("available", [True, False])
def test_root_metadata_is_the_gateways_own_and_lists_the_jwt_bearer_grant_when_it_is_served(available):
"""With no OAuth2 MCP server resolving at the root, the bare-origin document is the gateway's
own authorization server metadata: issuer is the origin an ID-JAG names as ``aud``, the token
endpoint is ``{base}/token``, and the jwt-bearer grant (with HTTP Basic client auth) is listed
exactly when the gateway serves it, under the same gates as the aggregate ``/mcp`` document."""
from fastapi import Request
from litellm.proxy._experimental.mcp_server import discoverable_endpoints
from litellm.proxy._experimental.mcp_server.gateway_dcr_flow import JWT_BEARER_GRANT_TYPE
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
global_mcp_server_manager.registry.clear()
request = MagicMock(spec=Request)
request.base_url = "https://llm.example.com/"
request.headers = {}
with (
patch.object(discoverable_endpoints, "jwt_bearer_available", return_value=available), # test-quality-ok: deployment gate seam
patch.object(discoverable_endpoints, "token_exchange_available", return_value=True), # test-quality-ok: deployment gate seam
):
document = discoverable_endpoints._build_oauth_authorization_server_response(request=request, mcp_server_name=None)
assert document["issuer"] == "https://llm.example.com"
assert document["token_endpoint"] == "https://llm.example.com/token"
assert (JWT_BEARER_GRANT_TYPE in document["grant_types_supported"]) is available
assert ("client_secret_basic" in document["token_endpoint_auth_methods_supported"]) is available
assert ("none" in document["token_endpoint_auth_methods_supported"]) is available
assert "urn:ietf:params:oauth:grant-type:token-exchange" in document["grant_types_supported"]
def test_root_metadata_owned_by_a_resolved_oauth2_server_never_lists_the_gateways_grant():
"""When exactly one OAuth2 MCP server resolves at the root, the root document describes that
upstream's token endpoint, which cannot redeem an ID-JAG, so the jwt-bearer grant is not
advertised there even though the gateway serves it; a requesting app uses the aggregate
``/.well-known/oauth-authorization-server/mcp`` document in that deployment."""
from fastapi import Request
from litellm.proxy._experimental.mcp_server import discoverable_endpoints
from litellm.proxy._experimental.mcp_server.gateway_dcr_flow import JWT_BEARER_GRANT_TYPE
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
server = _create_oauth2_server()
global_mcp_server_manager.registry.clear()
global_mcp_server_manager.registry[server.server_id] = server
request = MagicMock(spec=Request)
request.base_url = "https://llm.example.com/"
request.headers = {}
try:
with (
patch.object(discoverable_endpoints, "jwt_bearer_available", return_value=True), # test-quality-ok: deployment gate seam
patch.object(discoverable_endpoints, "token_exchange_available", return_value=True), # test-quality-ok: deployment gate seam
patch.object(global_mcp_server_manager, "get_mcp_server_by_name", return_value=server), # test-quality-ok: resolver seam
):
document = discoverable_endpoints._build_oauth_authorization_server_response(request=request, mcp_server_name=None)
finally:
global_mcp_server_manager.registry.clear()
assert document["token_endpoint"] == "https://llm.example.com/test_oauth/token"
assert JWT_BEARER_GRANT_TYPE not in document["grant_types_supported"]
assert document["grant_types_supported"] == ["authorization_code", "refresh_token"]
assert document["token_endpoint_auth_methods_supported"] == ["client_secret_post"]

View file

@ -20,10 +20,12 @@ from litellm.proxy._experimental.mcp_server.gateway_dcr_flow import (
CONNECT_FLOW_COOKIE_PREFIX,
GATEWAY_AUTH_CODE_PREFIX,
GATEWAY_AUTH_CODE_TTL_SECONDS,
JWT_BEARER_GRANT_TYPE,
MANUAL_DELIVERY_AUTH_CODE_TTL_SECONDS,
MAX_CLIENT_ID_LENGTH,
SUBJECT_TOKEN_TYPES,
TOKEN_EXCHANGE_GRANT_TYPE,
AssertionIdentity,
ConsentTeam,
MintedProxyCredential,
SubjectIdentity,
@ -44,6 +46,7 @@ from litellm.proxy._experimental.mcp_server.gateway_dcr_flow import (
open_gateway_dcr_client,
register_aggregate_client,
revoke_refresh_token,
supported_grant_types,
)
from litellm.proxy._experimental.mcp_server.outbound_credentials.session_credentials import (
SessionBearerAdmitted,
@ -1771,10 +1774,12 @@ CONSENT_TEAMS = (ConsentTeam(team_id="team-a", team_alias="Team A"), ConsentTeam
class _Minter:
def __init__(self, result=None):
self.calls = []
self.allowed_routes = []
self.result = result
async def __call__(self, user_id, team_id):
async def __call__(self, user_id, team_id, allowed_routes=None):
self.calls.append((user_id, team_id))
self.allowed_routes.append(allowed_routes)
if self.result is not None:
return self.result
return MintedProxyCredential(key=f"sk-cli-{user_id}", expires_in=3600, user_id=user_id, team_id=team_id)
@ -3296,3 +3301,200 @@ async def test_gateway_scope_still_narrows_when_the_resource_does_not_resolve()
_, cookies = _flow_cookie_from(response)
sealed_flow: Final = _sealed_wire_json(next(iter(cookies.values())), "", "gateway_connect_flow")
assert sealed_flow["resource_server_id"] == "alpha-id"
ID_JAG = "eyJhbGciOiJSUzI1NiIsInR5cCI6Im9hdXRoLWlkLWphZytqd3QifQ.eyJzdWIiOiJ1MSJ9.idp-signature"
REQUESTING_APP = "0oa-requesting-app"
ASSERTED = AssertionIdentity(
user_id="u1",
team_id="team-b",
replay_key="mcp_gateway_dcr:id_jag:jti-1",
replay_ttl_seconds=300,
scope="litellm:mcp",
allowed_routes=("mcp_inference_routes",),
)
class _Redeemer:
def __init__(self, result=None):
self.calls = []
self.result = result
async def __call__(self, assertion, client_id, client_secret, request):
self.calls.append((assertion, client_id, client_secret, request.url.path))
return self.result if self.result is not None else ASSERTED
async def _redeem_assertion_native(minter, redeemer, cache=None, client_id=REQUESTING_APP, **overrides):
arguments = {
"grant_type": JWT_BEARER_GRANT_TYPE,
"assertion": ID_JAG,
"client_secret": "app-secret",
"redeem_assertion": redeemer,
}
return await _redeem_native(None, client_id, minter, cache=cache, **{**arguments, **overrides})
@pytest.mark.asyncio
async def test_jwt_bearer_grant_mints_the_proxy_credential_for_the_asserted_user_once_per_assertion():
"""RFC 7523: an allowlisted requesting app redeems the ID-JAG its identity provider minted for
the same credential the consent flow mints, attributed to the user the assertion names and
the team asked for, with the assertion's scope stated and no refresh token (RFC 7521 section
4.1, the app renews with a fresh assertion). The assertion's jti is single-use: presenting it again is a replay and mints
nothing, while a fresh assertion for the same user is served."""
minter, redeemer, cache = _Minter(), _Redeemer(), DualCache()
response = await _redeem_assertion_native(minter, redeemer, cache=cache)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
body = json.loads(response.body)
assert redeemer.calls == [(ID_JAG, REQUESTING_APP, "app-secret", "/token")]
assert minter.calls == [("u1", "team-b")]
assert body["access_token"] == "sk-cli-u1"
assert body["token_type"] == "Bearer"
assert body["scope"] == "litellm:mcp"
assert "issued_token_type" not in body
assert (body["user_id"], body["team_id"]) == ("u1", "team-b")
assert "refresh_token" not in body
replayed = await _redeem_assertion_native(minter, redeemer, cache=cache)
assert replayed.status_code == 400
assert json.loads(replayed.body)["error"] == "invalid_grant"
assert minter.calls == [("u1", "team-b")]
fresh = _Redeemer(ASSERTED.model_copy(update={"replay_key": "mcp_gateway_dcr:id_jag:jti-2", "scope": None}))
again = await _redeem_assertion_native(minter, fresh, cache=cache)
assert again.status_code == 200
assert "scope" not in json.loads(again.body)
assert minter.calls == [("u1", "team-b"), ("u1", "team-b")]
@pytest.mark.asyncio
async def test_jwt_bearer_grant_claims_the_jti_before_it_mints():
"""A mint the gateway could not complete still consumed the assertion: the client gets the
503 and comes back with a fresh assertion, never with the same one."""
minter, cache = _Minter(result="unavailable"), DualCache()
first = await _redeem_assertion_native(minter, _Redeemer(), cache=cache)
assert first.status_code == 503
retried = await _redeem_assertion_native(_Minter(), _Redeemer(), cache=cache)
assert retried.status_code == 400
assert json.loads(retried.body)["error"] == "invalid_grant"
@pytest.mark.parametrize(
"error, status",
[
("invalid_client", 401),
("invalid_grant", 400),
("invalid_request", 400),
("unsupported_grant_type", 400),
("temporarily_unavailable", 503),
],
)
@pytest.mark.asyncio
async def test_jwt_bearer_grant_refusals_keep_their_oauth_error_and_status(error, status):
minter, cache = _Minter(), DualCache()
refusal = SubjectTokenRefusal(error=error, description="why")
response = await _redeem_assertion_native(minter, _Redeemer(refusal), cache=cache)
assert response.status_code == status
assert json.loads(response.body) == {"error": error, "error_description": "why"}
assert minter.calls == []
served = await _redeem_assertion_native(minter, _Redeemer(), cache=cache)
assert served.status_code == 200
@pytest.mark.asyncio
async def test_jwt_bearer_grant_requires_an_assertion_and_the_proxy_api_target():
minter, redeemer = _Minter(), _Redeemer()
missing = await _redeem_assertion_native(minter, redeemer, assertion=None)
assert missing.status_code == 400
assert json.loads(missing.body)["error"] == "invalid_request"
foreign = await _redeem_assertion_native(minter, redeemer, resource="https://elsewhere.example/api")
assert foreign.status_code == 400
assert json.loads(foreign.body)["error"] == "invalid_target"
assert redeemer.calls == []
assert minter.calls == []
@pytest.mark.asyncio
async def test_jwt_bearer_grant_is_refused_where_no_redeemer_is_wired():
minter = _Minter()
response = await _redeem_native(
None, REQUESTING_APP, minter, grant_type=JWT_BEARER_GRANT_TYPE, assertion=ID_JAG, client_secret=None
)
assert response.status_code == 400
assert json.loads(response.body)["error"] == "unsupported_grant_type"
assert minter.calls == []
@pytest.mark.asyncio
async def test_an_unknown_grant_type_names_the_jwt_bearer_grant_among_the_served_ones():
response = await _redeem_native(None, REQUESTING_APP, _Minter(), grant_type="urn:example:nope")
assert response.status_code == 400
assert JWT_BEARER_GRANT_TYPE in json.loads(response.body)["error_description"]
@pytest.mark.parametrize(
"token_exchange, jwt_bearer, expected",
[
(False, False, ("authorization_code", "refresh_token")),
(True, False, ("authorization_code", "refresh_token", TOKEN_EXCHANGE_GRANT_TYPE)),
(False, True, ("authorization_code", "refresh_token", JWT_BEARER_GRANT_TYPE)),
(True, True, ("authorization_code", "refresh_token", TOKEN_EXCHANGE_GRANT_TYPE, JWT_BEARER_GRANT_TYPE)),
],
)
def test_the_jwt_bearer_grant_is_advertised_exactly_when_it_is_served(token_exchange, jwt_bearer, expected):
assert supported_grant_types(token_exchange, jwt_bearer) == expected
contract = native_client_auth_contract(_request("/.well-known/litellm-cli-auth"), token_exchange, jwt_bearer)
assert contract["grant_types_supported"] == expected
@pytest.mark.asyncio
async def test_jwt_bearer_grant_narrows_the_credential_to_the_routes_the_scope_bought():
"""The route groups the assertion's scope resolved to reach the minter, so the credential is
enforced on every proxy route the way a virtual key's allowed_routes is, and the response states
exactly the scope that was enforced. No scope on the assertion mints the full credential."""
minter = _Minter()
both = ASSERTED.model_copy(
update={"scope": "litellm:mcp litellm:llm", "allowed_routes": ("mcp_inference_routes", "openai_routes")}
)
scoped = await _redeem_assertion_native(minter, _Redeemer(both), cache=DualCache())
assert scoped.status_code == 200
assert json.loads(scoped.body)["scope"] == "litellm:mcp litellm:llm"
full = await _redeem_assertion_native(
minter, _Redeemer(ASSERTED.model_copy(update={"scope": None, "allowed_routes": None})), cache=DualCache()
)
assert full.status_code == 200
assert "scope" not in json.loads(full.body)
assert minter.allowed_routes == [("mcp_inference_routes", "openai_routes"), None]
@pytest.mark.asyncio
async def test_jwt_bearer_grant_accepts_the_mcp_resource_indicator_as_its_target():
"""RFC 8707 clients send the ID-JAG's audience as ``resource``, and that audience may be the MCP
resource ``{base}/mcp``, so it is a valid target next to the proxy origin. Anything else stays
``invalid_target``."""
minter = _Minter()
served = await _redeem_assertion_native(
minter, _Redeemer(), cache=DualCache(), resource=PROXY_API_RESOURCE.rstrip("/") + "/mcp"
)
assert served.status_code == 200
foreign = await _redeem_assertion_native(
minter, _Redeemer(), cache=DualCache(), resource=PROXY_API_RESOURCE.rstrip("/") + "/other/mcp"
)
assert json.loads(foreign.body)["error"] == "invalid_target"
assert minter.calls == [("u1", "team-b")]
@pytest.mark.parametrize("jwt_bearer_available", [True, False])
def test_aggregate_metadata_advertises_basic_client_auth_only_with_the_grant_that_reads_it(jwt_bearer_available):
"""Only the jwt-bearer redemption reads an HTTP Basic client secret, the other grants take
``client_secret`` from the form, so ``client_secret_basic`` is advertised only when that grant
is on. A client following the document otherwise sends Basic alone and authenticates with no
secret."""
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
_build_aggregate_authorization_server_response,
)
document = _build_aggregate_authorization_server_response(_request(), True, jwt_bearer_available)
methods = document["token_endpoint_auth_methods_supported"]
assert isinstance(methods, list)
assert ("client_secret_basic" in methods) is jwt_bearer_available
assert "none" in methods and "client_secret_post" in methods

View file

@ -0,0 +1,738 @@
import hashlib
import logging
import math
from base64 import b64encode
from collections.abc import Mapping, Sequence
from typing import Final
import jwt
import pytest
from fastapi import HTTPException
from litellm.caching.caching import DualCache
from litellm.proxy._experimental.mcp_server.gateway_dcr_flow import (
AssertionIdentity,
ConsentTeam,
ReloadUserFailure,
SubjectIdentity,
SubjectTokenRefusal,
)
from litellm.proxy._experimental.mcp_server.id_jag_grant import (
ID_JAG_REQUIRED_CLAIMS,
REJECTED_ASSERTION,
TEAM_HEADER,
AssertionRedemption,
GrantedScope,
IdJagPrerequisites,
LookupTeams,
_claim_reader,
basic_client_id,
gateway_audiences,
granted_scope,
identity_from_id_jag,
jwt_bearer_available,
names_mcp_resource,
proxy_user_lookup,
select_team,
subject_resolver,
)
from litellm.proxy._types import IdJagRequestingApp, LiteLLM_JWTAuth, LiteLLMRoutes
from litellm.proxy.auth.handle_jwt import (
ID_JAG_TYP,
AssertionRejection,
JWKSUnreachableError,
JWTHandler,
jwks_unavailable_exception,
)
ISSUER = "https://idp.example.com"
GATEWAY = "https://gateway.example.com"
AUDIENCES = (f"{GATEWAY}/mcp", GATEWAY)
NOW = 1_800_000_000.0
PUBLIC_APP = IdJagRequestingApp(client_id="0oa-public")
CONFIDENTIAL_APP = IdJagRequestingApp(client_id="0oa-confidential", client_secret="s3cret")
SPACED_APP = IdJagRequestingApp(client_id="0oa spaced", client_secret="app secret")
EVERY_GATE_HOLDS = {
"jwt_auth_enabled": True,
"has_database": True,
"licensed": True,
"maps_jwts_to_virtual_keys": False,
"shared_replay_store": True,
"base_url": "https://gateway.example.com",
"requesting_apps": (PUBLIC_APP, CONFIDENTIAL_APP, SPACED_APP),
}
JWKS_DOWN = jwks_unavailable_exception(JWKSUnreachableError("ConnectError fetching the IdP's JWKS after 3 attempts"))
def _claims(**overrides: object) -> dict[str, object]:
return {
"iss": ISSUER,
"sub": "okta-user-1",
"aud": AUDIENCES[1],
"exp": NOW + 300,
"iat": NOW - 5,
"jti": "jti-1",
"client_id": PUBLIC_APP.client_id,
"scope": "litellm:mcp",
**overrides,
}
def _assertion(typ: str = ID_JAG_TYP, **claims: object) -> str:
return jwt.encode(
_claims(**claims),
"unused-by-the-fake-verifier-but-long-enough-for-hs256",
algorithm="HS256",
headers={"typ": typ},
)
class _Verifier:
"""Stands in for the injected ``DecodeAssertion`` port; records each call as a new tuple."""
def __init__(
self, result: Mapping[str, object] | AssertionRejection | None = None, raises: BaseException | None = None
) -> None:
self.calls: tuple[tuple[str, tuple[str, ...], tuple[str, ...]], ...] = ()
self.result = result
self.raises = raises
async def __call__(
self, assertion: str, audience: Sequence[str], required_claims: Sequence[str]
) -> Mapping[str, object] | AssertionRejection:
self.calls = (*self.calls, (assertion, tuple(audience), tuple(required_claims))) # rebind-ok: immutable record
if self.raises is not None:
raise self.raises
return self.result if self.result is not None else jwt.decode(assertion, options={"verify_signature": False})
class _Resolver:
"""Stands in for the injected ``ResolveSubject`` port; records each call as a new tuple."""
def __init__(
self,
user_id: str | None = "u1",
raises: BaseException | None = None,
result: SubjectIdentity | SubjectTokenRefusal | None = None,
) -> None:
self.calls: tuple[tuple[Mapping[str, object], str | None], ...] = ()
self.user_id = user_id
self.raises = raises
self.result = result
async def __call__(
self, claims: Mapping[str, object], team_header: str | None
) -> SubjectIdentity | SubjectTokenRefusal | None:
self.calls = (*self.calls, (dict(claims), team_header)) # rebind-ok: immutable record
if self.raises is not None:
raise self.raises
if self.result is not None:
return self.result
return None if self.user_id is None else SubjectIdentity(user_id=self.user_id, team_id=team_header)
def _redemption(
assertion: str,
client_id: str = PUBLIC_APP.client_id,
client_secret: str | None = None,
authorization: str | None = None,
team_id: str | None = "team-b",
leeway: float = 0.0,
) -> AssertionRedemption:
return AssertionRedemption(
assertion=assertion,
client_id=client_id,
client_secret=client_secret,
authorization_header=authorization,
team_id=team_id,
audiences=AUDIENCES,
now=NOW,
leeway_seconds=leeway,
)
@pytest.mark.asyncio
async def test_the_replay_marker_outlives_the_leeway_the_verifier_grants_exp():
"""PyJWT accepts an assertion until ``exp`` plus the handler's leeway, so the marker must
live that long too, including for an assertion already past ``exp`` but inside the leeway."""
within_leeway = await _identity(_redemption(_assertion(), leeway=30), _Verifier(), _Resolver())
assert isinstance(within_leeway, AssertionIdentity)
assert within_leeway.replay_ttl_seconds == 330
just_expired = await _identity(
_redemption(_assertion(), leeway=30), _Verifier(result=_claims(exp=NOW - 10)), _Resolver()
)
assert isinstance(just_expired, AssertionIdentity)
assert just_expired.replay_ttl_seconds == 20
async def _identity(
redemption: AssertionRedemption,
verifier: _Verifier | None = None,
resolver: _Resolver | None = None,
**unmet: object,
) -> AssertionIdentity | SubjectTokenRefusal:
return await identity_from_id_jag(
redemption,
prerequisites=IdJagPrerequisites(**{**EVERY_GATE_HOLDS, **unmet}),
decode=verifier if verifier is not None else _Verifier(),
resolve_subject=resolver if resolver is not None else _Resolver(),
)
@pytest.mark.asyncio
async def test_a_valid_id_jag_names_the_user_its_single_use_key_and_its_scope():
"""The verifier is asked for the gateway's own issuer values as audience and for every claim
the ID-JAG profile requires, the subject lookup sees the verified claims, and the identity
carries the team asked for, a replay key derived from iss and jti, a marker TTL that outlives
the assertion, and the assertion's scope."""
assertion, verifier, resolver = _assertion(), _Verifier(), _Resolver()
identity = await _identity(_redemption(assertion), verifier, resolver)
assert isinstance(identity, AssertionIdentity)
assert verifier.calls == ((assertion, AUDIENCES, ID_JAG_REQUIRED_CLAIMS),)
assert {"iss", "sub", "aud", "exp", "iat", "jti", "client_id"} <= set(ID_JAG_REQUIRED_CLAIMS)
assert resolver.calls == ((_claims(), "team-b"),)
assert (identity.user_id, identity.team_id, identity.scope) == ("u1", "team-b", "litellm:mcp")
assert identity.allowed_routes == ("mcp_inference_routes",)
assert identity.replay_key == "mcp_gateway_dcr:id_jag:" + hashlib.sha256(f"{ISSUER}\njti-1".encode()).hexdigest()
assert identity.replay_ttl_seconds == math.ceil(300)
other_jti = await _identity(_redemption(_assertion(jti="jti-2")), _Verifier(), _Resolver())
assert isinstance(other_jti, AssertionIdentity)
assert other_jti.replay_key != identity.replay_key
teamless = await _identity(_redemption(_assertion(scope=""), team_id=None))
assert isinstance(teamless, AssertionIdentity)
assert (teamless.team_id, teamless.scope, teamless.allowed_routes) == (None, None, None)
@pytest.mark.asyncio
async def test_a_requesting_app_the_operator_did_not_allowlist_learns_nothing_about_the_assertion():
verifier, resolver = _Verifier(), _Resolver()
refused = await _identity(_redemption(_assertion(), client_id="0oa-stranger"), verifier, resolver)
assert refused == SubjectTokenRefusal(
error="invalid_client", description="client_id is not an allowlisted requesting app"
)
assert verifier.calls == ()
assert resolver.calls == ()
def _basic(client_id: str, secret: str) -> str:
return "Basic " + b64encode(f"{client_id}:{secret}".encode()).decode()
@pytest.mark.parametrize(
"client_secret, authorization, accepted",
[
(None, None, False),
("wrong", None, False),
("s3cr\u00e9t", None, False),
("s3cret", None, True),
(None, _basic(CONFIDENTIAL_APP.client_id, "s3cr\u00e9t"), False),
(None, _basic(CONFIDENTIAL_APP.client_id, "s3cret"), True),
(None, _basic(CONFIDENTIAL_APP.client_id, "wrong"), False),
(None, _basic(PUBLIC_APP.client_id, "s3cret"), False),
("s3cret", _basic(CONFIDENTIAL_APP.client_id, "wrong"), False),
(None, "Basic not-base64!", False),
(None, "Bearer s3cret", False),
],
)
@pytest.mark.asyncio
async def test_a_confidential_app_must_authenticate_with_its_secret(client_secret, authorization, accepted):
verifier = _Verifier()
assertion = _assertion(client_id=CONFIDENTIAL_APP.client_id)
redemption = _redemption(assertion, CONFIDENTIAL_APP.client_id, client_secret, authorization)
identity = await _identity(redemption, verifier)
if accepted:
assert isinstance(identity, AssertionIdentity)
return
assert identity == SubjectTokenRefusal(error="invalid_client", description="client authentication failed")
assert verifier.calls == ()
@pytest.mark.parametrize(
"client_secret, authorization, accepted",
[
("app secret", None, True),
(None, "Basic " + b64encode(b"0oa+spaced:app+secret").decode(), True),
(None, "Basic " + b64encode(b"0oa%20spaced:app%20secret").decode(), True),
(None, "Basic " + b64encode(b"0oa spaced:app secret").decode(), True),
(None, "Basic " + b64encode(b"0oa+spaced:app+wrong").decode(), False),
(None, "Basic " + b64encode(b"0oa-spaced:app+secret").decode(), False),
],
)
@pytest.mark.asyncio
async def test_basic_credentials_are_form_decoded_so_a_space_in_an_id_or_secret_still_matches(
client_secret, authorization, accepted
):
"""RFC 6749 section 2.3.1 form-encodes the id and secret before base64, so ``+`` is a space. The
endpoint hands over the ``client_id`` it read from the header, so the header must name the same app."""
assertion = _assertion(client_id=SPACED_APP.client_id)
identity = await _identity(_redemption(assertion, SPACED_APP.client_id, client_secret, authorization), _Verifier())
if accepted:
assert isinstance(identity, AssertionIdentity)
return
assert identity == SubjectTokenRefusal(error="invalid_client", description="client authentication failed")
@pytest.mark.asyncio
async def test_a_public_app_is_identified_by_client_id_alone():
identity = await _identity(_redemption(_assertion(), client_secret="anything", authorization="Bearer x"))
assert isinstance(identity, AssertionIdentity)
@pytest.mark.parametrize(
"assertion, description",
[
("not-a-jwt", "assertion is not a JWT"),
(_assertion(typ="JWT"), f"assertion typ must be {ID_JAG_TYP}"),
(_assertion(typ="at+jwt"), f"assertion typ must be {ID_JAG_TYP}"),
],
)
@pytest.mark.asyncio
async def test_a_bearer_or_id_token_is_not_an_id_jag(assertion, description):
verifier = _Verifier()
refused = await _identity(_redemption(assertion), verifier)
assert refused == SubjectTokenRefusal(error="invalid_grant", description=description)
assert verifier.calls == ()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"verifier, reason",
[
(_Verifier(result=AssertionRejection(reason="Signature verification failed")), "Signature verification"),
(_Verifier(result=AssertionRejection(reason="Audience doesn't match")), "Audience"),
(_Verifier(result=AssertionRejection(reason='Token is missing the "jti" claim')), "jti"),
(_Verifier(result=AssertionRejection(reason="Signature has expired")), "expired"),
(_Verifier(result=AssertionRejection(reason="iss is not an issuer this gateway trusts")), "iss"),
(_Verifier(raises=Exception("Validation fails: Invalid audience")), "Invalid audience"),
(_Verifier(result=AssertionRejection(reason="bad kid\nWARNING forged line")), "bad kid WARNING forged line"),
],
)
async def test_an_assertion_the_verifier_rejects_is_refused_with_the_reason_kept_in_the_log(verifier, reason, caplog):
resolver = _Resolver()
with caplog.at_level(logging.WARNING, logger="litellm.proxy"):
refused = await _identity(_redemption(_assertion()), verifier, resolver)
assert refused == SubjectTokenRefusal(error="invalid_grant", description=REJECTED_ASSERTION)
assert reason not in refused.description
assert reason in caplog.text
assert resolver.calls == ()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"claims, reason",
[
({"client_id": "0oa-confidential"}, "client_id"),
({"exp": NOW}, "expired"),
({"exp": NOW - 1}, "expired"),
({"jti": ""}, "jti"),
({"jti": 7}, "jti"),
({"iss": None}, "iss"),
({"cnf": {"jkt": "thumbprint"}}, "proof"),
],
)
async def test_verified_claims_that_do_not_fit_the_profile_are_refused(claims, reason, caplog):
resolver = _Resolver()
with caplog.at_level(logging.WARNING, logger="litellm.proxy"):
refused = await _identity(_redemption(_assertion()), _Verifier(result=_claims(**claims)), resolver)
assert refused == SubjectTokenRefusal(error="invalid_grant", description=REJECTED_ASSERTION)
assert reason in caplog.text
assert resolver.calls == ()
@pytest.mark.asyncio
async def test_an_assertion_that_names_no_user_the_gateway_knows_cannot_be_redeemed(caplog):
with caplog.at_level(logging.WARNING, logger="litellm.proxy"):
refused = await _identity(_redemption(_assertion()), resolver=_Resolver(user_id=None))
assert refused == SubjectTokenRefusal(error="invalid_grant", description=REJECTED_ASSERTION)
assert "names no user" in caplog.text
missing = await _identity(
_redemption(_assertion()), resolver=_Resolver(raises=ValueError("User doesn't exist in db. 'user_id'=u1"))
)
assert missing == SubjectTokenRefusal(error="invalid_grant", description=REJECTED_ASSERTION)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"verifier, resolver",
[
(_Verifier(raises=JWKS_DOWN), _Resolver()),
(_Verifier(), _Resolver(raises=HTTPException(status_code=503, detail="database unavailable"))),
],
)
async def test_a_gateway_outage_is_reported_as_retryable_not_as_a_bad_assertion(verifier, resolver):
refused = await _identity(_redemption(_assertion()), verifier, resolver)
assert isinstance(refused, SubjectTokenRefusal)
assert refused.error == "temporarily_unavailable"
assert "retry" in refused.description
assert "assertion" in refused.description
assert "subject_token" not in refused.description
@pytest.mark.parametrize(
"unmet, description",
[
({"jwt_auth_enabled": False}, "JWT auth is not enabled"),
({"has_database": False}, "no database"),
({"licensed": False}, "enterprise only"),
({"maps_jwts_to_virtual_keys": True}, "maps IdP tokens to virtual keys"),
({"shared_replay_store": False}, "Redis"),
({"base_url": None}, "PROXY_BASE_URL"),
({"requesting_apps": ()}, "no requesting apps allowlisted"),
],
)
@pytest.mark.asyncio
async def test_the_deployment_gates_refuse_before_any_verification(unmet, description):
verifier = _Verifier()
refused = await _identity(_redemption(_assertion()), verifier, **unmet)
assert isinstance(refused, SubjectTokenRefusal)
assert refused.error == "unsupported_grant_type"
assert description in refused.description
assert verifier.calls == ()
assert IdJagPrerequisites(**{**EVERY_GATE_HOLDS, **unmet}).available is False
assert IdJagPrerequisites(**EVERY_GATE_HOLDS).available is True
def _running_jwt_handler(litellm_jwtauth: LiteLLM_JWTAuth | None) -> JWTHandler:
handler: Final = JWTHandler()
if litellm_jwtauth is not None:
handler.update_environment(prisma_client=None, user_api_key_cache=DualCache(), litellm_jwtauth=litellm_jwtauth)
return handler
@pytest.mark.parametrize(
"general_settings, prisma_client, premium_user, litellm_jwtauth, expected",
[
({"enable_jwt_auth": True}, object(), True, LiteLLM_JWTAuth(id_jag_requesting_apps=[PUBLIC_APP]), True),
(
{"enable_jwt_auth": True, "redis": None},
object(),
True,
LiteLLM_JWTAuth(id_jag_requesting_apps=[PUBLIC_APP]),
False,
),
(
{"enable_jwt_auth": True, "base_url": ""},
object(),
True,
LiteLLM_JWTAuth(id_jag_requesting_apps=[PUBLIC_APP]),
False,
),
({"enable_jwt_auth": True}, object(), True, LiteLLM_JWTAuth(id_jag_requesting_apps=[]), False),
(
{"enable_jwt_auth": True},
object(),
True,
LiteLLM_JWTAuth(id_jag_requesting_apps=[PUBLIC_APP], virtual_key_claim_field="vkey"),
False,
),
({"enable_jwt_auth": True}, object(), True, LiteLLM_JWTAuth(), False),
({"enable_jwt_auth": True}, object(), True, None, False),
({}, object(), True, LiteLLM_JWTAuth(id_jag_requesting_apps=[PUBLIC_APP]), False),
({"enable_jwt_auth": True}, None, True, LiteLLM_JWTAuth(id_jag_requesting_apps=[PUBLIC_APP]), False),
({"enable_jwt_auth": True}, object(), False, LiteLLM_JWTAuth(id_jag_requesting_apps=[PUBLIC_APP]), False),
],
)
def test_availability_is_read_from_the_running_proxy(
monkeypatch, general_settings, prisma_client, premium_user, litellm_jwtauth, expected
):
monkeypatch.setenv("PROXY_BASE_URL", general_settings.get("base_url", "https://gateway.example.com"))
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", general_settings)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", prisma_client)
monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", premium_user)
monkeypatch.setattr("litellm.proxy.proxy_server.redis_usage_cache", general_settings.get("redis", object()))
monkeypatch.setattr("litellm.proxy.proxy_server.jwt_handler", _running_jwt_handler(litellm_jwtauth))
assert jwt_bearer_available() is expected
def test_the_assertion_audience_is_the_fixed_gateway_url_not_the_request_host():
"""An ID-JAG must name this gateway's MCP resource or its authorization server under the
operator-fixed PROXY_BASE_URL. Without it there is no audience, so there is no grant."""
assert gateway_audiences("https://gateway.example.com") == (
"https://gateway.example.com/mcp",
"https://gateway.example.com",
)
assert gateway_audiences(None) == ()
@pytest.mark.asyncio
async def test_a_team_the_subject_lookup_cannot_settle_is_refused_before_the_assertion_is_consumed():
"""The resolver's refusal (a user on several teams who named none, or a header naming a team
they are not on) comes back as is, so the client fixes its request and presents the same
assertion again instead of finding its jti burned."""
refusal = SubjectTokenRefusal(error="invalid_request", description=f"send the {TEAM_HEADER} header")
refused = await _identity(_redemption(_assertion()), resolver=_Resolver(result=refusal))
assert refused is refusal
teamed = await _identity(
_redemption(_assertion()), resolver=_Resolver(result=SubjectIdentity(user_id="u1", team_id="t9"))
)
assert isinstance(teamed, AssertionIdentity)
assert teamed.team_id == "t9"
ENG = ConsentTeam(team_id="team-eng", team_alias="engineering")
OPS = ConsentTeam(team_id="team-ops", team_alias=None)
@pytest.mark.parametrize(
"team_header, claim_teams, teams, expected",
[
(None, (), (), None),
(None, (), (ENG,), "team-eng"),
("team-eng", (), (ENG, OPS), "team-eng"),
("engineering", (), (ENG, OPS), "team-eng"),
("team-ops", ("team-eng",), (ENG, OPS), "team-ops"),
(None, ("team-ops",), (ENG, OPS), "team-ops"),
(None, ("team-sales", "team-ops"), (ENG, OPS), "team-ops"),
(None, ("0oa-xaa-public-app",), (ENG,), "team-eng"),
(None, ("0oa-xaa-public-app",), (), None),
],
)
def test_select_team_prefers_the_header_then_the_assertion_then_the_only_team(
team_header, claim_teams, teams, expected
):
assert select_team(team_header, claim_teams, teams) == expected
class _Reader:
def __init__(self, user_id=None, email=None, team_ids=(), allowed_domain=None):
self.user_id, self.email, self.team_ids, self.allowed_domain = user_id, email, team_ids, allowed_domain
def is_allowed_domain(self, user_email):
return self.allowed_domain is None or user_email.split("@")[-1] == self.allowed_domain
def get_user_id(self, token, default_value):
return self.user_id or default_value
def get_user_email(self, token, default_value):
return self.email or default_value
def get_all_jwt_team_ids(self, token):
return list(self.team_ids)
async def _known_user(user_id: str, user_email: str | None) -> str | None:
return user_id if user_id in {"okta-user-1", "u-mapped"} else None
async def _two_teams(user_id: str) -> tuple[ConsentTeam, ...]:
return (ENG, OPS)
async def _teams_unreadable(user_id: str) -> ReloadUserFailure:
return "unavailable"
def _teams_failing(failure: ReloadUserFailure) -> LookupTeams:
async def lookup_teams(user_id: str) -> tuple[ConsentTeam, ...] | ReloadUserFailure:
return failure
return lookup_teams
@pytest.mark.asyncio
async def test_the_subject_resolver_binds_the_claims_to_a_known_user_and_their_asserted_team():
"""The user comes from the JWT auth user mapping, else ``sub``, and must exist. The team the
assertion asserts settles a multi-team user, the header still wins, and unreadable teams are a
retryable refusal rather than a credential for the wrong team."""
asserted = subject_resolver(_Reader(team_ids=("team-ops",)), _known_user, _two_teams)
assert await asserted(_claims(), None) == SubjectIdentity(user_id="okta-user-1", team_id="team-ops")
assert await asserted(_claims(), "team-eng") == SubjectIdentity(user_id="okta-user-1", team_id="team-eng")
mapped = subject_resolver(_Reader(user_id="u-mapped", email="m@example.com"), _known_user, _two_teams)
assert await mapped(_claims(), "engineering") == SubjectIdentity(user_id="u-mapped", team_id="team-eng")
unknown = subject_resolver(_Reader(), _known_user, _two_teams)
assert await unknown(_claims(sub="nobody"), None) is None
unreadable = await subject_resolver(_Reader(), _known_user, _teams_unreadable)(_claims(), None)
assert isinstance(unreadable, SubjectTokenRefusal)
assert unreadable.error == "temporarily_unavailable"
@pytest.mark.parametrize("failure", ["no_active_key", "unresolvable"])
@pytest.mark.asyncio
async def test_a_blocked_or_unmapped_user_is_a_bad_grant_not_an_outage_to_retry(failure):
"""A deactivated user, or one the resolver cannot map, can retry forever and never succeed, so
the refusal is invalid_grant. Only a database the gateway could not reach is retryable."""
refused = await subject_resolver(_Reader(), _known_user, _teams_failing(failure))(_claims(), None)
assert isinstance(refused, SubjectTokenRefusal)
assert refused.error == "invalid_grant"
assert "retry" not in refused.description
outage = await subject_resolver(_Reader(), _known_user, _teams_failing("faulted"))(_claims(), None)
assert isinstance(outage, SubjectTokenRefusal)
assert outage.error == "temporarily_unavailable"
def test_the_assertion_team_claims_ignore_team_id_default():
"""``team_id_default`` is how bearer JWT auth picks a team for a request, not a claim the
assertion made, so a user outside that team must still get their only team or no team."""
handler = JWTHandler()
handler.litellm_jwtauth = LiteLLM_JWTAuth(team_id_default="default-team")
claim_teams = tuple(_claim_reader(handler).get_all_jwt_team_ids(token={"iss": ISSUER, "sub": "u1"}))
assert claim_teams == ()
assert select_team(None, claim_teams, (ENG,)) == "team-eng"
assert select_team(None, claim_teams, ()) is None
@pytest.mark.parametrize(
"team_header, claim_teams, teams, error, mention",
[
("team-sales", (), (ENG, OPS), "invalid_request", TEAM_HEADER),
("engineering", (), (), "invalid_request", TEAM_HEADER),
(None, (), (ENG, OPS), "invalid_request", TEAM_HEADER),
(None, ("team-eng", "team-ops"), (ENG, OPS), "invalid_request", TEAM_HEADER),
(None, ("0oa-xaa-public-app",), (ENG, OPS), "invalid_request", TEAM_HEADER),
],
)
def test_select_team_refuses_a_team_the_user_is_not_on_or_an_unnamed_choice(
team_header, claim_teams, teams, error, mention
):
refused = select_team(team_header, claim_teams, teams)
assert isinstance(refused, SubjectTokenRefusal)
assert refused.error == error
assert mention in refused.description
@pytest.mark.parametrize(
"authorization, expected",
[
(None, None),
("Bearer abc", None),
("Basic not-base64!", None),
(_basic("0oa-confidential", "s3cret"), "0oa-confidential"),
("basic " + b64encode(b"0oa%20x:s3cret").decode(), "0oa x"),
("Basic " + b64encode(b"0oa+x:s3cret").decode(), "0oa x"),
("Basic " + b64encode(b"no-separator").decode(), None),
],
)
def test_basic_client_id_reads_the_app_from_the_authorization_header(authorization, expected):
assert basic_client_id(authorization) == expected
MCP_ROUTE_GROUPS = ("mcp_inference_routes",)
LLM_ROUTE_GROUPS = ("llm_api_routes_without_mcp",)
@pytest.mark.parametrize(
"claim, scope, allowed_routes",
[
(None, None, None),
("", None, None),
("litellm:mcp", "litellm:mcp", MCP_ROUTE_GROUPS),
("litellm:llm", "litellm:llm", LLM_ROUTE_GROUPS),
("litellm:mcp litellm:llm", "litellm:mcp litellm:llm", MCP_ROUTE_GROUPS + LLM_ROUTE_GROUPS),
("litellm:llm litellm:mcp litellm:llm", "litellm:mcp litellm:llm", MCP_ROUTE_GROUPS + LLM_ROUTE_GROUPS),
],
)
def test_the_scope_claim_buys_exactly_the_route_groups_it_names(claim, scope, allowed_routes):
"""``litellm:mcp`` is the MCP gateway's data-plane routes, ``litellm:llm`` the LLM API routes
without their MCP members, both together are both, and no claim is the full credential. The
scope echoed back is canonical: deduplicated, in vocabulary order, so it is exactly what is
enforced. Every group named must be one the route check resolves."""
granted = granted_scope(claim, mcp_resource=False)
assert (granted.scope, granted.allowed_routes) == (scope, allowed_routes)
if allowed_routes is not None:
assert set(allowed_routes) <= set(LiteLLMRoutes._member_names_)
mcp_members = set(LiteLLMRoutes.mcp_inference_routes.value)
llm_members = set().union(*(LiteLLMRoutes[group].value for group in LLM_ROUTE_GROUPS))
assert "/chat/completions" in llm_members and "/mcp" not in llm_members
assert "/mcp" in mcp_members and "/chat/completions" not in mcp_members
@pytest.mark.parametrize("claim", ["openid", "litellm:mcp openid", "litellm:admin", 7, ["litellm:mcp"]])
@pytest.mark.asyncio
async def test_a_scope_the_gateway_does_not_know_is_refused_as_invalid_scope_before_any_lookup(claim):
refusal = granted_scope(claim, mcp_resource=False)
assert isinstance(refusal, SubjectTokenRefusal)
assert refusal.error == "invalid_scope"
assert "litellm:mcp" in refusal.description and "litellm:llm" in refusal.description
resolver = _Resolver()
refused = await _identity(_redemption(_assertion()), _Verifier(result=_claims(scope=claim)), resolver)
assert refused == refusal
assert resolver.calls == ()
@pytest.mark.asyncio
async def test_a_linked_sso_account_resolves_by_its_provider_subject_without_an_email_claim():
"""An SSO login stores the provider subject as ``sso_user_id`` on a user whose ``user_id`` is
something else, so the lookup must offer the subject as both, with no email to fall back on."""
from litellm.models.user import LiteLLM_UserTable
async def get_user(*, user_id, user_email, sso_user_id):
if sso_user_id == "00u-okta-subject" and user_email is None:
return LiteLLM_UserTable(user_id="local-user-7", user_email=None)
return None
lookup = proxy_user_lookup(get_user)
assert await lookup("00u-okta-subject", None) == "local-user-7"
assert await lookup("nobody", None) is None
@pytest.mark.parametrize("email", ["eve@evil.example", None])
@pytest.mark.asyncio
async def test_the_allowed_email_domain_gates_the_assertions_subject_like_bearer_jwt_auth(email):
"""With ``user_allowed_email_domain`` set, bearer JWT auth refuses an identity whose email is
outside the domain or missing. An assertion for an existing user gets the same refusal before
any lookup, as invalid_grant, and a matching email still resolves."""
refused = await subject_resolver(_Reader(email=email, allowed_domain="corp.example"), _known_user, _two_teams)(
_claims(), "team-eng"
)
assert isinstance(refused, SubjectTokenRefusal)
assert refused.error == "invalid_grant"
allowed = subject_resolver(
_Reader(email="ann@corp.example", allowed_domain="corp.example"), _known_user, _two_teams
)
assert await allowed(_claims(), "team-eng") == SubjectIdentity(user_id="okta-user-1", team_id="team-eng")
@pytest.mark.parametrize("claim", [None, "", "litellm:mcp", "litellm:mcp litellm:mcp"])
def test_an_assertion_for_the_mcp_resource_is_ceilinged_to_the_mcp_routes(claim):
"""``aud = {base}/mcp`` means the identity provider named the MCP resource, so no scope or
``litellm:mcp`` both mint an MCP-only credential and echo ``litellm:mcp``."""
granted = granted_scope(claim, mcp_resource=True)
assert granted == GrantedScope(scope="litellm:mcp", allowed_routes=("mcp_inference_routes",))
@pytest.mark.parametrize("claim", ["litellm:llm", "litellm:mcp litellm:llm"])
def test_llm_routes_cannot_be_granted_under_the_mcp_resource_audience(claim):
refusal = granted_scope(claim, mcp_resource=True)
assert isinstance(refusal, SubjectTokenRefusal)
assert refusal.error == "invalid_scope"
assert "/mcp audience" in refusal.description and "bare origin" in refusal.description
@pytest.mark.parametrize(
"aud, expected",
[
(AUDIENCES[0], True),
(AUDIENCES[1], False),
([AUDIENCES[0]], True),
([AUDIENCES[0], AUDIENCES[1]], False),
("https://other.example", False),
(None, False),
],
)
def test_the_mcp_resource_audience_is_the_mcp_path_without_the_bare_origin(aud, expected):
assert names_mcp_resource(aud, GATEWAY) is expected
assert names_mcp_resource(aud, None) is False
@pytest.mark.asyncio
async def test_redeeming_for_the_mcp_resource_narrows_the_identity_and_refuses_llm_scope():
"""End to end through identity_from_id_jag: the same verified claims, with ``aud`` the MCP
resource, come out ceilinged, and asking for LLM routes under that audience is a scope error."""
narrowed = await _identity(_redemption(_assertion(aud=AUDIENCES[0])), _Verifier(), _Resolver())
assert isinstance(narrowed, AssertionIdentity)
assert (narrowed.scope, narrowed.allowed_routes) == ("litellm:mcp", ("mcp_inference_routes",))
refused = await _identity(_redemption(_assertion(aud=AUDIENCES[0], scope="litellm:llm")), _Verifier(), _Resolver())
assert isinstance(refused, SubjectTokenRefusal)
assert refused.error == "invalid_scope"
unscoped = jwt.encode(
{key: value for key, value in _claims(aud=AUDIENCES[1]).items() if key != "scope"},
"unused-by-the-fake-verifier-but-long-enough-for-hs256",
algorithm="HS256",
headers={"typ": ID_JAG_TYP},
)
full = await _identity(_redemption(unscoped), _Verifier(), _Resolver())
assert isinstance(full, AssertionIdentity)
assert (full.scope, full.allowed_routes) == (None, None)
scoped = await _identity(_redemption(_assertion(aud=AUDIENCES[1], scope="litellm:llm")), _Verifier(), _Resolver())
assert isinstance(scoped, AssertionIdentity)
assert scoped.scope == "litellm:llm"

View file

@ -1,15 +1,21 @@
"""Tests for minting the ``lite login`` credential from a consented native-client grant."""
from unittest.mock import ANY, AsyncMock, MagicMock
from unittest.mock import ANY, AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException
from litellm.constants import CLI_JWT_EXPIRATION_HOURS
from litellm.models.user import LiteLLM_UserTable
from litellm.proxy._experimental.mcp_server.gateway_dcr_flow import ConsentTeam, MintedProxyCredential
from litellm.proxy._experimental.mcp_server.proxy_api_credentials import lookup_consent_teams, mint_proxy_credential
from litellm.proxy._experimental.mcp_server.proxy_api_credentials import (
lookup_consent_teams,
lookup_consent_teams_from_database,
mint_proxy_credential,
)
from litellm.proxy._types import LitellmUserRoles
from litellm.proxy.auth.auth_checks import ExperimentalUIJWTToken
from litellm.proxy.auth.route_checks import RouteChecks
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.management_endpoints.ui_sso import CliSsoTeamDetail
@ -235,3 +241,82 @@ async def test_lookup_consent_teams_passes_user_lookup_failures_through(failure,
async def test_lookup_consent_teams_reports_unavailable_when_details_cannot_load(load_user, fetch_teams):
fetch_teams.return_value = None
assert await lookup_consent_teams("u1") == "unavailable"
def _route_status(minted: MintedProxyCredential, route: str, method: str | None = None) -> int:
request = None if method is None else MagicMock(method=method)
try:
RouteChecks.is_virtual_key_allowed_to_call_route(route=route, valid_token=_decoded(minted), request=request)
except HTTPException as refused:
return refused.status_code
return 200
@pytest.mark.parametrize(
"allowed_routes, chat_completions, mcp",
[
(None, True, True),
(("mcp_inference_routes",), False, True),
(("llm_api_routes_without_mcp",), True, False),
(("mcp_inference_routes", "llm_api_routes_without_mcp"), True, True),
],
ids=["full credential", "mcp only", "llm only", "both"],
)
@pytest.mark.asyncio
async def test_mint_narrows_the_credential_to_the_allowed_routes_the_route_check_enforces(
load_user, fetch_teams, allowed_routes, chat_completions, mcp
):
"""The allowed_routes a grant's scope resolved to ride inside the credential, so the same route
check a virtual key goes through on every request refuses /chat/completions for an MCP-only
credential and /mcp for an LLM-only one, and a credential minted without them is unchanged."""
minted = await mint_proxy_credential("u1", "team-a", allowed_routes=allowed_routes)
assert isinstance(minted, MintedProxyCredential)
assert _decoded(minted).allowed_routes == list(allowed_routes or ())
chat_status, mcp_status = (200 if chat_completions else 403), (200 if mcp else 403)
assert _route_status(minted, "/chat/completions") == chat_status
assert _route_status(minted, "/v1/chat/completions") == chat_status
assert _route_status(minted, "/mcp") == mcp_status
assert _route_status(minted, "/mcp/tools/call") == mcp_status
@pytest.mark.asyncio
async def test_lookup_consent_teams_from_database_reads_the_row_the_minter_will_recheck(load_user, fetch_teams):
"""A grant consumed once a team is picked must pick it from the same membership the minter re-reads,
or a stale cache would burn the assertion on a request the caller could have fixed."""
teams = await lookup_consent_teams_from_database("u1")
load_user.assert_awaited_once_with("u1", source="database")
assert teams == (ConsentTeam(team_id="team-a", team_alias="Team A"), ConsentTeam(team_id="team-b"))
@pytest.mark.parametrize(
"allowed_routes, registered_pass_through, auto_router_session, mcp_server_listing, agent_registry",
[
(("llm_api_routes",), 200, 200, 200, 200),
(("llm_api_routes_without_mcp",), 200, 200, 403, 403),
(("mcp_inference_routes",), 403, 403, 403, 403),
],
ids=["llm_api_routes key", "llm only", "mcp only"],
)
@pytest.mark.asyncio
async def test_an_llm_only_credential_keeps_the_llm_api_carve_outs_except_mcp_discovery(
load_user,
fetch_teams,
allowed_routes,
registered_pass_through,
auto_router_session,
mcp_server_listing,
agent_registry,
):
"""Operator-registered pass-through endpoints and the auto-router session read are LLM API
surface that only the ``llm_api_routes`` group used to reach, so an LLM-only credential keeps
them, while the MCP server listing that group also carves out stays MCP surface."""
minted = await mint_proxy_credential("u1", "team-a", allowed_routes=allowed_routes)
assert isinstance(minted, MintedProxyCredential)
with patch(
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.InitPassThroughEndpointHelpers.is_registered_pass_through_route",
side_effect=lambda route: route.startswith("/my-llm-endpoint"),
):
assert _route_status(minted, "/my-llm-endpoint/v1/chat", "POST") == registered_pass_through
assert _route_status(minted, "/auto_router/session", "GET") == auto_router_session
assert _route_status(minted, "/v1/mcp/server", "GET") == mcp_server_listing
assert _route_status(minted, "/v1/agents", "GET") == agent_registry

View file

@ -13,6 +13,7 @@ from litellm.caching.dual_cache import DualCache
from litellm.proxy._types import (
DEFAULT_JWKS_STALE_TTL,
JWTLiteLLMRoleMap,
JWTIssuerConfig,
LiteLLMRoutes,
LiteLLM_JWTAuth,
LiteLLM_ModelTable,
@ -29,9 +30,11 @@ from litellm.proxy._types import (
from litellm.proxy.agent_endpoints.agent_registry import AgentRegistry
from litellm.proxy.auth.auth_checks import TeamNotFoundError
from litellm.proxy.auth.handle_jwt import (
ID_JAG_TYP,
JWKS_FETCH_ATTEMPTS,
STALE_CACHE_KEY_PREFIX,
STALE_WRITTEN_AT_CACHE_KEY_PREFIX,
AssertionRejection,
HeaderTeam,
JWKSUnreachableError,
JWTAuthManager,
@ -8303,3 +8306,198 @@ def test_team_passthrough_route_denial_names_the_matched_deny_entry(
assert exc_info.value.status_code == 403
assert expected_detail in str(exc_info.value.detail)
ASSERTION_ISSUER = "https://idp.example.com/oauth2/default"
ASSERTION_JWKS_URL = f"{ASSERTION_ISSUER}/v1/keys"
GATEWAY_AUDIENCES = ("https://gateway.example.com/mcp", "https://gateway.example.com")
ID_JAG_CLAIMS = ("iss", "sub", "aud", "exp", "iat", "jti", "client_id")
def _assertion_signer():
import json
import jwt as pyjwt
from cryptography.hazmat.primitives.asymmetric import rsa
private_key: Final = rsa.generate_private_key(public_exponent=65537, key_size=2048)
jwk: Final = {**json.loads(pyjwt.algorithms.RSAAlgorithm.to_jwk(private_key.public_key())), "kid": "k1"}
def sign(claims: Mapping[str, object], typ: str = ID_JAG_TYP) -> str:
return pyjwt.encode(dict(claims), private_key, algorithm="RS256", headers={"kid": "k1", "typ": typ})
return jwk, sign
def _id_jag_claims(**overrides):
now: Final = int(time.time())
return {
"iss": ASSERTION_ISSUER,
"sub": "okta-user-1",
"aud": GATEWAY_AUDIENCES[0],
"exp": now + 300,
"iat": now - 5,
"jti": "jti-1",
"client_id": "0oa-requesting-app",
**overrides,
}
def _assertion_handler(jwk, litellm_jwtauth, jwks_url=ASSERTION_JWKS_URL):
"""A handler whose JWKS for the issuer is already cached, so no HTTP is needed."""
handler: Final = JWTHandler()
cache: Final = DualCache()
cache.set_cache(f"litellm_jwt_auth_keys_{jwks_url}", [jwk])
handler.update_environment(prisma_client=None, user_api_key_cache=cache, litellm_jwtauth=litellm_jwtauth)
return handler
@pytest.mark.asyncio
async def test_decode_trusted_assertion_verifies_against_the_configured_issuer_with_the_callers_audience():
"""The assertion is checked with the issuer's JWKS and claim mappings, but for the audience the
caller names (the gateway's own issuer values), not the API audience the issuer entry carries,
since an ID-JAG is addressed to the authorization server, never to the API."""
jwk, sign = _assertion_signer()
handler = _assertion_handler(
jwk,
LiteLLM_JWTAuth(
issuers=[
JWTIssuerConfig(
issuer=ASSERTION_ISSUER,
jwks_url=ASSERTION_JWKS_URL,
audience="api://litellm",
user_id_jwt_field="sub",
)
]
),
)
claims = _id_jag_claims()
verified = await handler.decode_trusted_assertion(sign(claims), GATEWAY_AUDIENCES, ID_JAG_CLAIMS)
assert isinstance(verified, dict)
assert {key: verified[key] for key in claims} == claims
assert handler.get_user_id(token=verified, default_value=None) == "okta-user-1"
second = await handler.decode_trusted_assertion(
sign(_id_jag_claims(aud=GATEWAY_AUDIENCES[1])), GATEWAY_AUDIENCES, ID_JAG_CLAIMS
)
assert isinstance(second, dict)
@pytest.mark.asyncio
async def test_decode_trusted_assertion_falls_back_to_the_env_scoped_issuer(monkeypatch):
jwk, sign = _assertion_signer()
monkeypatch.setenv("JWT_ISSUER", ASSERTION_ISSUER)
monkeypatch.setenv("JWT_PUBLIC_KEY_URL", ASSERTION_JWKS_URL)
handler = _assertion_handler(jwk, LiteLLM_JWTAuth())
verified = await handler.decode_trusted_assertion(sign(_id_jag_claims()), GATEWAY_AUDIENCES, ID_JAG_CLAIMS)
assert isinstance(verified, dict)
assert verified["jti"] == "jti-1"
monkeypatch.setenv("JWT_ISSUER", "https://someone-else.example.com")
foreign = await handler.decode_trusted_assertion(sign(_id_jag_claims()), GATEWAY_AUDIENCES, ID_JAG_CLAIMS)
assert isinstance(foreign, AssertionRejection)
assert "trusts" in foreign.reason
@pytest.mark.asyncio
@pytest.mark.parametrize(
"fault, reason",
[
({"aud": "api://litellm"}, "udience"),
({"jti": None}, "jti"),
({"client_id": None}, "client_id"),
({"exp": -10}, "xpired"),
({"iat": 600}, "iat"),
({"iss": "https://untrusted.example.com"}, "trusts"),
],
)
async def test_decode_trusted_assertion_rejects_a_claim_outside_the_profile(fault: dict[str, object], reason: str):
"""The claims are built inside the test, relative to the clock the verifier reads, so a slow
collection cannot turn an intended claim fault into an unrelated expiry (``None`` drops a claim,
an int on ``exp`` or ``iat`` is an offset from now)."""
now: Final = int(time.time())
offsets: Final = {k: now + v for k, v in fault.items() if k in ("exp", "iat") and isinstance(v, int)}
claims: Final = {k: v for k, v in _id_jag_claims(**offsets).items() if fault.get(k, k) is not None}
claims.update({k: v for k, v in fault.items() if v is not None and k not in offsets})
jwk, sign = _assertion_signer()
handler = _assertion_handler(
jwk,
LiteLLM_JWTAuth(issuers=[JWTIssuerConfig(issuer=ASSERTION_ISSUER, jwks_url=ASSERTION_JWKS_URL, audience="x")]),
)
rejected = await handler.decode_trusted_assertion(sign(claims), GATEWAY_AUDIENCES, ID_JAG_CLAIMS)
assert isinstance(rejected, AssertionRejection)
assert reason in rejected.reason
@pytest.mark.asyncio
async def test_decode_trusted_assertion_rejects_a_forged_signature_and_garbage(monkeypatch):
jwk, _ = _assertion_signer()
_, forge = _assertion_signer()
handler = _assertion_handler(
jwk,
LiteLLM_JWTAuth(issuers=[JWTIssuerConfig(issuer=ASSERTION_ISSUER, jwks_url=ASSERTION_JWKS_URL, audience="x")]),
)
forged = await handler.decode_trusted_assertion(forge(_id_jag_claims()), GATEWAY_AUDIENCES, ID_JAG_CLAIMS)
assert isinstance(forged, AssertionRejection)
assert "verification" in forged.reason
garbage = await handler.decode_trusted_assertion("not.a.jwt", GATEWAY_AUDIENCES, ID_JAG_CLAIMS)
assert isinstance(garbage, AssertionRejection)
monkeypatch.delenv("JWT_ISSUER", raising=False)
unconfigured = await _assertion_handler(jwk, LiteLLM_JWTAuth()).decode_trusted_assertion(
forge(_id_jag_claims()), GATEWAY_AUDIENCES, ID_JAG_CLAIMS
)
assert isinstance(unconfigured, AssertionRejection)
assert "trusts" in unconfigured.reason
@pytest.mark.asyncio
async def test_decode_trusted_assertion_reports_an_unreachable_jwks_as_an_outage_not_a_bad_assertion():
jwk, sign = _assertion_signer()
handler = _assertion_handler(
jwk,
LiteLLM_JWTAuth(issuers=[JWTIssuerConfig(issuer=ASSERTION_ISSUER, jwks_url=ASSERTION_JWKS_URL, audience="x")]),
jwks_url="https://a-different-cached-url.example.com/keys",
)
with patch.object(handler, "_fetch_jwks_keys", new_callable=AsyncMock, side_effect=JWKSUnreachableError("down")):
with pytest.raises(ProxyException) as outage:
await handler.decode_trusted_assertion(sign(_id_jag_claims()), GATEWAY_AUDIENCES, ID_JAG_CLAIMS)
assert outage.value.code == "503"
@pytest.mark.asyncio
async def test_auth_jwt_never_accepts_an_id_jag_as_a_bearer_token():
"""An assertion typed oauth-id-jag+jwt is a grant for the token endpoint, so bearer auth
refuses it before any key lookup, even where it would otherwise verify."""
jwk, sign = _assertion_signer()
handler = _assertion_handler(
jwk,
LiteLLM_JWTAuth(
issuers=[
JWTIssuerConfig(issuer=ASSERTION_ISSUER, jwks_url=ASSERTION_JWKS_URL, audience=GATEWAY_AUDIENCES[0])
]
),
)
accepted = await handler.auth_jwt(sign(_id_jag_claims(), typ="JWT"))
assert accepted["jti"] == "jti-1"
with pytest.raises(Exception, match="token endpoint"):
await handler.auth_jwt(sign(_id_jag_claims()))
@pytest.mark.asyncio
@pytest.mark.parametrize("verdict", [True, False])
async def test_decode_trusted_assertion_runs_the_configured_custom_validate_on_the_verified_claims(verdict):
"""The operator's ``custom_validate`` gates every JWT the proxy admits, so an assertion redeemed
at the token endpoint goes through it too, after signature and claim verification."""
jwk, sign = _assertion_signer()
seen: Final = []
handler = _assertion_handler(
jwk,
LiteLLM_JWTAuth(
issuers=[JWTIssuerConfig(issuer=ASSERTION_ISSUER, jwks_url=ASSERTION_JWKS_URL, audience="api://litellm")]
),
)
handler.litellm_jwtauth.custom_validate = lambda claims: seen.append(dict(claims)) or verdict
claims = _id_jag_claims()
verified = await handler.decode_trusted_assertion(sign(claims), GATEWAY_AUDIENCES, ID_JAG_CLAIMS)
assert [{key: call[key] for key in claims} for call in seen] == [claims]
if verdict:
assert isinstance(verified, dict)
else:
assert isinstance(verified, AssertionRejection)
assert "custom_validate" in verified.reason

View file

@ -401,3 +401,25 @@ def test_without_the_flag_the_warmup_route_registers_a_feature_and_returns_its_p
assert body.paths["/alpha/list"]["get"].tags == ("alpha",)
assert loaded_lazy_modules(app) == {features[0].module_path}
assert "/alpha/list" in _paths(app) and "/beta/list" not in _paths(app)
def test_root_authorization_server_metadata_is_the_gateways_not_the_byok_connector_document(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Two features claim the root ``/.well-known/oauth-authorization-server``. The one a client
gets must be the gateway's own authorization server metadata, whose ``/token`` serves the
gateway's grants, not the BYOK connector form flow whose token endpoint is
``/v1/mcp/oauth/token``; the connector keeps its path-appended document."""
monkeypatch.setenv(FLAG, "false")
app: Final = FastAPI()
attach_lazy_features(app)
with TestClient(app, base_url="https://gateway.example.com") as client:
root: Final = client.get("/.well-known/oauth-authorization-server")
connector: Final = client.get("/.well-known/oauth-authorization-server/v1/mcp/oauth")
assert root.status_code == 200
assert root.json()["issuer"] == "https://gateway.example.com"
assert root.json()["token_endpoint"] == "https://gateway.example.com/token"
assert connector.status_code == 200
assert connector.json()["token_endpoint"] == "https://gateway.example.com/v1/mcp/oauth/token"

View file

@ -27480,8 +27480,10 @@ export interface components {
};
/** Body_token_endpoint__mcp_server_name__token_post */
Body_token_endpoint__mcp_server_name__token_post: {
/** Assertion */
assertion?: string | null;
/** Client Id */
client_id: string;
client_id?: string | null;
/** Client Secret */
client_secret?: string | null;
/** Code */
@ -27507,8 +27509,10 @@ export interface components {
};
/** Body_token_endpoint_token_post */
Body_token_endpoint_token_post: {
/** Assertion */
assertion?: string | null;
/** Client Id */
client_id: string;
client_id?: string | null;
/** Client Secret */
client_secret?: string | null;
/** Code */