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

1"""Bridge token flow: litellm identity resolution and the DCR-bridge oauth_delegate mint/refresh pipeline.""" 

2 

3import math 

4import os 

5import secrets 

6from dataclasses import dataclass 

7from datetime import datetime, timezone 

8from typing import TYPE_CHECKING, Final, Literal 

9 

10from fastapi import HTTPException, Request 

11from fastapi.responses import JSONResponse 

12from pydantic import SecretStr 

13from typing_extensions import assert_never 

14 

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 

21 

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 

33 

34 

35def _litellm_key_from_request(request: Request) -> str | None: 

36 """Return the LiteLLM API key presented on the request, or ``None``. 

37 

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 

55 

56 

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 ) 

65 

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 ) 

86 

87 

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 ) 

101 

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 

113 

114 

115def _key_is_active(key_obj: "UserAPIKeyAuth") -> bool: 

116 """``True`` when the presented key is neither blocked nor past its expiry. 

117 

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. 

123 

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`. 

128 

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 

150 

151 

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 

157 

158 

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.""" 

163 

164 key_hash: str 

165 key: "UserAPIKeyAuth" 

166 

167 

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.""" 

180 

181 

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 ) 

186 

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" 

189 

190 

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. 

193 

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 

208 

209 return await _reload_active_key_by_hash(hash_token(token)) 

210 

211 

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 ) 

225 

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 

235 

236 

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 ) 

260 

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) 

284 

285 

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 

290 

291 

292UserRowSource = Literal["cache", "database"] 

293 

294 

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 ) 

326 

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) 

348 

349 

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 

354 

355 

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 ) 

371 

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 

385 

386 

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) 

406 

407 

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 

411 

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 

416 

417 

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 

420 

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 

427 

428 

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 

433 

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 

439 

440 

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 ) 

453 

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 

467 

468 

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 ) 

487 

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 

540 

541 

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.""" 

549 

550 

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) 

571 

572 

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 ) 

583 

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 ) 

605 

606 

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# --------------------------------------------------------------------------- 

620 

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] 

634 

635 

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.""" 

644 

645 identity: "EnvelopeIdentity" 

646 keys: "EnvelopeKeys" 

647 

648 

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 ) 

732 

733 

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) 

749 

750 

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) 

760 

761 

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. 

771 

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 ) 

799 

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) 

821 

822 

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.""" 

832 

833 ready: "_BridgeMintReady" 

834 upstream_refresh_token: SecretStr 

835 upstream_scope: str | None = None 

836 

837 

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) 

855 

856 

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 ) 

875 

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 ) 

892 

893 

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 ) 

912 

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) 

934 

935 

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 ) 

948 

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 ) 

963 

964 

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 ) 

978 

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