feat(mcp): aggregate DCR register, authorize, complete, and token flow for the gateway front door

This commit is contained in:
Tin Chi Lo 2026-07-14 02:33:45 -07:00
parent 22a7174e3c
commit f546cfcb5f
6 changed files with 1070 additions and 6 deletions

View file

@ -39,6 +39,14 @@ from litellm.proxy._experimental.mcp_server.faults import (
dcr_fault_detail,
render_token_fault,
)
from litellm.proxy._experimental.mcp_server.gateway_dcr_flow import (
aggregate_authorize,
aggregate_token,
complete_connect_flow,
is_gateway_dcr_client_id,
register_aggregate_client,
relative_request_url,
)
from litellm.proxy._experimental.mcp_server.oauth_utils import (
TOKEN_NO_CACHE_HEADERS,
get_request_base_url,
@ -217,14 +225,25 @@ def open_bridge_authorization_code(code: str) -> _BridgeAuthorizationCode | None
return None
def _session_cookie_user_id(request: Request) -> str | None:
"""The signed-in litellm user for a browser request, or ``None``. Thin wrapper so the
aggregate DCR flow's verbs receive the identity as a plain value instead of parsing
cookies themselves."""
from litellm.proxy._experimental.mcp_server.byok_oauth_endpoints import ( # noqa: PLC0415 # circular import at module load
_user_id_from_session_cookie,
)
return _user_id_from_session_cookie(request)
def _redirect_to_litellm_login(request: Request) -> RedirectResponse:
"""Send an unauthenticated browser through litellm login before the interactive bridge authorize
can capture its identity. The bridge oauth_delegate flow seals the SSO user into the gateway code,
so a session is required; without one there is nothing to bind. After login the user re-initiates
the connection, which then finds the session cookie (the seamless return-to round-trip, which is
origin-validated against the control-plane URL, is a follow-up)."""
so a session is required; without one there is nothing to bind. A same-origin relative
``return_to`` (honored by the SSO callback) brings the browser straight back to this authorize
request after login instead of stranding it on the dashboard."""
base_url = get_request_base_url(request)
return RedirectResponse(f"{base_url}/sso/key/generate")
return RedirectResponse(f"{base_url}/sso/key/generate?{urlencode({'return_to': relative_request_url(request)})}")
# LIT-4197: some upstream authorization servers reject an over-long ``state``
@ -1253,6 +1272,18 @@ async def authorize(
global_mcp_server_manager,
)
if mcp_server_name is None and client_id and is_gateway_dcr_client_id(client_id) and is_mcp_gateway_dcr_enabled():
return aggregate_authorize(
request=request,
client_id=client_id,
redirect_uri=redirect_uri,
state=state,
code_challenge=code_challenge,
code_challenge_method=code_challenge_method,
response_type=response_type,
session_user_id=_session_cookie_user_id(request),
)
lookup_name: Optional[str] = mcp_server_name or client_id
client_ip = IPAddressUtils.get_mcp_client_ip(request)
mcp_server = (
@ -1316,6 +1347,25 @@ async def token_endpoint(
global_mcp_server_manager,
)
if mcp_server_name is None and is_gateway_dcr_client_id(client_id) and is_mcp_gateway_dcr_enabled():
from litellm.proxy.proxy_server import ( # noqa: PLC0415 # circular import at module load
master_key,
user_api_key_cache,
)
return await aggregate_token(
request=request,
grant_type=grant_type,
code=code,
redirect_uri=redirect_uri,
client_id=client_id,
code_verifier=code_verifier,
refresh_token=refresh_token,
master_key=master_key,
reload_user=_reload_active_user_by_id,
cache=user_api_key_cache,
)
lookup_name = mcp_server_name or client_id
client_ip = IPAddressUtils.get_mcp_client_ip(request)
mcp_server = global_mcp_server_manager.get_mcp_server_by_name(lookup_name, client_ip=client_ip)
@ -1337,6 +1387,21 @@ async def token_endpoint(
)
@router.post("/authorize/complete")
async def authorize_complete(request: Request, flow: str = Form(...)):
"""Finish an aggregate connect flow (``mcp_gateway_dcr``): mint the gateway
authorization code for the signed-in user and redirect back to the DCR client. POST
plus the per-flow HttpOnly cookie set at /authorize; 404 when the flag is off so the
route is byte-invisible to existing deployments."""
if not is_mcp_gateway_dcr_enabled():
raise HTTPException(status_code=404, detail="Not Found")
return complete_connect_flow(
request=request,
flow_handle=flow,
session_user_id=_session_cookie_user_id(request),
)
# Per RFC 6749 §4.1.2.1, an IdP that rejects an OAuth authorization request
# redirects back to the configured redirect URI with ``error`` /
# ``error_description`` / ``error_uri`` query params and no ``code``. The MCP
@ -2061,6 +2126,8 @@ async def register_client(request: Request, mcp_server_name: Optional[str] = Non
}
client_ip = IPAddressUtils.get_mcp_client_ip(request)
if not mcp_server_name:
if is_mcp_gateway_dcr_enabled():
return await register_aggregate_client(request=request, request_body=data)
resolved = _resolve_oauth2_server_for_root_endpoints(client_ip=client_ip)
if resolved:
return await register_client_with_server(

View file

@ -0,0 +1,519 @@
"""The gateway-level DCR flow for the aggregate ``/mcp`` endpoint (``mcp_gateway_dcr``).
An OAuth-only DCR client (Claude Desktop, Claude Code, MCP Inspector) pointed at the
aggregate ``/mcp`` endpoint discovers the gateway as its authorization server (PR 1 of
this track) and then walks the flow implemented here:
1. ``POST /register``: stateless dynamic client registration. The ``client_id`` IS the
registration: the client's redirect URIs are sealed into it with the repo's
authenticated symmetric helper, so nothing is persisted and a forged or tampered
client_id simply fails to open. Clients are always public (``token_endpoint_auth_method
"none"``); PKCE S256 is what protects the code.
2. ``GET /authorize``: validates the client and redirect URI, requires S256 PKCE, and
interposes LiteLLM sign-in. Without a session cookie the browser is sent through
``/sso/key/generate`` with a same-origin ``return_to`` so it lands back here after
login. With a session, the flow parameters and the SSO user are sealed into a per-flow
HttpOnly cookie (the same pattern as the upstream OAuth state relay) and the browser is
sent to the connect page, where the user authorizes individual servers (vaulting those
tokens server-side) before finishing.
3. ``POST /authorize/complete``: the deliberate finish step. A POST (not GET) bound to the
SameSite=Lax flow cookie, so a cross-site link cannot silently mint a code with the
victim's session, and the signed-in user must match the user sealed into the flow.
Mints a short-lived, single-use, gateway-sealed authorization code and redirects to the
client's registered redirect URI.
4. ``POST /token``: exchanges the code (PKCE-verified, client- and redirect-bound,
single-use) for the identity-only session tokens of
:mod:`.outbound_credentials.session_token`, re-validating that the litellm user is
still active first; the ``refresh_token`` grant rotates the pair the same way.
Nothing here stores state server-side except the single-use code guard (a TTL cache
entry). Every sealed value is authenticated encryption over the proxy salt/master key
family, opened totally (bad input maps to an OAuth error, never a raise), and every
identity is a stable reference re-validated live at mint, refresh, and (in the admission
PR) tool-call time. Upstream server credentials never appear anywhere in this flow; they
are vaulted per user by the existing ``/v1/mcp`` authorize endpoints and resolved at
egress by user id.
"""
from __future__ import annotations
import hashlib
import hmac
import secrets
from base64 import urlsafe_b64encode
from datetime import datetime, timezone
from typing import Awaitable, Callable, Literal, TypeVar
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
from fastapi import Request
from fastapi.responses import JSONResponse, RedirectResponse, Response
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from litellm._logging import verbose_logger
from litellm.caching.caching import DualCache
from litellm.proxy._experimental.mcp_server.oauth_utils import (
TOKEN_NO_CACHE_HEADERS,
get_request_base_url,
)
from litellm.proxy._experimental.mcp_server.outbound_credentials.session_credentials import (
SessionRefreshOpened,
open_session_refresh_bearer,
session_keys_from_master_key,
)
from litellm.proxy._experimental.mcp_server.outbound_credentials.session_token import (
MintedSessionToken,
SessionKeys,
SessionPrincipal,
mint_session_refresh_token,
mint_session_token,
)
from litellm.proxy.common_utils.encrypt_decrypt_utils import (
decrypt_value_helper,
encrypt_value_helper,
)
GATEWAY_DCR_CLIENT_ID_PREFIX = "llm_dcrc_"
"""Marker prefix on every gateway-issued DCR client_id so the root authorize/token
endpoints can route an aggregate-flow request without decrypting, and existing per-server
flows (whose client_ids are upstream-issued) are never captured by the aggregate arm."""
GATEWAY_AUTH_CODE_PREFIX = "llm_gcode_"
"""Marker prefix on the gateway-sealed authorization code, distinct from the bridge
``llm_bcode_`` so neither flow can consume the other's codes."""
CONNECT_FLOW_COOKIE_PREFIX = "mcp_connect_flow_"
"""Per-flow HttpOnly cookie holding the sealed connect flow, keyed by a short random
handle carried in the connect-page URL (the same handle-plus-cookie pattern as the
``mcp_oauth_state_`` upstream relay, for the same reasons: replica-safe with no
server-side session store, and the sealed value never appears in a URL)."""
CONNECT_FLOW_TTL_SECONDS = 600
GATEWAY_AUTH_CODE_TTL_SECONDS = 120
_USED_CODE_CACHE_PREFIX = "mcp_gateway_dcr_code_used:"
MAX_REDIRECT_URIS = 3
MAX_REDIRECT_URI_LENGTH = 256
MAX_CLIENT_ID_LENGTH = 2048
"""Registration bounds. They exist to bound the sealed client_id, which rides inside
every session-token claim set: 3 URIs of 256 bytes seal to roughly 1.2KB, comfortably
under this cap and under the session token's own 4KB ceiling. Claude Desktop and MCP
Inspector register one or two redirect URIs."""
_CLIENT_RECORD_DEBUG_KEY = "gateway_dcr_client"
_CONNECT_FLOW_DEBUG_KEY = "gateway_connect_flow"
_AUTH_CODE_DEBUG_KEY = "gateway_authorization_code"
ReloadUserFailure = Literal["unresolvable", "unavailable", "no_active_key"]
ReloadUser = Callable[[str], Awaitable[ReloadUserFailure | None]]
"""Injected live-user revalidation (the token endpoint's mirror of admission):
``None`` means the user is active; ``unavailable`` is a retryable DB outage; anything
else fails the grant closed."""
class GatewayDcrClient(BaseModel):
"""The registration record sealed into a gateway DCR ``client_id``."""
model_config = ConfigDict(frozen=True)
redirect_uris: tuple[str, ...] = Field(min_length=1, max_length=MAX_REDIRECT_URIS)
iat: int
class _ConnectFlow(BaseModel):
"""One in-flight authorize: the SSO user it belongs to and the client parameters
needed to mint the code at the finish step. Sealed into the per-flow cookie."""
model_config = ConfigDict(frozen=True)
user_id: str = Field(min_length=1)
client_id: str = Field(min_length=1)
redirect_uri: str = Field(min_length=1)
state: str
code_challenge: str = Field(min_length=1)
exp: int
class _GatewayAuthCode(BaseModel):
"""The gateway-sealed authorization code: the user consent it represents and the
bindings the token endpoint must verify (client, redirect URI, PKCE challenge),
plus a ``jti`` for the single-use guard."""
model_config = ConfigDict(frozen=True)
user_id: str = Field(min_length=1)
client_id: str = Field(min_length=1)
redirect_uri: str = Field(min_length=1)
code_challenge: str = Field(min_length=1)
jti: str = Field(min_length=1)
iat: int
exp: int
def is_gateway_dcr_client_id(client_id: str | None) -> bool:
"""Cheap prefix routing test so the root endpoints only enter the aggregate arm for
clients this flow registered; every other client_id keeps today's behavior."""
return bool(client_id) and str(client_id).startswith(GATEWAY_DCR_CLIENT_ID_PREFIX)
def _oauth_error(status_code: int, error: str, description: str) -> JSONResponse:
"""RFC 6749 section 5.2 / RFC 7591 section 3.2.2 error body. Descriptions carry no
token, code, or URL material so they are safe to relay to any client."""
return JSONResponse(
status_code=status_code,
content={"error": error, "error_description": description},
headers=TOKEN_NO_CACHE_HEADERS,
)
def _seal(prefix: str, payload: BaseModel) -> str:
return prefix + encrypt_value_helper(payload.model_dump_json())
_SealedModelT = TypeVar("_SealedModelT", bound=BaseModel)
def _open_sealed(value: str, prefix: str, model: type[_SealedModelT], debug_key: str) -> _SealedModelT | None:
"""Open a sealed value totally: anything that is not prefix-shaped, does not decrypt,
or does not validate returns ``None`` for the caller to map onto an OAuth error."""
if not value.startswith(prefix):
return None
decrypted = decrypt_value_helper(value[len(prefix) :], debug_key, return_original_value=False)
if not isinstance(decrypted, str):
return None
try:
return model.model_validate_json(decrypted)
except ValidationError:
return None
def open_gateway_dcr_client(client_id: str) -> GatewayDcrClient | None:
return _open_sealed(client_id, GATEWAY_DCR_CLIENT_ID_PREFIX, GatewayDcrClient, _CLIENT_RECORD_DEBUG_KEY)
def _redirect_uri_acceptable(uri: str) -> bool:
"""https for real clients, plus http strictly on a loopback host for local dev
clients (RFC 8252 section 7.3). No fragments (RFC 6749 section 3.1.2)."""
if len(uri) > MAX_REDIRECT_URI_LENGTH:
return False
parsed = urlparse(uri)
if parsed.fragment or not parsed.netloc:
return False
if parsed.scheme == "https":
return True
return parsed.scheme == "http" and (parsed.hostname or "").lower() in ("localhost", "127.0.0.1", "::1")
async def register_aggregate_client(request: Request, request_body: dict) -> Response:
"""RFC 7591 dynamic registration against the gateway itself, statelessly.
Only ``redirect_uris`` is authoritative; every client is registered as a public
``token_endpoint_auth_method "none"`` client regardless of what it asked for (RFC
7591 lets the server override metadata), because the gateway never issues client
secrets: possession of a secret would add nothing over the mandatory S256 PKCE, and a
stateless registration has nowhere to keep one. Nothing is persisted, so open
registration cannot be used to fill storage.
"""
raw_uris = request_body.get("redirect_uris")
if not isinstance(raw_uris, list) or not raw_uris or len(raw_uris) > MAX_REDIRECT_URIS:
return _oauth_error(
400,
"invalid_redirect_uri",
f"redirect_uris must be a list of 1 to {MAX_REDIRECT_URIS} URIs",
)
if not all(isinstance(uri, str) and _redirect_uri_acceptable(uri) for uri in raw_uris):
return _oauth_error(
400,
"invalid_redirect_uri",
"each redirect URI must be https (or http on a loopback host), "
f"fragment-free, and at most {MAX_REDIRECT_URI_LENGTH} characters",
)
now = datetime.now(timezone.utc)
client_id = _seal(
GATEWAY_DCR_CLIENT_ID_PREFIX, GatewayDcrClient(redirect_uris=tuple(raw_uris), iat=int(now.timestamp()))
)
if len(client_id) > MAX_CLIENT_ID_LENGTH:
return _oauth_error(400, "invalid_client_metadata", "registered metadata is too large")
return JSONResponse(
status_code=201,
content={
"client_id": client_id,
"client_id_issued_at": int(now.timestamp()),
"redirect_uris": list(raw_uris),
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
},
)
def _flow_cookie_name(handle: str) -> str:
return f"{CONNECT_FLOW_COOKIE_PREFIX}{handle}"
def _cookie_path_and_secure(request: Request) -> tuple[str, bool]:
parsed = urlparse(get_request_base_url(request))
return parsed.path or "/", parsed.scheme == "https"
def _append_query_params(url: str, params: dict[str, str]) -> str:
parsed = urlparse(url)
query = parse_qsl(parsed.query, keep_blank_values=True) + list(params.items())
return urlunparse(parsed._replace(query=urlencode(query)))
def relative_request_url(request: Request) -> str:
"""The request's own path and query as a same-origin ``return_to`` target for the
login round-trip; relative by construction, so it can never leave the gateway."""
path = request.url.path
return f"{path}?{request.url.query}" if request.url.query else path
def aggregate_authorize(
request: Request,
client_id: str,
redirect_uri: str,
state: str,
code_challenge: str | None,
code_challenge_method: str | None,
response_type: str | None,
session_user_id: str | None,
) -> Response:
"""The aggregate authorize verb: validate the client, require S256 PKCE, interpose
LiteLLM sign-in, and hand the browser to the connect page with the flow sealed into a
per-flow cookie.
Validation failures respond directly with 400 and never redirect: per RFC 6749
section 4.1.2.1 an unvalidated redirect URI must not receive an error redirect, and
once the client is at fault there is no trusted place to send the browser.
"""
client = open_gateway_dcr_client(client_id)
if client is None:
return _oauth_error(400, "invalid_client", "unknown or malformed client_id")
if redirect_uri not in client.redirect_uris:
return _oauth_error(400, "invalid_request", "redirect_uri is not registered for this client")
if response_type != "code":
return _oauth_error(400, "unsupported_response_type", "response_type must be 'code'")
if not code_challenge or code_challenge_method != "S256":
return _oauth_error(
400,
"invalid_request",
"PKCE is required: send code_challenge with code_challenge_method=S256",
)
base_url = get_request_base_url(request)
if session_user_id is None:
login_url = f"{base_url}/sso/key/generate?{urlencode({'return_to': relative_request_url(request)})}"
return RedirectResponse(login_url, status_code=303)
now = datetime.now(timezone.utc)
handle = secrets.token_urlsafe(24)
flow = _ConnectFlow(
user_id=session_user_id,
client_id=client_id,
redirect_uri=redirect_uri,
state=state,
code_challenge=code_challenge,
exp=int(now.timestamp()) + CONNECT_FLOW_TTL_SECONDS,
)
connect_url = _append_query_params(
f"{base_url}/ui/chat/integrations",
{"connect_flow": handle, "connect_client": _origin_only(redirect_uri)},
)
response = RedirectResponse(connect_url, status_code=303)
path, secure = _cookie_path_and_secure(request)
response.set_cookie(
key=_flow_cookie_name(handle),
value=_seal("", flow),
max_age=CONNECT_FLOW_TTL_SECONDS,
path=path,
secure=secure,
httponly=True,
samesite="lax",
)
return response
def _origin_only(url: str) -> str:
"""Scheme+host for display on the connect page; never the full redirect URI, whose
path or query could carry values that do not belong in a page URL or logs."""
parsed = urlparse(url)
return f"{parsed.scheme}://{parsed.netloc}" if parsed.netloc else ""
def complete_connect_flow(
request: Request,
flow_handle: str,
session_user_id: str | None,
) -> Response:
"""The deliberate finish step of the connect flow: mint the gateway authorization
code and send the browser back to the client.
Reached by POST so a cross-site GET cannot trigger it, and bound to the HttpOnly
per-flow cookie plus an exact match between the signed-in user and the user sealed
into the flow: a link crafted by another party dies here with ``access_denied``
instead of minting a code for the victim's identity.
"""
sealed_flow = request.cookies.get(_flow_cookie_name(flow_handle))
if sealed_flow is None:
return _oauth_error(400, "invalid_request", "unknown or expired connect flow")
flow = _open_sealed(sealed_flow, "", _ConnectFlow, _CONNECT_FLOW_DEBUG_KEY)
if flow is None:
return _oauth_error(400, "invalid_request", "unknown or expired connect flow")
now = datetime.now(timezone.utc)
if now.timestamp() >= flow.exp:
return _oauth_error(400, "invalid_request", "the connect flow has expired; restart the connection")
if session_user_id is None:
return _oauth_error(401, "login_required", "sign in to LiteLLM to finish connecting")
if session_user_id != flow.user_id:
return _oauth_error(403, "access_denied", "the signed-in user does not match this connect flow")
code = _seal(
GATEWAY_AUTH_CODE_PREFIX,
_GatewayAuthCode(
user_id=flow.user_id,
client_id=flow.client_id,
redirect_uri=flow.redirect_uri,
code_challenge=flow.code_challenge,
jti=secrets.token_urlsafe(24),
iat=int(now.timestamp()),
exp=int(now.timestamp()) + GATEWAY_AUTH_CODE_TTL_SECONDS,
),
)
params = {"code": code, **({"state": flow.state} if flow.state else {})}
response = RedirectResponse(_append_query_params(flow.redirect_uri, params), status_code=303)
path, secure = _cookie_path_and_secure(request)
response.delete_cookie(key=_flow_cookie_name(flow_handle), path=path, secure=secure, httponly=True, samesite="lax")
return response
def _pkce_verifier_matches(code_verifier: str, code_challenge: str) -> bool:
digest = hashlib.sha256(code_verifier.encode("ascii", "replace")).digest()
computed = urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
return hmac.compare_digest(computed, code_challenge)
class _SingleUseGuard:
"""Best-effort single-use marking for gateway authorization codes over the injected
proxy cache (in-memory always, Redis when the deployment wires it, in which case the
guard holds across replicas). The code's 120s TTL is the hard bound either way; the
guard exists so a same-process or shared-cache replay fails ``invalid_grant``."""
def __init__(self, cache: DualCache) -> None:
self._cache = cache
async def already_used(self, jti: str) -> bool:
return await self._cache.async_get_cache(f"{_USED_CODE_CACHE_PREFIX}{jti}") is not None
async def mark_used(self, jti: str) -> None:
await self._cache.async_set_cache(
f"{_USED_CODE_CACHE_PREFIX}{jti}", "1", ttl=GATEWAY_AUTH_CODE_TTL_SECONDS + 60
)
def _session_token_pair(principal: SessionPrincipal, keys: SessionKeys, now: datetime) -> Response:
access = mint_session_token(principal, keys, now)
refresh = mint_session_refresh_token(principal, keys, now)
if not isinstance(access, MintedSessionToken) or not isinstance(refresh, MintedSessionToken):
return _oauth_error(500, "server_error", "failed to mint the session credential")
return JSONResponse(
status_code=200,
content={
"access_token": access.token.get_secret_value(),
"token_type": "Bearer",
"expires_in": int((access.expires_at - now).total_seconds()),
"refresh_token": refresh.token.get_secret_value(),
},
headers=TOKEN_NO_CACHE_HEADERS,
)
def _reload_failure_response(failure: ReloadUserFailure) -> Response:
if failure == "unavailable":
return _oauth_error(503, "temporarily_unavailable", "the gateway database is unavailable; retry")
if failure == "unresolvable":
return _oauth_error(500, "server_error", "the gateway is not configured to resolve users")
return _oauth_error(400, "invalid_grant", "the user for this grant is no longer active")
async def aggregate_token(
request: Request,
grant_type: str,
code: str | None,
redirect_uri: str | None,
client_id: str,
code_verifier: str | None,
refresh_token: str | None,
master_key: str | None,
reload_user: ReloadUser,
cache: DualCache,
) -> Response:
"""The aggregate token verb: authorization_code and refresh_token grants for the
identity-only session pair. Every path re-validates the litellm user live before
minting, so a deactivated user cannot obtain or renew a session."""
if master_key is None:
verbose_logger.error("mcp_gateway_dcr token grant rejected: no master_key configured")
return _oauth_error(500, "server_error", "the gateway has no master key configured")
keys = session_keys_from_master_key(master_key)
now = datetime.now(timezone.utc)
if grant_type == "authorization_code":
return await _authorization_code_grant(
code=code,
redirect_uri=redirect_uri,
client_id=client_id,
code_verifier=code_verifier,
keys=keys,
now=now,
reload_user=reload_user,
guard=_SingleUseGuard(cache),
)
if grant_type == "refresh_token":
return await _refresh_token_grant(
refresh_token=refresh_token,
client_id=client_id,
keys=keys,
now=now,
reload_user=reload_user,
)
return _oauth_error(400, "unsupported_grant_type", "grant_type must be authorization_code or refresh_token")
async def _authorization_code_grant(
code: str | None,
redirect_uri: str | None,
client_id: str,
code_verifier: str | None,
keys: SessionKeys,
now: datetime,
reload_user: ReloadUser,
guard: _SingleUseGuard,
) -> Response:
if not code or not redirect_uri or not code_verifier:
return _oauth_error(400, "invalid_request", "code, redirect_uri, and code_verifier are required")
parsed = _open_sealed(code, GATEWAY_AUTH_CODE_PREFIX, _GatewayAuthCode, _AUTH_CODE_DEBUG_KEY)
if parsed is None:
return _oauth_error(400, "invalid_grant", "the authorization code is invalid")
if now.timestamp() >= parsed.exp:
return _oauth_error(400, "invalid_grant", "the authorization code has expired")
if client_id != parsed.client_id or redirect_uri != parsed.redirect_uri:
return _oauth_error(400, "invalid_grant", "the authorization code was issued to a different client")
if not _pkce_verifier_matches(code_verifier, parsed.code_challenge):
return _oauth_error(400, "invalid_grant", "PKCE verification failed")
if await guard.already_used(parsed.jti):
return _oauth_error(400, "invalid_grant", "the authorization code was already used")
await guard.mark_used(parsed.jti)
failure = await reload_user(parsed.user_id)
if failure is not None:
return _reload_failure_response(failure)
return _session_token_pair(SessionPrincipal(user_id=parsed.user_id, client_id=client_id), keys, now)
async def _refresh_token_grant(
refresh_token: str | None,
client_id: str,
keys: SessionKeys,
now: datetime,
reload_user: ReloadUser,
) -> Response:
if not refresh_token:
return _oauth_error(400, "invalid_request", "refresh_token is required")
opened = open_session_refresh_bearer(refresh_token, keys, now, expected_client_id=client_id)
if not isinstance(opened, SessionRefreshOpened):
return _oauth_error(400, "invalid_grant", "the refresh token is invalid for this client")
failure = await reload_user(opened.principal.user_id)
if failure is not None:
return _reload_failure_response(failure)
return _session_token_pair(opened.principal, keys, now)

View file

@ -950,7 +950,7 @@ async def google_login(
request=request,
)
if return_to is not None and sso_redirect is not None:
if SSOAuthenticationHandler._validate_return_to(return_to):
if _is_same_origin_return_path(return_to) or SSOAuthenticationHandler._validate_return_to(return_to):
sso_redirect.set_cookie(
key="litellm_cp_return_to",
value=return_to,
@ -2419,6 +2419,15 @@ async def sso_readiness():
)
def _is_same_origin_return_path(return_to: str) -> bool:
"""True for a strictly relative return path (starts with ``/``, not
protocol-relative ``//``, no backslash tricks browsers normalize to slashes), which
stays on the gateway's own origin by construction and is therefore safe to honor
without a configured ``control_plane_url``. Used by the MCP gateway DCR authorize
round-trip so a browser sent through login lands back on the authorize request."""
return return_to.startswith("/") and not return_to.startswith("//") and "\\" not in return_to
class SSOAuthenticationHandler:
"""
Handler for SSO Authentication across all SSO providers
@ -3055,7 +3064,6 @@ class SSOAuthenticationHandler:
jwt_handler: Optional[JWTHandler] = None,
return_to: Optional[str] = None,
) -> RedirectResponse:
import jwt
from litellm.proxy.proxy_server import (
general_settings,
@ -3220,6 +3228,15 @@ class SSOAuthenticationHandler:
jwt_token = encode_ui_session_jwt(returned_ui_token_object, master_key or "")
# Same-origin relative return (the MCP gateway DCR authorize round-trip):
# set the session cookie exactly like the dashboard path, then send the
# browser back to where it came from instead of the dashboard.
if return_to is not None and _is_same_origin_return_path(return_to):
redirect_response = RedirectResponse(url=return_to, status_code=303)
redirect_response.set_cookie(key="token", value=jwt_token)
redirect_response.delete_cookie("litellm_cp_return_to")
return redirect_response
# Control-plane cross-origin: store JWT behind a single-use opaque
# code (60s TTL) so the token never appears in browser history / logs.
# The control plane redeems it via POST /v3/login/exchange.

View file

@ -7258,3 +7258,64 @@ async def test_bare_origin_discovery_resolves_single_server_not_aggregate():
assert resource_response["authorization_servers"] == ["https://llm.example.com/test_oauth"]
finally:
global_mcp_server_manager.registry.clear()
def test_gateway_dcr_flow_routing_engages_only_for_llm_dcrc_clients(monkeypatch):
"""The aggregate DCR arms engage for llm_dcrc_ client_ids (register always mints one,
authorize/token route into the aggregate flow); a non-gateway client_id keeps the
per-server behavior, and /authorize/complete exists but 400s without a valid flow."""
from fastapi import FastAPI
from fastapi.testclient import TestClient
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import router
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-test-salt-for-lit3637")
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", "sk-test-salt-for-lit3637", raising=False)
global_mcp_server_manager.registry.clear()
app = FastAPI()
app.include_router(router)
client = TestClient(app)
registered = client.post("/register", json={"redirect_uris": ["https://claude.ai/cb"]})
assert registered.status_code == 201
assert registered.json()["client_id"].startswith("llm_dcrc_")
assert registered.json()["token_endpoint_auth_method"] == "none"
authorize_params = {
"client_id": "llm_dcrc_bogus",
"redirect_uri": "https://claude.ai/cb",
"response_type": "code",
"code_challenge": "c" * 43,
"code_challenge_method": "S256",
}
bogus_client = client.get("/authorize", params=authorize_params)
assert bogus_client.status_code == 400
assert bogus_client.json()["error"] == "invalid_client"
no_cookie = client.post("/authorize/complete", data={"flow": "h"})
assert no_cookie.status_code == 400
assert no_cookie.json()["error"] == "invalid_request"
token_response = client.post(
"/token",
data={
"grant_type": "authorization_code",
"client_id": "llm_dcrc_bogus",
"code": "x",
"redirect_uri": "https://claude.ai/cb",
"code_verifier": "v" * 43,
},
)
assert token_response.status_code == 400
assert token_response.json()["error"] == "invalid_grant"
# a non-gateway (upstream-issued) client_id is not routed into the aggregate arm; it
# falls to the per-server exchange, which 404s for an unknown server
upstream_shaped = client.post(
"/token",
data={"grant_type": "authorization_code", "client_id": "regular-upstream-client", "code": "x"},
)
assert upstream_shaped.status_code == 404

View file

@ -0,0 +1,379 @@
"""Tests for the aggregate gateway DCR flow (register, authorize, complete, token)."""
import hashlib
import json
from base64 import urlsafe_b64encode
from datetime import datetime, timedelta, timezone
from http.cookies import SimpleCookie
from urllib.parse import parse_qs, urlparse
import pytest
from starlette.requests import Request
from litellm.caching.caching import DualCache
from litellm.proxy._experimental.mcp_server.gateway_dcr_flow import (
CONNECT_FLOW_COOKIE_PREFIX,
GATEWAY_AUTH_CODE_PREFIX,
GATEWAY_AUTH_CODE_TTL_SECONDS,
GATEWAY_DCR_CLIENT_ID_PREFIX,
_GatewayAuthCode,
_seal,
aggregate_authorize,
aggregate_token,
complete_connect_flow,
is_gateway_dcr_client_id,
open_gateway_dcr_client,
register_aggregate_client,
)
from litellm.proxy._experimental.mcp_server.outbound_credentials.session_credentials import (
resolve_session_bearer,
session_keys_from_master_key,
SessionBearerAdmitted,
)
MASTER_KEY = "sk-gateway-dcr-flow-tests"
REDIRECT_URI = "https://claude.ai/api/mcp/auth_callback"
CODE_VERIFIER = "verifier-" + "v" * 43
CODE_CHALLENGE = urlsafe_b64encode(hashlib.sha256(CODE_VERIFIER.encode("ascii")).digest()).rstrip(b"=").decode("ascii")
@pytest.fixture(autouse=True)
def _salt_key(monkeypatch):
monkeypatch.setenv("LITELLM_SALT_KEY", MASTER_KEY)
def _request(path="/authorize", query="", cookies=None, method="GET"):
cookie_header = []
if cookies:
cookie = SimpleCookie()
for name, value in cookies.items():
cookie[name] = value
cookie_header = [(b"cookie", cookie.output(header="", sep="; ").strip().encode())]
return Request(
{
"type": "http",
"method": method,
"scheme": "https",
"path": path,
"query_string": query.encode(),
"headers": [(b"host", b"llm.example.com"), *cookie_header],
}
)
async def _register(redirect_uris) -> dict:
response = await register_aggregate_client(request=_request("/register"), request_body={"redirect_uris": redirect_uris})
return json.loads(response.body)
async def _reload_user_active(user_id: str):
return None
@pytest.mark.asyncio
async def test_register_mints_stateless_public_client():
body = await _register([REDIRECT_URI])
assert body["token_endpoint_auth_method"] == "none"
assert "client_secret" not in body
assert body["redirect_uris"] == [REDIRECT_URI]
assert is_gateway_dcr_client_id(body["client_id"])
record = open_gateway_dcr_client(body["client_id"])
assert record is not None
assert record.redirect_uris == (REDIRECT_URI,)
@pytest.mark.asyncio
async def test_register_allows_loopback_http_for_dev_clients():
body = await _register(["http://localhost:6274/oauth/callback"])
assert is_gateway_dcr_client_id(body["client_id"])
@pytest.mark.asyncio
@pytest.mark.parametrize(
"redirect_uris",
[
[],
"not-a-list",
["http://evil.example.com/callback"],
["https://claude.ai/cb#fragment"],
["ftp://claude.ai/cb"],
["https://a.example.com/" + "p" * 300],
["https://a.example.com/1", "https://a.example.com/2", "https://a.example.com/3", "https://a.example.com/4"],
[12345],
],
)
async def test_register_rejects_bad_redirect_uris(redirect_uris):
response = await register_aggregate_client(request=_request("/register"), request_body={"redirect_uris": redirect_uris})
assert response.status_code == 400
assert json.loads(response.body)["error"] in ("invalid_redirect_uri", "invalid_client_metadata")
@pytest.mark.asyncio
async def test_tampered_client_id_does_not_open():
body = await _register([REDIRECT_URI])
tampered = body["client_id"][:-4] + "AAAA"
assert open_gateway_dcr_client(tampered) is None
assert open_gateway_dcr_client("llm_dcrc_garbage") is None
assert open_gateway_dcr_client("other_prefix") is None
def _authorize(client_id, session_user_id, redirect_uri=REDIRECT_URI, challenge=CODE_CHALLENGE, method="S256", response_type="code"):
return aggregate_authorize(
request=_request(query=f"client_id={client_id}"),
client_id=client_id,
redirect_uri=redirect_uri,
state="client-state-123",
code_challenge=challenge,
code_challenge_method=method,
response_type=response_type,
session_user_id=session_user_id,
)
@pytest.mark.asyncio
async def test_authorize_validation_failures_never_redirect_to_client():
client_id = (await _register([REDIRECT_URI]))["client_id"]
for response, expected_error in (
(_authorize("llm_dcrc_bogus", "u1"), "invalid_client"),
(_authorize(client_id, "u1", redirect_uri="https://attacker.example.com/cb"), "invalid_request"),
(_authorize(client_id, "u1", response_type="token"), "unsupported_response_type"),
(_authorize(client_id, "u1", challenge=None), "invalid_request"),
(_authorize(client_id, "u1", method="plain"), "invalid_request"),
):
assert response.status_code == 400
assert json.loads(response.body)["error"] == expected_error
@pytest.mark.asyncio
async def test_authorize_without_session_redirects_to_login_with_return_to():
client_id = (await _register([REDIRECT_URI]))["client_id"]
response = _authorize(client_id, session_user_id=None)
assert response.status_code == 303
location = response.headers["location"]
assert location.startswith("https://llm.example.com/sso/key/generate?return_to=")
assert "return_to=%2Fauthorize" in location
@pytest.mark.asyncio
async def test_authorize_with_session_hands_browser_to_connect_page_with_flow_cookie():
client_id = (await _register([REDIRECT_URI]))["client_id"]
response = _authorize(client_id, session_user_id="u1")
assert response.status_code == 303
location = urlparse(response.headers["location"])
assert location.path == "/ui/chat/integrations"
params = parse_qs(location.query)
handle = params["connect_flow"][0]
assert params["connect_client"] == ["https://claude.ai"]
set_cookie = response.headers["set-cookie"]
assert f"{CONNECT_FLOW_COOKIE_PREFIX}{handle}" in set_cookie
assert "HttpOnly" in set_cookie
return handle, set_cookie
def _flow_cookie_from(response) -> tuple:
location = urlparse(response.headers["location"])
handle = parse_qs(location.query)["connect_flow"][0]
cookie = SimpleCookie()
cookie.load(response.headers["set-cookie"])
name = f"{CONNECT_FLOW_COOKIE_PREFIX}{handle}"
return handle, {name: cookie[name].value}
@pytest.mark.asyncio
async def test_full_walk_register_authorize_complete_token_and_replay():
"""The whole front door on one deterministic walk: register -> authorize ->
complete -> token, then the security edges on the same artifacts (user mismatch,
PKCE mismatch, single-use replay, refresh rotation, cross-client refresh)."""
client_id = (await _register([REDIRECT_URI]))["client_id"]
authorize_response = _authorize(client_id, session_user_id="u1")
handle, cookies = _flow_cookie_from(authorize_response)
denied = complete_connect_flow(
request=_request("/authorize/complete", cookies=cookies, method="POST"),
flow_handle=handle,
session_user_id="attacker",
)
assert denied.status_code == 403
anonymous = complete_connect_flow(
request=_request("/authorize/complete", cookies=cookies, method="POST"),
flow_handle=handle,
session_user_id=None,
)
assert anonymous.status_code == 401
completed = complete_connect_flow(
request=_request("/authorize/complete", cookies=cookies, method="POST"),
flow_handle=handle,
session_user_id="u1",
)
assert completed.status_code == 303
redirect = urlparse(completed.headers["location"])
assert f"{redirect.scheme}://{redirect.netloc}{redirect.path}" == REDIRECT_URI
params = parse_qs(redirect.query)
assert params["state"] == ["client-state-123"]
code = params["code"][0]
assert code.startswith(GATEWAY_AUTH_CODE_PREFIX)
cache = DualCache()
async def _token(**overrides):
arguments = {
"request": _request("/token", method="POST"),
"grant_type": "authorization_code",
"code": code,
"redirect_uri": REDIRECT_URI,
"client_id": client_id,
"code_verifier": CODE_VERIFIER,
"refresh_token": None,
"master_key": MASTER_KEY,
"reload_user": _reload_user_active,
"cache": cache,
}
return await aggregate_token(**{**arguments, **overrides})
wrong_verifier = await _token(code_verifier="wrong-" + "w" * 43)
assert json.loads(wrong_verifier.body)["error"] == "invalid_grant"
wrong_client = await _token(client_id=(await _register([REDIRECT_URI]))["client_id"])
assert json.loads(wrong_client.body)["error"] == "invalid_grant"
token_response = await _token()
assert token_response.status_code == 200
payload = json.loads(token_response.body)
assert payload["token_type"] == "Bearer"
assert 0 < payload["expires_in"] <= 3600
keys = session_keys_from_master_key(MASTER_KEY)
admitted = resolve_session_bearer(f"Bearer {payload['access_token']}", keys, datetime.now(timezone.utc))
assert isinstance(admitted, SessionBearerAdmitted)
assert admitted.principal.user_id == "u1"
assert admitted.principal.client_id == client_id
replay = await _token()
assert json.loads(replay.body)["error"] == "invalid_grant"
refreshed = await _token(grant_type="refresh_token", code=None, refresh_token=payload["refresh_token"])
assert refreshed.status_code == 200
rotated = json.loads(refreshed.body)
assert rotated["refresh_token"] != payload["refresh_token"]
cross_client = await _token(
grant_type="refresh_token",
code=None,
refresh_token=payload["refresh_token"],
client_id=(await _register([REDIRECT_URI]))["client_id"],
)
assert json.loads(cross_client.body)["error"] == "invalid_grant"
@pytest.mark.asyncio
async def test_complete_rejects_missing_tampered_and_expired_flows():
missing = complete_connect_flow(
request=_request("/authorize/complete", method="POST"), flow_handle="nope", session_user_id="u1"
)
assert missing.status_code == 400
tampered = complete_connect_flow(
request=_request("/authorize/complete", cookies={f"{CONNECT_FLOW_COOKIE_PREFIX}h1": "garbage"}, method="POST"),
flow_handle="h1",
session_user_id="u1",
)
assert tampered.status_code == 400
@pytest.mark.asyncio
async def test_token_rejects_expired_code_and_missing_configuration():
expired_code = _seal(
GATEWAY_AUTH_CODE_PREFIX,
_GatewayAuthCode(
user_id="u1",
client_id="llm_dcrc_x",
redirect_uri=REDIRECT_URI,
code_challenge=CODE_CHALLENGE,
jti="jti-1",
iat=int((datetime.now(timezone.utc) - timedelta(seconds=500)).timestamp()),
exp=int((datetime.now(timezone.utc) - timedelta(seconds=500 - GATEWAY_AUTH_CODE_TTL_SECONDS)).timestamp()),
),
)
response = await aggregate_token(
request=_request("/token", method="POST"),
grant_type="authorization_code",
code=expired_code,
redirect_uri=REDIRECT_URI,
client_id="llm_dcrc_x",
code_verifier=CODE_VERIFIER,
refresh_token=None,
master_key=MASTER_KEY,
reload_user=_reload_user_active,
cache=DualCache(),
)
assert json.loads(response.body)["error"] == "invalid_grant"
no_master_key = await aggregate_token(
request=_request("/token", method="POST"),
grant_type="authorization_code",
code="llm_gcode_x",
redirect_uri=REDIRECT_URI,
client_id="llm_dcrc_x",
code_verifier=CODE_VERIFIER,
refresh_token=None,
master_key=None,
reload_user=_reload_user_active,
cache=DualCache(),
)
assert no_master_key.status_code == 500
assert json.loads(no_master_key.body)["error"] == "server_error"
unsupported = await aggregate_token(
request=_request("/token", method="POST"),
grant_type="password",
code=None,
redirect_uri=None,
client_id="llm_dcrc_x",
code_verifier=None,
refresh_token=None,
master_key=MASTER_KEY,
reload_user=_reload_user_active,
cache=DualCache(),
)
assert json.loads(unsupported.body)["error"] == "unsupported_grant_type"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"failure,expected_status,expected_error",
[
("no_active_key", 400, "invalid_grant"),
("unavailable", 503, "temporarily_unavailable"),
("unresolvable", 500, "server_error"),
],
)
async def test_token_gates_on_live_user_revalidation(failure, expected_status, expected_error):
client_id = (await _register([REDIRECT_URI]))["client_id"]
authorize_response = _authorize(client_id, session_user_id="deactivated-user")
handle, cookies = _flow_cookie_from(authorize_response)
completed = complete_connect_flow(
request=_request("/authorize/complete", cookies=cookies, method="POST"),
flow_handle=handle,
session_user_id="deactivated-user",
)
code = parse_qs(urlparse(completed.headers["location"]).query)["code"][0]
async def _reload_user_failing(user_id: str):
return failure
response = await aggregate_token(
request=_request("/token", method="POST"),
grant_type="authorization_code",
code=code,
redirect_uri=REDIRECT_URI,
client_id=client_id,
code_verifier=CODE_VERIFIER,
refresh_token=None,
master_key=MASTER_KEY,
reload_user=_reload_user_failing,
cache=DualCache(),
)
assert response.status_code == expected_status
assert json.loads(response.body)["error"] == expected_error

View file

@ -7391,3 +7391,24 @@ async def test_auth_callback_without_oauth_error_proceeds_to_normal_flow():
assert exc_info.value.status_code == 500
assert "DB not connected" in str(exc_info.value.detail)
class TestSameOriginReturnPath:
"""The same-origin relative return_to arm added for the MCP gateway DCR authorize
round-trip: only strictly relative paths qualify, so login can never redirect the
browser off the gateway origin."""
def test_accepts_relative_paths(self):
from litellm.proxy.management_endpoints.ui_sso import _is_same_origin_return_path
assert _is_same_origin_return_path("/authorize?client_id=llm_dcrc_x&state=s") is True
assert _is_same_origin_return_path("/some_server/authorize") is True
def test_rejects_absolute_protocol_relative_and_backslash_paths(self):
from litellm.proxy.management_endpoints.ui_sso import _is_same_origin_return_path
assert _is_same_origin_return_path("https://evil.example.com/authorize") is False
assert _is_same_origin_return_path("//evil.example.com/authorize") is False
assert _is_same_origin_return_path("/\\evil.example.com") is False
assert _is_same_origin_return_path("javascript:alert(1)") is False
assert _is_same_origin_return_path("") is False