Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/_experimental/mcp_server/idp_token_exchange.py: 35%

97 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 12:01 +0000

1"""The identity-provider side of the RFC 8693 token exchange on ``POST /token``: a native 

2client that already holds a JWT from the customer's IdP trades it for the same proxy-API 

3credential ``lite login`` stores, proven by the proxy's own JWT auth (signature, claims, 

4and the user and team sync it performs), so no browser round trip is needed.""" 

5 

6from __future__ import annotations 

7 

8from collections.abc import Awaitable, Callable, Mapping 

9from dataclasses import dataclass 

10from typing import Final, Literal, Protocol 

11 

12from fastapi import HTTPException, Request 

13from typing_extensions import assert_never 

14 

15from litellm._logging import verbose_proxy_logger 

16from litellm.proxy._experimental.mcp_server.gateway_dcr_flow import SubjectIdentity, SubjectTokenRefusal 

17from litellm.proxy._types import JWTAuthBuilderResult, ProxyException 

18from litellm.proxy.auth.handle_jwt import JWTAuthManager, JWTHandler 

19from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler 

20 

21EXCHANGE_ROUTE: Final = "/token" 

22REJECTED_SUBJECT_TOKEN: Final = "subject_token was rejected by the gateway's JWT auth" 

23SUBJECT_TOKEN_CHECK_UNAVAILABLE: Final = ( 

24 "the gateway could not verify subject_token because its identity provider or database is unavailable; retry" 

25) 

26SUBJECT_TOKEN_CHECK_FAULTED: Final = ( 

27 "the gateway could not verify subject_token because its database reported a fault that is not a transient " 

28 "outage; retrying will not help until the gateway deployment is repaired" 

29) 

30GatewayOutage = Literal["retryable", "faulted"] 

31 

32 

33@dataclass(frozen=True, slots=True) 

34class TokenExchangePrerequisites: 

35 """The deployment-level gates ``user_api_key_auth`` applies before it verifies any JWT 

36 bearer, plus the JWT-to-virtual-key mapping it consults first: a gateway that maps 

37 tokens authenticates a JWT as its mapped key, with that key's models and budget, or 

38 refuses an unmapped one, and the exchange proves the token through ``auth_builder`` 

39 alone, so it would mint the user's own credential past that policy. Discovery and 

40 registration advertise the exchange grant only when every gate holds, and an exchange 

41 attempt is refused naming the first one that does not.""" 

42 

43 jwt_auth_enabled: bool 

44 has_database: bool 

45 licensed: bool 

46 maps_jwts_to_virtual_keys: bool 

47 

48 @property 

49 def available(self) -> bool: 

50 return self.jwt_auth_enabled and self.has_database and self.licensed and not self.maps_jwts_to_virtual_keys 

51 

52 def refusal(self) -> SubjectTokenRefusal | None: 

53 if not self.jwt_auth_enabled: 

54 return SubjectTokenRefusal( 

55 error="unsupported_grant_type", 

56 description="JWT auth is not enabled on this gateway, so it cannot exchange IdP tokens", 

57 ) 

58 if not self.has_database: 

59 return SubjectTokenRefusal( 

60 error="unsupported_grant_type", 

61 description="this gateway has no database, so it cannot exchange IdP tokens", 

62 ) 

63 if not self.licensed: 

64 return SubjectTokenRefusal( 

65 error="unsupported_grant_type", 

66 description="JWT auth is an enterprise only feature; no license is set", 

67 ) 

68 if self.maps_jwts_to_virtual_keys: 

69 return SubjectTokenRefusal( 

70 error="unsupported_grant_type", 

71 description="this gateway maps IdP tokens to virtual keys, which the exchange does not serve", 

72 ) 

73 return None 

74 

75 

76def read_token_exchange_prerequisites() -> TokenExchangePrerequisites: 

77 from litellm.proxy.proxy_server import ( # noqa: PLC0415 # rebound after startup, so read them per call 

78 general_settings, 

79 jwt_handler, 

80 premium_user, 

81 prisma_client, 

82 ) 

83 

84 return TokenExchangePrerequisites( 

85 jwt_auth_enabled=general_settings.get("enable_jwt_auth", False) is True, 

86 has_database=prisma_client is not None, 

87 licensed=premium_user is True, 

88 maps_jwts_to_virtual_keys=_maps_jwts_to_virtual_keys(jwt_handler), 

89 ) 

90 

91 

92def _maps_jwts_to_virtual_keys(jwt_handler: JWTHandler) -> bool: 

93 if not hasattr(jwt_handler, "litellm_jwtauth"): 93 ↛ 94line 93 didn't jump to line 94 because the condition on line 93 was never true

94 return False 

95 return jwt_handler.litellm_jwtauth.is_virtual_key_mapping_configured() 

96 

97 

98def token_exchange_available() -> bool: 

99 return read_token_exchange_prerequisites().available 

100 

101 

102class AuthorizeSubjectToken(Protocol): 

103 """Injected JWT authorization ``(subject_token, request_headers)``: the proxy's 

104 ``JWTAuthManager.auth_builder`` in production, which raises when the token is not 

105 acceptable and otherwise names the user and team it resolved.""" 

106 

107 def __call__( 107 ↛ exitline 107 didn't return from function '__call__' because

108 self, subject_token: str, request_headers: Mapping[str, str], / 

109 ) -> Awaitable[JWTAuthBuilderResult]: ... 

