Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py: 11%
427 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
1"""Bridge token flow: litellm identity resolution and the DCR-bridge oauth_delegate mint/refresh pipeline."""
3import math
4import os
5import secrets
6from dataclasses import dataclass
7from datetime import datetime, timezone
8from typing import TYPE_CHECKING, Final, Literal
10from fastapi import HTTPException, Request
11from fastapi.responses import JSONResponse
12from pydantic import SecretStr
13from typing_extensions import assert_never
15from litellm._logging import verbose_logger
16from litellm.proxy._experimental.mcp_server.oauth_utils import TOKEN_NO_CACHE_HEADERS
17from litellm.proxy.common_utils.encrypt_decrypt_utils import (
18 _V2_GCM_PREFIX, # pyright: ignore[reportPrivateUsage] # reuse the encrypted credential's format discriminator
19)
20from litellm.types.mcp_server.mcp_server_manager import MCPServer
22if TYPE_CHECKING: 22 ↛ 23line 22 didn't jump to line 23 because the condition on line 22 was never true
23 from litellm.models.user import LiteLLM_UserTable
24 from litellm.proxy._experimental.mcp_server.discoverable_endpoints import _BridgeAuthorizationCode
25 from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import (
26 EnvelopeIdentity,
27 EnvelopeKeys,
28 RefreshCredential,
29 UpstreamTokenGrant,
30 )
31 from litellm.proxy._types import UserAPIKeyAuth
32 from litellm.proxy.auth.handle_jwt import JWTIdentity
35def _litellm_key_from_request(request: Request) -> str | None:
36 """Return the LiteLLM API key presented on the request, or ``None``.
38 Accepts the key from ``x-litellm-api-key`` (what MCP clients such as Claude Desktop/Code
39 send) as well as ``Authorization``; either may carry a bare token or ``Bearer <token>``.
40 ``x-litellm-api-key`` wins when both are present, since ``Authorization`` may instead carry
41 an OAuth/upstream bearer.
42 """
43 for header_value in (
44 request.headers.get("x-litellm-api-key"),
45 request.headers.get("Authorization") or request.headers.get("authorization"),
46 ):
47 if not header_value:
48 continue
49 value = header_value.strip()
50 if value.lower().startswith("bearer "):
51 value = value[7:].strip()
52 if value:
53 return value
54 return None
57async def oauth_authorization_uses_gateway_credential(request: Request) -> bool:
58 """Classify credentials for browser authorize; candidates still require full authorization."""
59 from litellm.proxy.auth.handle_jwt import JWTHandler # noqa: PLC0415 # proxy import cycle
60 from litellm.proxy.proxy_server import ( # noqa: PLC0415 # startup owns the active auth configuration
61 jwt_handler,
62 master_key,
63 user_custom_auth,
64 )
66 if "x-litellm-api-key" in request.headers:
67 return True
68 token: Final = _litellm_key_from_request(request)
69 if token is None:
70 return "authorization" in request.headers
71 if token.startswith("sk-") or (master_key and secrets.compare_digest(token.encode(), master_key.encode())):
72 return True
73 if user_custom_auth is not None or jwt_handler.litellm_jwtauth.oidc_userinfo_enabled:
74 return True
75 if not JWTHandler.is_jwt(token):
76 return await _opaque_bearer_is_gateway_credential(token)
77 claims: Final = JWTHandler.get_unverified_claims(token)
78 issuer: Final = claims.get("iss") if claims is not None else None
79 global_issuer: Final = os.getenv("JWT_ISSUER")
80 # An unscoped global validator can accept issuers absent from the configured issuer list.
81 if not isinstance(issuer, str) or not issuer or not global_issuer:
82 return True
83 return issuer == global_issuer or any(
84 issuer == configured.issuer for configured in jwt_handler.litellm_jwtauth.issuers or ()
85 )
88async def _opaque_bearer_is_gateway_credential(token: str) -> bool:
89 from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( # noqa: PLC0415 # envelope imports bridge types
90 is_envelope,
91 is_refresh_envelope,
92 )
93 from litellm.proxy._types import hash_token # noqa: PLC0415 # proxy import cycle
94 from litellm.proxy.auth.auth_checks import ExperimentalUIJWTToken # noqa: PLC0415 # proxy import cycle
95 from litellm.proxy.auth.resolvers.exceptions import KeyNotFoundError # noqa: PLC0415 # proxy import cycle
96 from litellm.proxy.auth.resolvers.store import IdentityStore # noqa: PLC0415 # proxy import cycle
97 from litellm.proxy.proxy_server import ( # noqa: PLC0415 # startup owns the identity store dependencies
98 prisma_client,
99 user_api_key_cache,
100 )
102 if is_envelope(token) or is_refresh_envelope(token) or token.startswith(_V2_GCM_PREFIX):
103 return True
104 try:
105 if ExperimentalUIJWTToken.get_key_object_from_ui_hash_key(token) is not None:
106 return True
107 await IdentityStore(prisma_client, user_api_key_cache).resolve(hashed_token=hash_token(token))
108 except KeyNotFoundError:
109 return False
110 except Exception as exc: # noqa: BLE001 # an identity lookup fault must not permit cookie fallback
111 verbose_logger.debug("OAuth bearer ownership could not be checked (%s)", type(exc).__name__)
112 return True
115def _key_is_active(key_obj: "UserAPIKeyAuth") -> bool:
116 """``True`` when the presented key is neither blocked nor past its expiry.
118 The OAuth token endpoint is unauthenticated, so the presented key is validated here before it is
119 trusted; a revoked or expired key must not mint a bridge envelope or write a stored credential.
120 ``get_key_object`` resolves a row without these checks (the main ``user_api_key_auth`` pipeline
121 enforces them downstream, which this endpoint bypasses), so they are applied here. Deleted keys
122 are already rejected upstream, where ``get_key_object`` raises on a row that no longer exists.
124 This is an active-state gate only; it deliberately does not require a ``user_id``. A valid
125 team-scoped or service-account key has no ``user_id`` yet is a legitimate credential, so gating
126 on ``user_id`` presence would wrongly reject it. Callers that need the user (the per-user token
127 store) derive it separately via :func:`_active_key_user_id`.
129 Total by design: ``expires`` is typed ``str | datetime``, and an unparseable string would make
130 ``datetime.fromisoformat`` raise. Since the callers run this outside their key-resolution
131 ``try``, an uncaught parse error would surface as a 500 instead of the endpoint's fail-closed
132 behavior, so a malformed expiry is treated as inactive (return ``False``) rather than raising.
133 """
134 if key_obj.blocked is True:
135 return False
136 expires: Final = key_obj.expires
137 if expires is not None:
138 if isinstance(expires, datetime):
139 expiry = expires
140 else:
141 try:
142 expiry = datetime.fromisoformat(expires)
143 except (ValueError, TypeError):
144 return False
145 if expiry.tzinfo is None or expiry.tzinfo.utcoffset(expiry) is None:
146 expiry = expiry.replace(tzinfo=timezone.utc)
147 if expiry < datetime.now(timezone.utc):
148 return False
149 return True
152def _active_key_user_id(key_obj: "UserAPIKeyAuth") -> str | None:
153 """The active key's ``user_id``, or ``None`` when the key is blocked/expired or simply has no
154 ``user_id`` (a team-scoped or service-account key). Used only by the per-user token store, which
155 needs a user to key the stored credential; the bridge mint uses the key hash and does not."""
156 return key_obj.user_id if _key_is_active(key_obj) else None
159@dataclass(frozen=True, slots=True)
160class _ResolvedKey:
161 """An active litellm key resolved from the token request: its hash (the value ``get_key_object``
162 and the cache/DB layer key the record by) and the live record."""
164 key_hash: str
165 key: "UserAPIKeyAuth"
168_KeyResolutionFailure = Literal["no_active_key", "unavailable", "faulted", "unresolvable"]
169"""Why a token request yielded no active litellm key, kept distinct so a caller statuses each truthfully
170instead of blaming the client for a gateway problem:
171- ``no_active_key``: none was presented, or the presented key is unknown / blocked / expired (the
172 caller's request is at fault)
173- ``unavailable``: the auth database was transiently unreachable while resolving (retryable)
174- ``faulted``: the auth database's query engine reported a fault that retrying will not clear (still a
175 503, but the wording must not tell the operator to wait)
176- ``unresolvable``: the gateway cannot resolve identity right now (no DB connection, or an unexpected
177 error) -- a gateway fault, not the caller's
178The classification mirrors admission's ``_reload_admitted_key`` so the mint (ingress) and admission
179(egress) never disagree on the status of the same outage."""
182def _database_failure(exc: Exception) -> Literal["unavailable", "faulted"]:
183 from litellm.proxy.db.exception_handler import ( # noqa: PLC0415 # inline import avoids a module-load circular import
184 PrismaDBExceptionHandler,
185 )
187 fault: Final = PrismaDBExceptionHandler.find_database_service_unavailable_error_in_chain(exc) or exc
188 return "faulted" if PrismaDBExceptionHandler.is_permanent_database_fault(fault) else "unavailable"
191async def _resolve_active_litellm_key(request: Request) -> "_ResolvedKey | _KeyResolutionFailure":
192 """Resolve the presented litellm key to an active key record, or say precisely why not.
194 Single resolution path the OAuth token endpoint reuses, resolving authoritatively via
195 ``get_key_object`` (cache first, then DB). The failure is a value, not a bare ``None``, so a caller
196 can tell "the client sent no usable credential" (a request error) apart from "the gateway could not
197 check" (an infrastructure error) and status each truthfully; collapsing both to ``None`` is what let
198 a DB outage read as a 400. A resolved key is still gated by ``_key_is_active``, so a blocked or
199 expired key is ``no_active_key`` while a valid team-scoped or service-account key (no ``user_id``)
200 resolves. Classification mirrors admission's ``_reload_admitted_key``: no DB connection is a gateway
201 fault, a ``ProxyException`` / ``HTTPException`` from ``get_key_object`` is an unknown or invalid key,
202 a database-service-unavailable error is a retryable outage, and anything else is an unexpected
203 gateway fault."""
204 token: Final = _litellm_key_from_request(request)
205 if not token:
206 return "no_active_key"
207 from litellm.proxy._types import hash_token # noqa: PLC0415 # inline import avoids a module-load circular import
209 return await _reload_active_key_by_hash(hash_token(token))
212def master_key_admin_auth(key_hash: str) -> "UserAPIKeyAuth | None":
213 from litellm.constants import ( # noqa: PLC0415 # inline import avoids a module-load circular import
214 LITELLM_PROXY_MASTER_KEY_ALIAS,
215 )
216 from litellm.proxy._types import ( # noqa: PLC0415 # inline import avoids a module-load circular import
217 LitellmUserRoles,
218 UserAPIKeyAuth,
219 hash_token,
220 )
221 from litellm.proxy.proxy_server import ( # noqa: PLC0415 # inline import avoids a module-load circular import
222 litellm_proxy_admin_name,
223 master_key,
224 )
226 if not master_key or not secrets.compare_digest(key_hash, hash_token(master_key)):
227 return None
228 auth: Final = UserAPIKeyAuth(
229 api_key=LITELLM_PROXY_MASTER_KEY_ALIAS,
230 user_role=LitellmUserRoles.PROXY_ADMIN,
231 user_id=litellm_proxy_admin_name,
232 )
233 auth.via_virtual_key = True
234 return auth
237async def _reload_active_key_by_hash(key_hash: str) -> "_ResolvedKey | _KeyResolutionFailure":
238 """Reload the live key record for ``key_hash`` (cache first, then DB) and gate it on active state,
239 returning the resolved key or a precise failure. Shared by the token request's presented-key
240 resolution (:func:`_resolve_active_litellm_key`, which hashes the presented key) and the refresh
241 path (which already holds the hash sealed in the refresh envelope), so both re-validate identity
242 through one active-key gate and one failure classification. Classification mirrors admission's
243 ``_reload_admitted_key``: no DB connection is a gateway fault, a ``ProxyException`` / ``HTTPException``
244 from ``get_key_object`` is an unknown or invalid key, a database-service-unavailable error is a
245 retryable outage, and anything else is an unexpected gateway fault. A blocked or expired key is
246 ``no_active_key``, so a revoked key can neither mint nor refresh a bridge envelope."""
247 from litellm.proxy._types import (
248 ProxyException, # noqa: PLC0415 # inline import avoids a module-load circular import
249 )
250 from litellm.proxy.auth.auth_checks import ( # noqa: PLC0415 # inline import avoids a module-load circular import
251 get_key_object,
252 )
253 from litellm.proxy.db.exception_handler import ( # noqa: PLC0415 # inline import avoids a module-load circular import
254 PrismaDBExceptionHandler,
255 )
256 from litellm.proxy.proxy_server import ( # noqa: PLC0415 # inline import avoids a module-load circular import
257 prisma_client,
258 user_api_key_cache,
259 )
261 if (admin := master_key_admin_auth(key_hash)) is not None:
262 return _ResolvedKey(key_hash=key_hash, key=admin)
263 if prisma_client is None:
264 return "unresolvable"
265 try:
266 key_obj: Final = await get_key_object(
267 hashed_token=key_hash,
268 prisma_client=prisma_client,
269 user_api_key_cache=user_api_key_cache,
270 )
271 except (ProxyException, HTTPException):
272 return "no_active_key"
273 except Exception as exc: # noqa: BLE001 # classify: a DB outage is retryable, anything else is an opaque gateway fault
274 if PrismaDBExceptionHandler.is_database_service_unavailable_error(exc):
275 return _database_failure(exc)
276 verbose_logger.debug(
277 "_reload_active_key_by_hash: unexpected key-resolution error (%s)",
278 type(exc).__name__,
279 )
280 return "unresolvable"
281 if not _key_is_active(key_obj):
282 return "no_active_key"
283 return _ResolvedKey(key_hash=key_hash, key=key_obj)
286async def _reload_active_user_by_id(user_id: str) -> "_KeyResolutionFailure | None":
287 """``None`` when the user is live, else the precise failure ``load_active_user_by_id`` found."""
288 loaded: Final = await load_active_user_by_id(user_id)
289 return loaded if isinstance(loaded, str) else None
292UserRowSource = Literal["cache", "database"]
295async def load_active_user_by_id(
296 user_id: str, source: UserRowSource = "cache"
297) -> "LiteLLM_UserTable | _KeyResolutionFailure":
298 """Load a live litellm user by id, returning the record when the user is active or a precise
299 failure otherwise. The interactive DCR client authenticates via SSO, so its refresh envelope seals a
300 user subject; renewing it must re-check the user is still live (present and not SCIM-deactivated) so a
301 deactivated user cannot keep refreshing, mirroring how admission re-validates the same user subject on
302 the egress side. No DB connection is a gateway fault (``unresolvable``) and a
303 database-service-unavailable error is a retryable outage (``unavailable``). Everything else fails
304 closed as ``no_active_key`` (the caller maps it to invalid_grant): a ``ProxyException`` /
305 ``HTTPException``, a SCIM-deactivated user, and, unlike the key path, a missing user. ``get_user_object``
306 lets a real outage propagate as-is and re-raises any other DB failure as a bare ``ValueError`` (the
307 original error surviving only as ``__context__``), so the outage check walks the cause chain, and a
308 missing user falls through to ``no_active_key`` rather than an opaque gateway fault.
309 ``source="database"`` reads the row from the database, never the cache, so the credential mint refuses
310 a user that a writer deactivated or deleted without evicting the cached row, and it leaves the fresh
311 row in the cache for the requests the credential makes next. Every other caller keeps the cache read,
312 so introspection, which a resource server may call per request, stays off the database."""
313 from litellm.proxy._types import (
314 ProxyException, # noqa: PLC0415 # inline import avoids a module-load circular import
315 )
316 from litellm.proxy.auth.auth_checks import ( # noqa: PLC0415 # inline import avoids a module-load circular import
317 get_user_object,
318 )
319 from litellm.proxy.db.exception_handler import ( # noqa: PLC0415 # inline import avoids a module-load circular import
320 PrismaDBExceptionHandler,
321 )
322 from litellm.proxy.proxy_server import ( # noqa: PLC0415 # inline import avoids a module-load circular import
323 prisma_client,
324 user_api_key_cache,
325 )
327 if prisma_client is None:
328 return "unresolvable"
329 try:
330 user_object: Final = await get_user_object(
331 user_id=user_id,
332 prisma_client=prisma_client,
333 user_api_key_cache=user_api_key_cache,
334 user_id_upsert=False,
335 check_db_only=source == "database",
336 )
337 except (ProxyException, HTTPException):
338 return "no_active_key"
339 except Exception as exc: # noqa: BLE001 # a DB outage is retryable; a missing user (get_user_object's wrapped ValueError) or any other resolution failure fails closed as no_active_key, never a 500
340 outage: Final = PrismaDBExceptionHandler.find_database_service_unavailable_error_in_chain(exc)
341 if outage is not None:
342 return _database_failure(outage)
343 verbose_logger.debug("_reload_active_user_by_id: user-resolution error (%s)", type(exc).__name__)
344 return "no_active_key"
345 if user_object is None:
346 return "no_active_key"
347 return _active_user_record(user_object)
350def _active_user_record(user_object: "LiteLLM_UserTable") -> "LiteLLM_UserTable | Literal['no_active_key']":
351 if isinstance(user_object.metadata, dict) and user_object.metadata.get("scim_active") is False:
352 return "no_active_key"
353 return user_object
356async def _key_owner_scim_deactivated(key: "UserAPIKeyAuth") -> bool:
357 """True only when the key's owning user was explicitly SCIM-deactivated, so a refresh revokes an
358 offboarded owner's key exactly as admission does via ``_reject_if_admitted_owner_scim_deactivated``.
359 A key with no owner, a missing owner record, or a failed lookup fails OPEN (returns ``False``),
360 matching admission and the standard builder: a key may outlive its owner record, and a transient DB
361 blip must not revoke a live key. Only an explicit ``scim_active`` of ``False`` gates renewal."""
362 if key.user_id is None:
363 return False
364 from litellm.proxy.auth.auth_checks import ( # noqa: PLC0415 # inline import avoids a module-load circular import
365 get_user_object,
366 )
367 from litellm.proxy.proxy_server import ( # noqa: PLC0415 # inline import avoids a module-load circular import
368 prisma_client,
369 user_api_key_cache,
370 )
372 if prisma_client is None:
373 return False
374 try:
375 owner: Final = await get_user_object(
376 user_id=key.user_id,
377 prisma_client=prisma_client,
378 user_api_key_cache=user_api_key_cache,
379 user_id_upsert=False,
380 )
381 except Exception as exc: # noqa: BLE001 # fail open: a missing owner (get_user_object's wrapped ValueError) or a DB blip must not revoke a live key
382 verbose_logger.debug("refresh: key-owner SCIM lookup failed, not revoking (%s)", type(exc).__name__)
383 return False
384 return owner is not None and isinstance(owner.metadata, dict) and owner.metadata.get("scim_active") is False
387async def _revalidate_active_subject(identity: "EnvelopeIdentity") -> "_KeyResolutionFailure | None":
388 """Re-validate that the subject sealed in a refresh envelope is still live, dispatching on its type:
389 a key_hash reloads the virtual key, a user_id reloads the user. Returns ``None`` when the subject is
390 active or a precise failure otherwise, so revocation gates renewal for either identity source the same
391 way admission gates the egress: a blocked or expired key, a SCIM-deactivated key owner (mirroring
392 admission's owner check, so an offboarded user cannot keep renewing a still-active key), and a
393 deactivated or deleted user all fail closed to ``no_active_key``."""
394 match identity.subject_type:
395 case "key_hash":
396 reloaded: Final = await _reload_active_key_by_hash(identity.subject)
397 if not isinstance(reloaded, _ResolvedKey):
398 return reloaded
399 if await _key_owner_scim_deactivated(reloaded.key):
400 return "no_active_key"
401 return None
402 case "user_id":
403 return await _reload_active_user_by_id(identity.subject)
404 case _:
405 assert_never(identity.subject_type)
408async def _extract_user_id_from_request(request: Request) -> str | None:
409 """Resolve the caller for identity binding without granting credential-write permission."""
410 from litellm.proxy.auth.handle_jwt import JWTIdentity # noqa: PLC0415 # proxy import cycle
412 resolved: Final = await _resolve_request_auth(request)
413 if isinstance(resolved, JWTIdentity):
414 return resolved.user_id
415 return _active_key_user_id(resolved) if resolved is not None else None
418async def authorize_oauth_credential_request(request: Request, server_id: str) -> str | None:
419 from litellm.proxy._types import UserAPIKeyAuth # noqa: PLC0415 # proxy import cycle
421 resolved: Final = await _resolve_request_auth(request, f"/v1/mcp/server/{server_id}/oauth-user-credential")
422 if not isinstance(resolved, UserAPIKeyAuth) or not _active_key_user_id(resolved):
423 return None
424 if not await can_store_oauth_credential(request, resolved, server_id):
425 return None
426 return resolved.user_id
429async def _resolve_request_auth(
430 request: Request, write_route: str | None = None
431) -> "UserAPIKeyAuth | JWTIdentity | None":
432 from litellm.proxy.auth.handle_jwt import JWTHandler # noqa: PLC0415 # proxy import cycle
434 token: Final = _litellm_key_from_request(request)
435 if token is not None and JWTHandler.is_jwt(token):
436 return await _resolve_jwt_auth(request, token, write_route)
437 resolved: Final = await _resolve_active_litellm_key(request)
438 return resolved.key if isinstance(resolved, _ResolvedKey) else None
441async def can_store_oauth_credential(request: Request, auth: "UserAPIKeyAuth", server_id: str) -> bool:
442 """Apply the same write policy to request credentials and verified signed-callback users."""
443 from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( # noqa: PLC0415 # registry imports auth helpers
444 global_mcp_server_manager,
445 )
446 from litellm.proxy._experimental.mcp_server.ui_session_utils import (
447 can_access_mcp_server, # noqa: PLC0415 # proxy import cycle
448 )
449 from litellm.proxy.auth.route_checks import RouteChecks # noqa: PLC0415 # proxy import cycle
450 from litellm.proxy.auth.user_api_key_auth import ( # noqa: PLC0415 # proxy import cycle
451 _run_centralized_common_checks, # pyright: ignore[reportPrivateUsage] # reuse admission policy for the credential-write action
452 )
454 write_route: Final = f"/v1/mcp/server/{server_id}/oauth-user-credential"
455 try:
456 RouteChecks.is_virtual_key_allowed_to_call_route(route=write_route, valid_token=auth, request=request)
457 await _run_centralized_common_checks(
458 user_api_key_auth_obj=auth,
459 request=request,
460 request_data={},
461 route=write_route,
462 )
463 return await can_access_mcp_server(auth, server_id, global_mcp_server_manager.get_allowed_mcp_servers)
464 except Exception as exc: # noqa: BLE001 # authorization failure must never write credentials
465 verbose_logger.debug("OAuth credential write not authorized (%s)", type(exc).__name__)
466 return False
469async def _resolve_jwt_auth(
470 request: Request,
471 token: str,
472 write_route: str | None,
473) -> "UserAPIKeyAuth | JWTIdentity | None":
474 from litellm.proxy._types import UserAPIKeyAuth # noqa: PLC0415 # proxy import cycle
475 from litellm.proxy.auth.handle_jwt import JWTAuthManager # noqa: PLC0415 # proxy import cycle
476 from litellm.proxy.auth.user_api_key_auth import ( # noqa: PLC0415 # proxy import cycle
477 _resolve_jwt_to_virtual_key, # pyright: ignore[reportPrivateUsage] # reuse admission mapping policy without provisioning a new key
478 )
479 from litellm.proxy.proxy_server import ( # noqa: PLC0415 # proxy globals initialized at startup
480 general_settings,
481 jwt_handler,
482 premium_user,
483 prisma_client,
484 proxy_logging_obj,
485 user_api_key_cache,
486 )
488 if general_settings.get("enable_jwt_auth") is not True or premium_user is not True or prisma_client is None:
489 return None
490 try:
491 if jwt_handler.litellm_jwtauth.is_virtual_key_mapping_configured():
492 claims: Final = await jwt_handler.auth_jwt(token=token)
493 validate: Final = jwt_handler.litellm_jwtauth.custom_validate
494 if validate is not None and not validate(claims):
495 return None
496 mapped: Final = await _resolve_jwt_to_virtual_key(
497 jwt_claims=claims,
498 jwt_handler=jwt_handler,
499 prisma_client=prisma_client,
500 user_api_key_cache=user_api_key_cache,
501 parent_otel_span=None,
502 proxy_logging_obj=proxy_logging_obj,
503 )
504 if isinstance(mapped, UserAPIKeyAuth):
505 return None if await _key_owner_scim_deactivated(mapped) or not _key_is_active(mapped) else mapped
506 if mapped is not None:
507 return None
508 if write_route is None:
509 identity: Final = await JWTAuthManager.resolve_identity(
510 api_key=token,
511 jwt_handler=jwt_handler,
512 prisma_client=prisma_client,
513 user_api_key_cache=user_api_key_cache,
514 parent_otel_span=None,
515 proxy_logging_obj=proxy_logging_obj,
516 )
517 if identity.user_object is not None and isinstance(_active_user_record(identity.user_object), str):
518 return None
519 return identity
520 authorized: Final = await JWTAuthManager.authorize_jwt(
521 api_key=token,
522 jwt_handler=jwt_handler,
523 request_data={},
524 general_settings=general_settings,
525 route=write_route,
526 prisma_client=prisma_client,
527 user_api_key_cache=user_api_key_cache,
528 parent_otel_span=None,
529 proxy_logging_obj=proxy_logging_obj,
530 request_headers=dict(request.headers),
531 request_method=request.method,
532 )
533 resolved_user: Final = authorized["user_object"]
534 if resolved_user is not None and isinstance(_active_user_record(resolved_user), str):
535 return None
536 return JWTAuthManager.user_api_key_auth_from_result(authorized)
537 except Exception as exc: # noqa: BLE001 # public OAuth exchange stays available; unvalidated identities never write credentials
538 verbose_logger.debug("OAuth JWT identity could not be validated (%s)", type(exc).__name__)
539 return None
542_UpstreamGrantRejection = Literal["no_access_token", "expired_lifetime"]
543"""Why an upstream token response cannot back a bridge envelope:
544- ``no_access_token``: the response carries no usable ``access_token``
545- ``expired_lifetime``: the response reports a parseable, non-positive ``expires_in``, i.e. an upstream
546 token that is already dead, so sealing it would forward a bearer the edge cannot use
547An absent or unparseable ``expires_in`` is NOT a rejection; the lifetime is merely unknown and the
548envelope uses its fallback lifetime, the by-design behaviour for an upstream that omits the field."""
551def _classify_upstream_lifetime(raw_expires_in: object) -> "int | Literal['unspecified', 'expired']":
552 """Classify an upstream ``expires_in`` into a positive number of seconds, ``"unspecified"`` (absent
553 or unparseable, so the envelope uses its fallback), or ``"expired"`` (a non-positive value the upstream reports
554 as already elapsed). Telling "we do not know the lifetime" apart from "the upstream says it is
555 already dead" is what stops an explicitly-expired token from silently receiving the envelope's
556 one-hour fallback. The expired decision is made on the parsed numeric value, not on ``int(...)`` of it, so a
557 positive sub-second lifetime in ``(0, 1)`` is not truncated to ``0`` and misread as elapsed; the
558 envelope works in whole seconds, so such a lifetime clamps up to its 1s floor. ``bool`` is excluded
559 (an ``int`` subclass but never a real lifetime), and the conversions can raise on ``NaN`` /
560 ``Infinity`` / oversized input, which reads as unparseable rather than surfacing as a 500."""
561 if raw_expires_in is None or isinstance(raw_expires_in, bool) or not isinstance(raw_expires_in, (int, float, str)):
562 return "unspecified"
563 try:
564 numeric: Final = float(raw_expires_in)
565 seconds: Final = int(numeric)
566 except (ValueError, TypeError, OverflowError):
567 return "unspecified"
568 if numeric <= 0:
569 return "expired"
570 return max(1, seconds)
573def _bridge_grant_from_token_response(token_response: object) -> "UpstreamTokenGrant | _UpstreamGrantRejection":
574 """Validate an upstream OAuth token response into a typed grant, or say why it cannot back an
575 envelope. Each field is isinstance-checked so nothing untyped from ``response.json()`` reaches the
576 grant. ``expires_in`` is read three ways (see :func:`_classify_upstream_lifetime`): an unknown
577 lifetime leaves the grant ``expires_in`` ``None`` for the envelope fallback, a positive value is
578 honoured, and an explicit already-elapsed value is a rejection rather than a silent fall-through to
579 the cap."""
580 from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( # noqa: PLC0415 # inline import avoids a module-load circular import
581 UpstreamTokenGrant,
582 )
584 if not isinstance(token_response, dict):
585 return "no_access_token"
586 access: Final = token_response.get("access_token")
587 if not isinstance(access, str) or not access:
588 return "no_access_token"
589 lifetime: Final = _classify_upstream_lifetime(token_response.get("expires_in"))
590 if lifetime == "expired":
591 return "expired_lifetime"
592 token_type: Final = token_response.get("token_type")
593 scope: Final = token_response.get("scope")
594 return UpstreamTokenGrant(
595 access_token=SecretStr(access),
596 token_type=token_type if isinstance(token_type, str) and token_type else "Bearer",
597 # The upstream refresh_token is deliberately NOT sealed: the edge never consumes it (it forwards
598 # only token_type + access_token), so it would be dead weight embedding a long-lived upstream
599 # credential in the client-held bearer, and it enlarges the envelope. The dedicated refresh
600 # envelope carries that credential separately.
601 refresh_token=None,
602 scope=scope if isinstance(scope, str) and scope else None,
603 expires_in=lifetime if isinstance(lifetime, int) else None,
604 )
607# ---------------------------------------------------------------------------
608# DCR-bridge oauth_delegate mint: a three-phase pipeline whose failures are values.
609#
610# prepare (before the upstream exchange) -> validate every precondition and resolve identity+keys
611# exchange (the single-use upstream code is consumed here, in exchange_token_with_server)
612# finish (after the exchange) -> seal the upstream grant into the client-held envelope
613#
614# Every precondition lives in ``prepare``, which runs BEFORE the exchange, so no failure can burn the
615# single-use code or rotate a refresh token, for either grant type -- that whole class of bug is gone
616# by construction rather than guarded case by case. Failures are values mapped to an OAuth-shaped
617# response in one place (``_bridge_mint_error_response``), so status codes and the RFC 6749 §5.2 body
618# shape are uniform. Adding a failure mode is a new literal plus a match arm the type checker forces.
619# ---------------------------------------------------------------------------
621_BridgeMintError = Literal[
622 "no_identity",
623 "jwt_client_policy_unsupported",
624 "invalid_refresh",
625 "identity_unavailable",
626 "identity_faulted",
627 "identity_unresolvable",
628 "not_configured",
629 "no_upstream_token",
630 "upstream_token_expired",
631 "upstream_lifetime_unrepresentable",
632 "too_large",
633]
636@dataclass(frozen=True, slots=True)
637class _BridgeMintReady:
638 """Everything the seal needs, resolved once before the exchange: the identity to bind the envelope
639 to and the master-key-derived envelope keys. The identity is a key_hash subject for the scripted
640 two-header client (resolved from the litellm key it presents) or a user_id subject for the
641 interactive SSO client (the user recovered from the gateway authorization code), so one phase-3 seal
642 serves both. Resolving identity here means ``_finish_bridge_mint`` has no preconditions left to
643 fail."""
645 identity: "EnvelopeIdentity"
646 keys: "EnvelopeKeys"
649def _bridge_mint_error_response(error: _BridgeMintError) -> JSONResponse:
650 """Map a bridge-mint failure value to its token-endpoint response: one place, RFC 6749 §5.2 shape
651 (top-level ``error``, no-store headers) for every case, with a status truthful about where the
652 failure is. The caller's request is 400, a transient gateway outage is 503, a gateway
653 misconfiguration is 500, and an upstream problem is 502. The identity-resolution statuses match how
654 admission statuses the same conditions on the egress side, so mint and admit never disagree under
655 one outage."""
656 match error:
657 case "no_identity":
658 status, code, desc = (
659 400,
660 "invalid_request",
661 "this server issues a gateway-bound credential; complete the interactive sign-in, or "
662 "send a litellm credential (x-litellm-api-key or Authorization) on the token request",
663 )
664 case "jwt_client_policy_unsupported":
665 status, code, desc = (
666 400,
667 "invalid_request",
668 "JWT bridge minting is not supported with a claim-based MCP client allowlist; "
669 "the bridge credential cannot preserve the signed client identity",
670 )
671 case "invalid_refresh":
672 status, code, desc = (
673 400,
674 "invalid_grant",
675 "the refresh credential is not a valid, live refresh envelope for this server; "
676 "re-run authorization_code to obtain a new one",
677 )
678 case "identity_unavailable":
679 status, code, desc = (
680 503,
681 "temporarily_unavailable",
682 "the authentication database is temporarily unreachable; retry shortly",
683 )
684 case "identity_faulted":
685 status, code, desc = (
686 503,
687 "temporarily_unavailable",
688 "the authentication database reported a fault that is not a transient outage; "
689 "retrying will not help until the gateway deployment is repaired",
690 )
691 case "identity_unresolvable":
692 status, code, desc = (
693 500,
694 "server_error",
695 "the gateway could not resolve the litellm identity for this request",
696 )
697 case "not_configured":
698 status, code, desc = (
699 500,
700 "server_error",
701 "the gateway is not configured to mint a gateway-bound credential (master_key is not set)",
702 )
703 case "no_upstream_token":
704 status, code, desc = (
705 502,
706 "server_error",
707 "the upstream token response has no usable access_token",
708 )
709 case "upstream_token_expired":
710 status, code, desc = (
711 502,
712 "server_error",
713 "the upstream token response reports an already-expired lifetime",
714 )
715 case "upstream_lifetime_unrepresentable":
716 status, code, desc = (
717 502,
718 "server_error",
719 "the upstream token response reports an unrepresentable lifetime",
720 )
721 case "too_large":
722 status, code, desc = (
723 502,
724 "server_error",
725 "the upstream token is too large to seal into a gateway-bound credential",
726 )
727 case _:
728 assert_never(error)
729 return JSONResponse(
730 status_code=status, content={"error": code, "error_description": desc}, headers=TOKEN_NO_CACHE_HEADERS
731 )
734def _key_resolution_failure_to_mint_error(failure: _KeyResolutionFailure) -> _BridgeMintError:
735 """Lift an identity-resolution failure into the mint taxonomy, preserving origin so the status stays
736 truthful: the caller's missing credential is 400, a transient DB outage is 503, and a gateway that
737 cannot resolve identity is 500."""
738 match failure:
739 case "no_active_key":
740 return "no_identity"
741 case "unavailable":
742 return "identity_unavailable"
743 case "faulted":
744 return "identity_faulted"
745 case "unresolvable":
746 return "identity_unresolvable"
747 case _:
748 assert_never(failure)
751def _upstream_rejection_to_mint_error(rejection: _UpstreamGrantRejection) -> _BridgeMintError:
752 """Lift an upstream-response rejection into the mint taxonomy; both are upstream faults (502)."""
753 match rejection:
754 case "no_access_token":
755 return "no_upstream_token"
756 case "expired_lifetime":
757 return "upstream_token_expired"
758 case _:
759 assert_never(rejection)
762async def _prepare_bridge_mint(
763 request: Request,
764 mcp_server: MCPServer,
765 bridge_identity: "_BridgeAuthorizationCode | None" = None,
766) -> "_BridgeMintReady | _BridgeMintError":
767 """Phase 1 for the authorization_code grant, BEFORE the upstream exchange: confirm the gateway can
768 mint (master_key set), resolve the litellm identity, and derive the envelope keys. Returns a ready
769 context or a precise failure value. Running before the exchange is what makes every failure here fail
770 closed without consuming the single-use code.
772 Two identity sources, one envelope. The interactive DCR client authenticates via SSO at the bridged
773 authorize, so its identity arrives as ``bridge_identity`` (the user recovered from the gateway
774 authorization code) and mints a user subject. The scripted two-header client presents a litellm
775 credential (a virtual key or a JWT) on the token request instead: a key mints a key_hash subject,
776 while a JWT resolves through the same auth path as admission and mints a key_hash subject when it
777 maps to a virtual key. An unmapped JWT is rejected because a user subject cannot preserve its
778 JWT-specific authorization restrictions. A JWT client-claim allowlist also prevents JWT minting:
779 the envelope cannot retain the signed client identity for subsequent allowlist checks. A missing or invalid
780 presented key keeps its resolution origin so the mapper statuses it truthfully; neither source
781 present is ``no_identity``. The refresh_token grant has its own phase-1
782 (:func:`_prepare_bridge_refresh`), which recovers identity from the presented refresh envelope."""
783 from litellm.proxy._experimental.mcp_server.client_allowlist import ( # noqa: PLC0415 # keep mint policy dependencies local
784 load_mcp_client_allowlist,
785 )
786 from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import ( # noqa: PLC0415 # inline import avoids a module-load circular import
787 envelope_keys_from_master_key,
788 )
789 from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( # noqa: PLC0415 # inline import avoids a module-load circular import
790 key_hash_identity,
791 user_identity,
792 )
793 from litellm.proxy._types import UserAPIKeyAuth # noqa: PLC0415 # proxy import cycle
794 from litellm.proxy.auth.handle_jwt import JWTHandler # noqa: PLC0415 # proxy import cycle
795 from litellm.proxy.proxy_server import ( # noqa: PLC0415 # inline import avoids a module-load circular import
796 general_settings,
797 master_key,
798 )
800 if not master_key:
801 return "not_configured"
802 keys: Final = envelope_keys_from_master_key(master_key)
803 if bridge_identity is not None:
804 identity = user_identity(server_id=mcp_server.server_id, user_id=bridge_identity.litellm_user_id)
805 return _BridgeMintReady(identity=identity, keys=keys)
806 presented_token: Final = _litellm_key_from_request(request)
807 if presented_token is not None and JWTHandler.is_jwt(presented_token):
808 client_allowlist: Final = load_mcp_client_allowlist(general_settings)
809 if client_allowlist is not None and client_allowlist.jwt_field is not None:
810 return "jwt_client_policy_unsupported"
811 resolved_jwt: Final = await _resolve_jwt_auth(request, presented_token, None)
812 if isinstance(resolved_jwt, UserAPIKeyAuth) and resolved_jwt.token:
813 identity = key_hash_identity(server_id=mcp_server.server_id, key_hash=resolved_jwt.token)
814 return _BridgeMintReady(identity=identity, keys=keys)
815 return "no_identity"
816 resolved: Final = await _resolve_active_litellm_key(request)
817 if not isinstance(resolved, _ResolvedKey):
818 return _key_resolution_failure_to_mint_error(resolved)
819 identity = key_hash_identity(server_id=mcp_server.server_id, key_hash=resolved.key_hash)
820 return _BridgeMintReady(identity=identity, keys=keys)
823@dataclass(frozen=True, slots=True)
824class _BridgeRefreshReady:
825 """A validated refresh request: the identity+keys to mint the renewed pair under, the upstream refresh
826 token (unwrapped from the client's refresh envelope) to exchange with the upstream IdP, and the scope
827 sealed alongside it at mint. The upstream refresh token is a ``SecretStr`` like every other credential
828 in this layer, so a repr or a traceback that captures this value never exposes the raw upstream refresh
829 token in plaintext. ``upstream_scope`` carries the originally-granted scope so the renewal re-requests
830 it when the client (a DCR/MCP client that typically omits scope on refresh) sends none, keeping the
831 renewed token's scope stable against an upstream that would otherwise narrow or drop it."""
833 ready: "_BridgeMintReady"
834 upstream_refresh_token: SecretStr
835 upstream_scope: str | None = None
838def _refresh_key_failure_to_mint_error(failure: _KeyResolutionFailure) -> _BridgeMintError:
839 """Lift an identity-resolution failure on the refresh path into the mint taxonomy. Unlike the mint
840 path, a resolved-but-inactive (or unknown) key is ``invalid_grant`` rather than ``invalid_request``:
841 the client did present an identity (sealed in the refresh envelope), but it is no longer live, so the
842 refresh is invalid and the client must re-authenticate. A transient outage is still 503 and a gateway
843 fault still 500, matching the mint path and admission."""
844 match failure:
845 case "no_active_key":
846 return "invalid_refresh"
847 case "unavailable":
848 return "identity_unavailable"
849 case "faulted":
850 return "identity_faulted"
851 case "unresolvable":
852 return "identity_unresolvable"
853 case _:
854 assert_never(failure)
857async def _prepare_bridge_refresh(
858 mcp_server: MCPServer, refresh_value: str | None
859) -> "_BridgeRefreshReady | _BridgeMintError":
860 """Phase 1 for the refresh_token grant, BEFORE the upstream exchange: open the client's refresh
861 envelope, re-validate the sealed litellm identity so a revoked key cannot keep refreshing, and
862 recover the upstream refresh token to exchange. Identity comes entirely from the sealed envelope, not
863 the HTTP request, so the request object is not needed here. The client presents a refresh envelope,
864 never a raw upstream refresh token, so a missing value, a non-envelope, an unopenable envelope, or one
865 minted for another server is ``invalid_grant``. Running before the exchange means a rejected refresh
866 never consumes or rotates the upstream refresh token."""
867 from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import ( # noqa: PLC0415 # inline import avoids a module-load circular import
868 BridgeRefreshOpened,
869 envelope_keys_from_master_key,
870 open_bridge_refresh_envelope,
871 )
872 from litellm.proxy.proxy_server import ( # noqa: PLC0415 # inline import avoids a module-load circular import
873 master_key,
874 )
876 if not master_key:
877 return "not_configured"
878 if not refresh_value:
879 return "invalid_refresh"
880 keys: Final = envelope_keys_from_master_key(master_key)
881 opened: Final = open_bridge_refresh_envelope(refresh_value, keys, datetime.now(timezone.utc), mcp_server.server_id)
882 if not isinstance(opened, BridgeRefreshOpened):
883 return "invalid_refresh"
884 failure: Final = await _revalidate_active_subject(opened.identity)
885 if failure is not None:
886 return _refresh_key_failure_to_mint_error(failure)
887 return _BridgeRefreshReady(
888 ready=_BridgeMintReady(identity=opened.identity, keys=keys),
889 upstream_refresh_token=opened.refresh.refresh_token,
890 upstream_scope=opened.refresh.scope,
891 )
894def _finish_bridge_mint(
895 ready: "_BridgeMintReady", mcp_server: MCPServer, token_response: object, now: datetime
896) -> "JSONResponse | _BridgeMintError":
897 """Phase 3, AFTER the upstream exchange: seal the upstream grant into the client-held access envelope
898 using the pre-resolved identity and keys, and, when the upstream returned a refresh token, seal a
899 long-lived refresh envelope alongside it so the client can renew without re-authenticating. Shared by
900 the authorization_code and refresh_token paths, so a renewal that the upstream rotates re-issues a
901 fresh refresh envelope. The only hard failures here are properties of the upstream access token (no
902 usable token, an already-expired lifetime, or a token too large to seal); a refresh token that cannot
903 be sealed degrades to an access-only response rather than failing the whole exchange."""
904 from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import ( # noqa: PLC0415 # inline import avoids a module-load circular import
905 build_bridge_token_response,
906 )
907 from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( # noqa: PLC0415 # inline import avoids a module-load circular import
908 EnvelopeLifetimeUnrepresentable,
909 SealedEnvelope,
910 UpstreamTokenGrant,
911 )
913 grant: Final = _bridge_grant_from_token_response(token_response)
914 if not isinstance(grant, UpstreamTokenGrant):
915 return _upstream_rejection_to_mint_error(grant)
916 sealed: Final = build_bridge_token_response(ready.identity, grant, ready.keys, now)
917 if isinstance(sealed, EnvelopeLifetimeUnrepresentable):
918 return "upstream_lifetime_unrepresentable"
919 if not isinstance(sealed, SealedEnvelope):
920 return "too_large"
921 # Report expires_in from the JWT's own second-truncated exp, rounding the elapsed portion up, so the
922 # client is never told the bearer lives past the point admission (which uses that exp) rejects it.
923 expires_in: Final = max(0, int(sealed.expires_at.timestamp()) - math.ceil(now.timestamp()))
924 refresh_envelope: Final = _mint_refresh_envelope_value(ready.identity, token_response, ready.keys, now, mcp_server)
925 body: Final = {
926 "access_token": sealed.token.get_secret_value(),
927 "token_type": "Bearer",
928 "expires_in": expires_in,
929 # A refresh envelope rides along only when the upstream returned a refresh token to seal; when it
930 # rotates on renewal, the client receives the new one and the old envelope's upstream token dies.
931 **({"refresh_token": refresh_envelope} if refresh_envelope is not None else {}),
932 }
933 return JSONResponse(body, headers=TOKEN_NO_CACHE_HEADERS)
936def _upstream_refresh_credential(token_response: object) -> "RefreshCredential | None":
937 """Extract the upstream refresh grant from a token response, or ``None`` when there is none to seal.
938 Each field is isinstance-checked so nothing untyped reaches the refresh envelope; ``refresh_expires_in``
939 (the refresh token's own lifetime, when the upstream reports it) is classified like ``expires_in`` and
940 bounds the refresh envelope's TTL. An upstream that reports the refresh token itself as already elapsed
941 (``refresh_expires_in`` non-positive) yields ``None`` rather than a refresh envelope: sealing a dead
942 token would hand the client a full-TTL-capped envelope the IdP will reject, so the exchange degrades to
943 an access-only response (the client re-authenticates at access expiry), mirroring how
944 :func:`_bridge_grant_from_token_response` refuses an already-elapsed access token instead of capping it."""
945 from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( # noqa: PLC0415 # inline import avoids a module-load circular import
946 RefreshCredential,
947 )
949 if not isinstance(token_response, dict):
950 return None
951 refresh: Final = token_response.get("refresh_token")
952 if not isinstance(refresh, str) or not refresh:
953 return None
954 lifetime: Final = _classify_upstream_lifetime(token_response.get("refresh_expires_in"))
955 if lifetime == "expired":
956 return None
957 scope: Final = token_response.get("scope")
958 return RefreshCredential(
959 refresh_token=SecretStr(refresh),
960 scope=scope if isinstance(scope, str) and scope else None,
961 expires_in=lifetime if isinstance(lifetime, int) else None,
962 )
965def _mint_refresh_envelope_value(
966 identity: "EnvelopeIdentity", token_response: object, keys: "EnvelopeKeys", now: datetime, mcp_server: MCPServer
967) -> str | None:
968 """Seal the upstream refresh grant (if any) into a refresh envelope and return its bearer string, or
969 ``None`` when the upstream returned no refresh token or the refresh token is too large to seal. A
970 too-large refresh token degrades to an access-only response (logged) rather than failing an exchange
971 that already succeeded upstream: the client simply re-authenticates when the access envelope expires."""
972 from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import ( # noqa: PLC0415 # inline import avoids a module-load circular import
973 build_bridge_refresh_token_response,
974 )
975 from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( # noqa: PLC0415 # inline import avoids a module-load circular import
976 SealedEnvelope,
977 )
979 refresh_credential: Final = _upstream_refresh_credential(token_response)
980 if refresh_credential is None:
981 return None
982 sealed: Final = build_bridge_refresh_token_response(identity, refresh_credential, keys, now)
983 if isinstance(sealed, SealedEnvelope):
984 return sealed.token.get_secret_value()
985 verbose_logger.warning(
986 "bridge mint: the upstream refresh token is too large to seal into a refresh envelope for "
987 "server=%s; issuing an access-only response, so the client re-authenticates at access expiry",
988 mcp_server.server_id,
989 )
990 return None