Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 59 additions & 15 deletions litellm/proxy/auth/user_api_key_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -2030,24 +2030,18 @@ def _should_skip_budget_checks(
return False


@tracer.wrap()
async def user_api_key_auth(
async def run_user_api_key_auth_pipeline(
request: Request,
api_key: str = fastapi.Security(api_key_header),
azure_api_key_header: str = fastapi.Security(azure_api_key_header),
anthropic_api_key_header: Optional[str] = fastapi.Security(
anthropic_api_key_header
),
google_ai_studio_api_key_header: Optional[str] = fastapi.Security(
google_ai_studio_api_key_header
),
azure_apim_header: Optional[str] = fastapi.Security(azure_apim_header),
custom_litellm_key_header: Optional[str] = fastapi.Security(
custom_litellm_key_header
),
api_key: str,
azure_api_key_header: str,
anthropic_api_key_header: Optional[str],
google_ai_studio_api_key_header: Optional[str],
azure_apim_header: Optional[str],
custom_litellm_key_header: Optional[str],
) -> UserAPIKeyAuth:
"""
Parent function to authenticate user api key / jwt token.
Shared implementation for ``user_api_key_auth`` and for call sites that must
run the same auth pipeline without FastAPI ``Security()`` injection.
"""

request_data = await _read_request_body(request=request)
Expand Down Expand Up @@ -2104,6 +2098,56 @@ async def user_api_key_auth(
return user_api_key_auth_obj


async def user_api_key_auth_from_request_headers(request: Request) -> UserAPIKeyAuth:
"""
Run the same auth as ``Depends(user_api_key_auth)`` using headers on ``request``.

Used when a route cannot use the FastAPI dependency (e.g. MCP OAuth broker
``/authorize`` / ``/token`` resolving optional ``Authorization``).
"""
h = request.headers
return await run_user_api_key_auth_pipeline(
request=request,
api_key=h.get("authorization") or "",
azure_api_key_header=h.get("api-key") or "",
anthropic_api_key_header=h.get("x-api-key"),
google_ai_studio_api_key_header=h.get("x-goog-api-key"),
azure_apim_header=h.get("ocp-apim-subscription-key"),
custom_litellm_key_header=h.get("x-litellm-api-key"),
)


@tracer.wrap()
async def user_api_key_auth(
request: Request,
api_key: str = fastapi.Security(api_key_header),
azure_api_key_header: str = fastapi.Security(azure_api_key_header),
anthropic_api_key_header: Optional[str] = fastapi.Security(
anthropic_api_key_header
),
google_ai_studio_api_key_header: Optional[str] = fastapi.Security(
google_ai_studio_api_key_header
),
azure_apim_header: Optional[str] = fastapi.Security(azure_apim_header),
custom_litellm_key_header: Optional[str] = fastapi.Security(
custom_litellm_key_header
),
) -> UserAPIKeyAuth:
"""
Parent function to authenticate user api key / jwt token.
"""

return await run_user_api_key_auth_pipeline(
request=request,
api_key=api_key,
azure_api_key_header=azure_api_key_header,
anthropic_api_key_header=anthropic_api_key_header,
google_ai_studio_api_key_header=google_ai_studio_api_key_header,
azure_apim_header=azure_apim_header,
custom_litellm_key_header=custom_litellm_key_header,
)


async def _return_user_api_key_auth_obj(
user_obj: Optional[LiteLLM_UserTable],
api_key: str,
Expand Down
98 changes: 85 additions & 13 deletions litellm/proxy/management_endpoints/mcp_management_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ def validate_tool_name(name: str) -> _ToolNameValidationResult: # type: ignore[
MCPUserCredentialResponse,
NewMCPServerRequest,
RejectMCPServerRequest,
SpecialHeaders,
SpecialMCPServerName,
UpdateMCPServerRequest,
UserAPIKeyAuth,
Expand Down Expand Up @@ -1492,9 +1493,57 @@ async def add_session_mcp_server(

return _redact_mcp_credentials(temp_record)

async def _try_resolve_mcp_oauth_broker_user(
request: Request,
) -> Optional[UserAPIKeyAuth]:
"""
Optional proxy credentials for ``/authorize`` and ``/token``.

When absent, unauthenticated access is still allowed for **temp-cache**
servers only (browser OAuth). When present, global-registry access
follows admin / allowlist rules via ``_get_cached_temporary_mcp_server_or_404``.

Only non-empty **string** values in any recognised auth header trigger a
full auth pipeline import (tests and mocks may attach MagicMock headers).
The recognised headers match those checked by
``user_api_key_auth_from_request_headers``: ``Authorization``,
``API-Key``, ``x-api-key``, ``x-goog-api-key``,
``Ocp-Apim-Subscription-Key``, and ``x-litellm-api-key``.
"""
try:
headers = request.headers
except Exception:
return None
raw: object = None
for header_name in (
SpecialHeaders.openai_authorization.value,
SpecialHeaders.azure_authorization.value,
SpecialHeaders.anthropic_authorization.value,
SpecialHeaders.google_ai_studio_authorization.value,
SpecialHeaders.azure_apim_authorization.value,
SpecialHeaders.custom_litellm_api_key.value,
):
for key in (header_name, header_name.lower()):
try:
candidate = headers.get(key)
except Exception:
candidate = None
if isinstance(candidate, str) and candidate.strip():
raw = candidate
break
if isinstance(raw, str):
break
Comment thread
Sameerlite marked this conversation as resolved.
if not isinstance(raw, str) or not raw.strip():
return None
from litellm.proxy.auth.user_api_key_auth import (
user_api_key_auth_from_request_headers,
)

return await user_api_key_auth_from_request_headers(request)

async def _get_cached_temporary_mcp_server_or_404(
server_id: str,
user_api_key_dict: UserAPIKeyAuth,
user_api_key_dict: Optional[UserAPIKeyAuth] = None,
request: Optional[Request] = None,
) -> MCPServer:
server = await get_cached_temporary_mcp_server(server_id)
Expand All @@ -1516,12 +1565,37 @@ async def _get_cached_temporary_mcp_server_or_404(
detail={"error": f"MCP server {server_id} not found"},
)

# Per-server access policy mirrors `fetch_mcp_server`: admin-view
# callers are unrestricted; non-admins must have the server in their
# allowed-servers set. Temporary cached servers come from the
# admin-only `/server/oauth/session` setup flow and are not exposed
# to non-admins.
if not _user_has_admin_view(user_api_key_dict):
# Access-control for the OAuth broker endpoints.
#
# Unauthenticated callers (browser-initiated OAuth, no API key):
# - Temp-cache servers: allowed. These are created by admins via the
# admin-only /server/oauth/session endpoint specifically to drive
# this browser flow. The LiteLLM UI always creates a temp session
# before calling /authorize, so all legitimate browser flows use a
# temp server_id.
# - Global-registry servers: rejected (403). Allowing unauthenticated
# access to global-registry servers would let any caller invoke the
# proxy's OAuth broker with the server's stored client_secret, while
# authenticated non-admins without allowlist access receive 403 —
# an unintended privilege inversion.
#
# Authenticated callers:
# - Admins: unrestricted.
# - Non-admins: temp servers are always denied (temp sessions are
# admin-internal); global servers require allowlist membership.
if user_api_key_dict is None:
if not resolved_from_temp_cache:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"error": (
"Unauthenticated access to global-registry MCP server "
f"{server_id} is not permitted. "
"Pass a valid API key or use a session-scoped server ID."
)
},
)
elif not _user_has_admin_view(user_api_key_dict):
Comment thread
Sameerlite marked this conversation as resolved.
if resolved_from_temp_cache:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
Expand All @@ -1542,12 +1616,10 @@ async def _get_cached_temporary_mcp_server_or_404(
@router.get(
"/server/oauth/{server_id}/authorize",
include_in_schema=False,
dependencies=[Depends(user_api_key_auth)],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like an insecure workaround, will message on Slack

)
async def mcp_authorize(
request: Request,
server_id: str,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
client_id: Optional[str] = None,
redirect_uri: str = Query(...),
state: str = "",
Expand All @@ -1556,8 +1628,9 @@ async def mcp_authorize(
response_type: Optional[str] = None,
scope: Optional[str] = None,
):
user_api_key_dict = await _try_resolve_mcp_oauth_broker_user(request)
mcp_server = await _get_cached_temporary_mcp_server_or_404(
server_id, user_api_key_dict, request=request
server_id, user_api_key_dict=user_api_key_dict, request=request
)
# Use the server's stored client_id when the caller doesn't supply one
resolved_client_id = mcp_server.client_id or client_id or ""
Expand Down Expand Up @@ -1587,12 +1660,10 @@ async def mcp_authorize(
@router.post(
"/server/oauth/{server_id}/token",
include_in_schema=False,
dependencies=[Depends(user_api_key_auth)],
)
async def mcp_token(
request: Request,
server_id: str,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
grant_type: str = Form(...),
code: Optional[str] = Form(None),
redirect_uri: Optional[str] = Form(None),
Expand All @@ -1602,8 +1673,9 @@ async def mcp_token(
refresh_token: Optional[str] = Form(None),
scope: Optional[str] = Form(None),
):
user_api_key_dict = await _try_resolve_mcp_oauth_broker_user(request)
mcp_server = await _get_cached_temporary_mcp_server_or_404(
server_id, user_api_key_dict, request=request
server_id, user_api_key_dict=user_api_key_dict, request=request
)
resolved_client_id = mcp_server.client_id or client_id or ""
if not resolved_client_id:
Expand Down
Loading
Loading