110 

111 

112async def exchange_idp_subject_token(subject_token: str, request: Request) -> SubjectIdentity | SubjectTokenRefusal: 

113 from litellm.proxy.proxy_server import ( # noqa: PLC0415 # rebound after startup, so read them per call 

114 general_settings, 

115 jwt_handler, 

116 prisma_client, 

117 proxy_logging_obj, 

118 user_api_key_cache, 

119 ) 

120 

121 async def authorize(token: str, request_headers: Mapping[str, str]) -> JWTAuthBuilderResult: 

122 return await JWTAuthManager.auth_builder( 

123 api_key=token, 

124 jwt_handler=jwt_handler, 

125 request_data={}, 

126 general_settings=general_settings, 

127 route=EXCHANGE_ROUTE, 

128 prisma_client=prisma_client, 

129 user_api_key_cache=user_api_key_cache, 

130 parent_otel_span=None, 

131 proxy_logging_obj=proxy_logging_obj, 

132 request_headers=request_headers, 

133 request_method="POST", 

134 ) 

135 

136 return await identity_from_subject_token( 

137 subject_token, 

138 request_headers=request.headers, 

139 prerequisites=read_token_exchange_prerequisites(), 

140 is_jwt=jwt_handler.is_jwt, 

141 authorize=authorize, 

142 ) 

143 

144 

145async def identity_from_subject_token( 

146 subject_token: str, 

147 request_headers: Mapping[str, str], 

148 prerequisites: TokenExchangePrerequisites, 

149 is_jwt: Callable[[str], bool], 

150 authorize: AuthorizeSubjectToken, 

151) -> SubjectIdentity | SubjectTokenRefusal: 

152 """Apply the same gates ``user_api_key_auth`` applies to a JWT bearer, then let the 

153 proxy's JWT auth prove the token. A rejection comes back as ``invalid_request``, which 

154 RFC 8693 section 2.2.2 prescribes for an invalid or unacceptable subject token, and a 

155 check the gateway could not complete (the IdP's JWKS unreachable with no cached copy, 

156 the auth database down) as ``temporarily_unavailable``, so the client retries instead 

157 of treating a valid token as bad, worded by whether retrying can help. The reason stays 

158 in the proxy log: this endpoint is public and JWT auth's own wording can name the JWKS 

159 URL it fetched or quote the IdP's response.""" 

160 unmet: Final = prerequisites.refusal() 

161 if unmet is not None: 

162 return unmet 

163 if not is_jwt(subject_token): 

164 return SubjectTokenRefusal(error="invalid_request", description="subject_token is not a JWT") 

165 try: 

166 result: Final = await authorize(subject_token, request_headers) 

167 except HTTPException as denied: 

168 return _refusal_for(denied, denied.detail) 

169 except ProxyException as denied: 

170 return _refusal_for(denied, denied.message) 

171 except Exception as denied: # noqa: BLE001 # auth_jwt raises a plain Exception on signature and claim failures 

172 return _refusal_for(denied, denied) 

173 user_id: Final = result["user_id"] 

174 if user_id is None: 

175 return SubjectTokenRefusal(error="invalid_request", description="subject_token names no user the gateway knows") 

176 return SubjectIdentity(user_id=user_id, team_id=result["team_id"]) 

177 

178 

179def _refusal_for(denied: Exception, reason: object) -> SubjectTokenRefusal: 

180 outage: Final = _gateway_could_not_verify(denied) 

181 if outage is None: 

182 verbose_proxy_logger.warning("token exchange refused a subject_token: %s", reason) 

183 return SubjectTokenRefusal(error="invalid_request", description=REJECTED_SUBJECT_TOKEN) 

184 verbose_proxy_logger.error("token exchange could not verify a subject_token, %s: %s", outage, reason) 

185 return SubjectTokenRefusal(error="temporarily_unavailable", description=_check_unavailable_description(outage)) 

186 

187 

188def _check_unavailable_description(outage: GatewayOutage) -> str: 

189 match outage: 

190 case "retryable": 

191 return SUBJECT_TOKEN_CHECK_UNAVAILABLE 

192 case "faulted": 

193 return SUBJECT_TOKEN_CHECK_FAULTED 

194 case _: 

195 assert_never(outage) 

196 

197 

198def _gateway_could_not_verify(denied: Exception) -> GatewayOutage | None: 

199 """A database fault anywhere in the chain or a 5xx from JWT auth (the IdP's JWKS 

200 unreachable with no cached copy) is the gateway failing, not the token. A fault retrying cannot clear (a missing or 

201 version-skewed query engine) is named as such, the way the mint path words it, so the 

202 client is not told to wait on a deployment that needs repair.""" 

203 fault: Final = PrismaDBExceptionHandler.find_database_service_unavailable_error_in_chain(denied) 

204 if fault is not None: 

205 return "faulted" if PrismaDBExceptionHandler.is_permanent_database_fault(fault) else "retryable" 

206 return "retryable" if _is_server_error(denied) else None 

207 

208 

209def _is_server_error(denied: Exception) -> bool: 

210 match denied: 

211 case HTTPException(status_code=status_code): 

212 return status_code >= 500 

213 case ProxyException(code=code): 

214 return code.isdigit() and int(code) >= 500 

215 case _: 

216 return False