Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/_experimental/mcp_server/outbound_credentials/types.py: 79%

224 statements  

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

1"""The upstream-credential vocabulary — the typed seam the resolver dispatches on. 

2 

3This module ships the data types only; the resolver lands in a later PR. It is the contract 

4the credential build implements and the spec tests assert against. 

5 

6Design invariants encoded here: 

7 

8- **Mode is the single source of truth.** A server declares exactly one per-mode `config` 

9 (the `AuthConfig` discriminated union); `auth_spec_kind` is *derived* from it, never a 

10 second field that can drift. The resolver dispatches on the config variant, one arm per 

11 mode. No field-presence inference, no precedence cascade. 

12- **Illegal states unrepresentable.** Each mode's config is its own frozen model holding 

13 only that mode's fields — an `aws_sigv4` server cannot hold OAuth fields, and a config 

14 missing a required field is rejected at construction, not at call time. 

15- **Fail-closed at the boundary.** A raw mode string can only enter through 

16 `parse_auth_spec_kind()`, which returns a typed `CredError`. 

17- **Errors as values.** Every seam returns `Result[_, CredError]`; only edge adapters raise. 

18- **No v1 imports.** This vocabulary stays free of `MCPServer` and the rest of v1; the 

19 v1 -> v2 adapter maps onto these types in a later PR. 

20 

21Sum types are Expression `@tagged_union`s discriminated on a `Literal` `tag`, matched via 

22`self.tag` with an `assert_never` tail; `Result` is this package's vendored `Ok | Error` 

23union (see `result.py`), not `expression.Result`. 

24""" 

25 

26from __future__ import annotations 

27 

28from collections.abc import Mapping 

29from dataclasses import dataclass, field 

30from enum import Enum 

31from typing import Annotated, Final, Literal 

32 

33import httpx2 

34from expression import case, tag, tagged_union 

35from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator 

36from typing_extensions import assert_never 

37 

38from litellm.proxy._experimental.mcp_server.outbound_credentials.result import ( 

39 Error, 

40 Ok, 

41 Result, 

42) 

43from litellm.types.mcp import ( 

44 DEFAULT_CREDENTIAL_HEADER, 

45 DEFAULT_SUBJECT_TOKEN_TYPE, 

46 normalize_upstream_header_name, 

47) 

48 

49 

50class AuthResolution(str, Enum): 

51 no_auth = "no-auth" 

52 stored_user_token = "stored-user-token" 

53 static_token = "static-token" 

54 per_request_header = "per-request-header" 

55 oauth2_passthrough = "oauth2-passthrough" 

56 client_credentials = "m2m-client-credentials" 

57 token_exchange = "token-exchange" 

58 id_jag = "id-jag" 

59 aws_sigv4 = "aws-sigv4" 

60 extra_headers = "extra-headers" 

61 not_applicable = "not-applicable" 

62 unresolved = "unresolved" 

63 failed = "resolution-failed" 

64 multiple = "multiple" 

65 

66 

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

68class ResolvedCredential: 

69 auth: httpx2.Auth = field(repr=False) 

70 source: AuthResolution 

71 

72 

73class AuthSpecKind(str, Enum): 

74 """The server's statically-declared upstream-auth mode — derived from its `config`. 

75 

76 Covers v1's full `MCPAuth` surface, not only OAuth grants: the three grant modes, the 

77 collapsed static-header family, client passthrough, no-auth, and AWS request signing. 

78 BYOK is *not* a member: it is the `api_key` mode seeded per-user, a source selector 

79 inside that arm. The static-header schemes v1 splits into separate `MCPAuth` values 

80 (`bearer_token`/`api_key`/`basic`/`token`/`authorization`) collapse into `api_key`; the 

81 scheme is a parameter the arm carries, not its own mode. 

82 """ 

83 

84 authorization_code = "authorization_code" # per-user 3LO; gateway-stored token 

85 client_credentials = "client_credentials" # gateway service account (M2M) 

86 token_exchange = "token_exchange" # RFC 8693: token endpoint + subject_token (OBO) 

87 id_jag = "id_jag" # draft-ietf-oauth-identity-assertion-authz-grant: two-leg exchange then jwt-bearer 

88 api_key = "api_key" # static header, any scheme (BYOK = per-user-seeded source) 

89 passthrough = "passthrough" # client forwards an upstream-audience token 

90 none = "none" # no upstream credential; resolve yields a no-op auth, never an error 

91 aws_sigv4 = "aws_sigv4" # AWS SigV4 per-request signing (e.g. Bedrock AgentCore) 

92 

93 

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

95class Unauthorized: 

96 """A 401 plus the optional challenge a client needs to recover. 

97 

98 ``detail`` is the human message; ``www_authenticate`` and ``body`` carry a scheme-specific 

99 challenge (e.g. BYOK's provisioning prompt) so the edge can reproduce it verbatim. 

100 ``claims`` carries an IdP step-up challenge (e.g. Entra Conditional Access) so the edge can 

101 fold it into the ``WWW-Authenticate`` it builds; the client replays the claims to the IdP to 

102 satisfy the step-up, then retries with the fresh token. 

103 """ 

104 

105 detail: str 

106 www_authenticate: str | None = None 

107 body: Mapping[str, str] | None = None 

108 claims: str | None = None 

109 

110 

111@tagged_union(frozen=True) 

112class CredError: 

113 """Why a credential could not be produced. Fail-closed: an arm yields this or an `httpx2.Auth`. 

114 

115 Discriminated on the `Literal` `tag`; consumers `match self.tag` (see `summary`) so the 

116 type checker can prove exhaustiveness. Construct via the `of_*` factories. 

117 """ 

118 

119 tag: Literal[ 

120 "unauthorized", 

121 "misconfigured", 

122 "url_credentials_not_allowed", 

123 "upstream_unavailable", 

124 "unsupported_mode", 

125 "precondition_required", 

126 "not_implemented", 

127 ] = tag() 

128 

129 unauthorized: Unauthorized = case() # no usable credential for this (subject, server) -> 401 challenge 

130 misconfigured: str = case() # the declared mode is missing required config -> 5xx (operator) 

131 url_credentials_not_allowed: None = case() 

132 upstream_unavailable: str = case() # the IdP / token endpoint could not be reached -> 503 

133 unsupported_mode: str = case() # a raw mode string did not parse into AuthSpecKind (boundary) 

134 precondition_required: str = case() # a required per-user value (e.g. an env var) has not been provided -> 412 

135 not_implemented: str = case() # the declared mode's resolver arm is not built yet -> 501 (not operator error) 

136 

137 @staticmethod 

138 def of_unauthorized( 

139 detail: str, 

140 *, 

141 www_authenticate: str | None = None, 

142 body: Mapping[str, str] | None = None, 

143 claims: str | None = None, 

144 ) -> CredError: 

145 return CredError( 

146 unauthorized=Unauthorized( 

147 detail=detail, 

148 www_authenticate=www_authenticate, 

149 body=body, 

150 claims=claims, 

151 ) 

152 ) 

153 

154 @staticmethod 

155 def of_misconfigured(detail: str) -> CredError: 

156 return CredError(misconfigured=detail) 

157 

158 @staticmethod 

159 def of_url_credentials_not_allowed() -> CredError: 

160 return CredError(url_credentials_not_allowed=None) 

161 

162 @staticmethod 

163 def of_upstream_unavailable(detail: str) -> CredError: 

164 return CredError(upstream_unavailable=detail) 

165 

166 @staticmethod 

167 def of_unsupported_mode(detail: str) -> CredError: 

168 return CredError(unsupported_mode=detail) 

169 

170 @staticmethod 

171 def of_precondition_required(detail: str) -> CredError: 

172 return CredError(precondition_required=detail) 

173 

174 @staticmethod 

175 def of_not_implemented(detail: str) -> CredError: 

176 return CredError(not_implemented=detail) 

177 

178 @property 

179 def summary(self) -> str: 

180 # Exhaustiveness: every Literal tag has an arm; the trailing assert_never typechecks 

181 # only while that stays true (a `case _` would defeat reportMatchNotExhaustive). 

182 match self.tag: 

183 case "unauthorized": 183 ↛ 184line 183 didn't jump to line 184 because the pattern on line 183 never matched

184 return f"unauthorized: {self.unauthorized.detail}" 

185 case "misconfigured": 185 ↛ 187line 185 didn't jump to line 187 because the pattern on line 185 always matched

186 return f"misconfigured: {self.misconfigured}" 

187 case "url_credentials_not_allowed": 

188 return ( 

189 "misconfigured: auth_type none cannot be used with credentials embedded in the upstream URL; " 

190 "remove them from the URL and configure Basic Auth with auth_type: basic and " 

191 "auth_value: username:password" 

192 ) 

193 case "upstream_unavailable": 

194 return f"upstream unavailable: {self.upstream_unavailable}" 

195 case "unsupported_mode": 

196 return self.unsupported_mode 

197 case "precondition_required": 

198 return f"precondition required: {self.precondition_required}" 

199 case "not_implemented": 

200 return f"not implemented: {self.not_implemented}" 

201 assert_never(self.tag) 

202 

203 

204def validate_header_name(raw: str) -> Result[str, CredError]: 

205 """``normalize_upstream_header_name`` with this package's error-as-value policy. 

206 

207 The grammar itself lives in ``litellm.types.mcp`` so the v1 model, the management endpoint and 

208 this vocabulary all judge a header name the same way while each keeps its own failure shape. 

209 """ 

210 normalized: Final = normalize_upstream_header_name(raw) 

211 if normalized is None: 

212 return Error(CredError.of_misconfigured(f"invalid upstream header name: {raw!r}")) 

213 return Ok(normalized) 

214 

215 

216class HeaderCarrier(BaseModel): 

217 """Where a resolved credential is written upstream, and how its value is formatted. 

218 

219 ``Authorization: Bearer`` is only OAuth's *default* conveyance (RFC 6750 section 2.1), not its 

220 only one: an ESB or API gateway commonly terminates its own credential in a private header while 

221 a second credential passes through to the origin, so a credential has to be able to say which 

222 slot it owns. Modeled like OpenAPI's apiKey scheme, so any upstream convention is expressible 

223 (Authorization + Bearer, a raw value on X-API-Key, Ocp-Apim-Subscription-Key, esb-oauth, ...). 

224 

225 Every config whose credential the gateway mints or holds inherits this, so no resolver arm names 

226 a header itself and the conflict rule in ``_resolve_v2_auth`` can always ask the auth object 

227 which slot it is about to occupy. ``passthrough`` deliberately does not: it forwards the 

228 caller's own credential into the slot the caller used, and mints nothing to place. 

229 """ 

230 

231 model_config = ConfigDict(frozen=True) 

232 header_name: str = DEFAULT_CREDENTIAL_HEADER 

233 value_prefix: str = "Bearer" 

234 

235 @field_validator("header_name") 

236 @classmethod 

237 def _check_header_name(cls, value: str) -> str: 

238 match validate_header_name(value): 

239 case Ok(name): 

240 return name 

241 case Error(err): 

242 raise ValueError(err.summary) 

243 

244 def header(self, value: str) -> tuple[str, str]: 

245 formatted: Final = f"{self.value_prefix} {value}" if self.value_prefix else value 

246 return self.header_name, formatted 

247 

248 

249class AuthorizationCodeConfig(HeaderCarrier): 

250 """Per-user 3LO; the gateway is the OAuth client and stores the user's token. 

251 

252 Endpoints are discovered (RFC 9728 -> RFC 8414) and the client is registered via DCR 

253 (RFC 7591), so the common case carries none of the fields below; they are optional manual 

254 overrides for IdPs without discovery / DCR. The per-user token is read from the token store 

255 at resolve time, not held here. 

256 """ 

257 

258 model_config = ConfigDict(frozen=True) 

259 kind: Literal[AuthSpecKind.authorization_code] = AuthSpecKind.authorization_code 

260 scopes: tuple[str, ...] = () 

261 client_id: str | None = None 

262 client_secret: SecretStr | None = None 

263 authorization_url: str | None = None 

264 token_url: str | None = None 

265 

266 

267class ClientCredentialsConfig(HeaderCarrier): 

268 """M2M service account; one upstream identity for every user. 

269 

270 Fields are optional so the config can be built incomplete: a value may be supplied at 

271 runtime (`token_url` via RFC 8414 discovery, `client_id`/`secret` via DCR), and the 

272 resolver arm returns `CredError.misconfigured` when a needed field is still absent. 

273 

274 `audience` is the IdP-specific audience parameter some authorization servers require on 

275 the client_credentials grant (sent as `audience` in the token request when set). 

276 `token_endpoint_auth_method` selects how the client authenticates to the token endpoint 

277 (RFC 6749 section 2.3.1); `None` defaults to `client_secret_post`. 

278 """ 

279 

280 model_config = ConfigDict(frozen=True) 

281 kind: Literal[AuthSpecKind.client_credentials] = AuthSpecKind.client_credentials 

282 client_id: str | None = None 

283 client_secret: SecretStr | None = None 

284 token_url: str | None = None 

285 scopes: tuple[str, ...] = () 

286 audience: str | None = None 

287 upstream_resource: str | None = None 

288 token_endpoint_auth_method: Literal["client_secret_post", "client_secret_basic"] | None = None 

289 

290 

291class TokenExchangeConfig(HeaderCarrier): 

292 """OBO: swap the caller's live inbound token for a token bound to the upstream's audience. The 

293 gateway authenticates to the exchange endpoint as an OAuth client (`client_id`/`client_secret`); 

294 the inbound token is sent only to that endpoint, never to the upstream. 

295 

296 `profile` selects the wire dialect, since not every IdP speaks RFC 8693: 

297 - `rfc8693` (default) is the standard token-exchange grant: the inbound token is the 

298 `subject_token` (typed by `subject_token_type`), the target is the optional `audience`. 

299 - `entra_obo` is Microsoft Entra On-Behalf-Of, which is the RFC 7523 `jwt-bearer` grant rather 

300 than 8693: the inbound token rides as `assertion`, the target resource is carried in `scopes` 

301 (`api://<app-id>/.default`, since Entra has no audience parameter), and the Microsoft-only 

302 `requested_token_use=on_behalf_of` extension makes the jwt-bearer grant a delegation. 

303 `subject_token_type` and `audience` are unused in this profile. 

304 

305 `audience` (rfc8693 only) is optional and sent only when the operator configured one, since both 

306 `audience` and `resource` are optional in RFC 8693 and the authorization server applies its own 

307 default when neither is sent (fabricating one risks `invalid_target`). 

308 """ 

309 

310 model_config = ConfigDict(frozen=True) 

311 kind: Literal[AuthSpecKind.token_exchange] = AuthSpecKind.token_exchange 

312 profile: Literal["rfc8693", "entra_obo"] = "rfc8693" 

313 subject_token_type: str = DEFAULT_SUBJECT_TOKEN_TYPE 

314 token_exchange_endpoint: str | None = None 

315 audience: str | None = None 

316 client_id: str | None = None 

317 client_secret: SecretStr | None = None 

318 token_endpoint_auth_method: Literal["client_secret_basic", "client_secret_post"] | None = None 

319 scopes: tuple[str, ...] = () 

320 

321 

322class PrivateKeyJwtAuth(BaseModel): 

323 """RFC 7523 private-key-JWT client authentication: the gateway signs a `client_assertion`.""" 

324 

325 model_config = ConfigDict(frozen=True) 

326 source: Literal["private_key_jwt"] = "private_key_jwt" 

327 private_key: SecretStr 

328 key_id: str | None = None 

329 signing_alg: str = "RS256" 

330 

331 

332class ClientSecretAuth(BaseModel): 

333 """`client_secret_post` client authentication: the gateway posts `client_id` + `client_secret`.""" 

334 

335 model_config = ConfigDict(frozen=True) 

336 source: Literal["client_secret"] = "client_secret" 

337 client_secret: SecretStr 

338 

339 

340ClientAuth = Annotated[PrivateKeyJwtAuth | ClientSecretAuth, Field(discriminator="source")] 

341 

342 

343class IdJagConfig(HeaderCarrier): 

344 """draft-ietf-oauth-identity-assertion-authz-grant (Okta "AI agent token exchange"). 

345 

346 Two legs: leg 1 is an RFC 8693 token exchange at the IdP org AS (`org_token_endpoint`) that 

347 swaps the caller's identity token for an ID-JAG assertion; leg 2 is an RFC 7523 jwt-bearer at 

348 the upstream resource AS (`resource_token_endpoint`) that swaps the assertion for the access 

349 token. The gateway authenticates to both endpoints as `client_id` via `client_auth`. Required 

350 fields are enforced at construction so a half-configured server cannot reach the arm. 

351 """ 

352 

353 model_config = ConfigDict(frozen=True) 

354 kind: Literal[AuthSpecKind.id_jag] = AuthSpecKind.id_jag 

355 org_token_endpoint: str 

356 resource_token_endpoint: str 

357 client_id: str 

358 client_auth: ClientAuth 

359 subject_token_type: str = "urn:ietf:params:oauth:token-type:id_token" 

360 audience: str | None = None 

361 resource: str | None = None 

362 scopes: tuple[str, ...] = () 

363 

364 

365class SharedKey(BaseModel): 

366 """A fixed key configured on the server, identical for every caller.""" 

367 

368 model_config = ConfigDict(frozen=True) 

369 source: Literal["shared"] = "shared" 

370 value: SecretStr 

371 

372 

373class Byok(BaseModel): 

374 """A key the user brings via the entry flow, stored per-user and pulled from the credential 

375 store at resolve time. Missing means the user must provide it, a 401 + WWW-Authenticate 

376 challenge.""" 

377 

378 model_config = ConfigDict(frozen=True) 

379 source: Literal["byok"] = "byok" 

380 

381 

382ApiKeySource = Annotated[SharedKey | Byok, Field(discriminator="source")] 

383 

384 

385class ApiKeyConfig(HeaderCarrier): 

386 """A fixed credential injected as a header. The value is shared (in config) or seeded 

387 per-user (pulled from the store); the inherited `header_name` and `value_prefix` say where 

388 and how it is written. 

389 """ 

390 

391 model_config = ConfigDict(frozen=True) 

392 kind: Literal[AuthSpecKind.api_key] = AuthSpecKind.api_key 

393 key_source: ApiKeySource 

394 

395 

396class PassthroughConfig(BaseModel): 

397 """Client-driven upstream OAuth; the gateway forwards the client's upstream token.""" 

398 

399 model_config = ConfigDict(frozen=True) 

400 kind: Literal[AuthSpecKind.passthrough] = AuthSpecKind.passthrough 

401 

402 

403class NoneConfig(BaseModel): 

404 """No upstream credential; the request is sent unauthenticated.""" 

405 

406 model_config = ConfigDict(frozen=True) 

407 kind: Literal[AuthSpecKind.none] = AuthSpecKind.none 

408 

409 

410class StaticKeys(BaseModel): 

411 """Long-lived AWS access keys configured on the server.""" 

412 

413 model_config = ConfigDict(frozen=True) 

414 source: Literal["static_keys"] = "static_keys" 

415 access_key_id: str 

416 secret_access_key: SecretStr 

417 session_token: SecretStr | None = None 

418 

419 

420class AssumeRole(BaseModel): 

421 """An IAM role the gateway assumes via STS for short-lived, auto-refreshed credentials.""" 

422 

423 model_config = ConfigDict(frozen=True) 

424 source: Literal["assume_role"] = "assume_role" 

425 role_arn: str 

426 session_name: str | None = None 

427 external_id: str | None = None 

428 

429 

430class Ambient(BaseModel): 

431 """The environment's default AWS credential chain (instance profile, IRSA, env vars).""" 

432 

433 model_config = ConfigDict(frozen=True) 

434 source: Literal["ambient"] = "ambient" 

435 

436 

437AwsCredentialSource = Annotated[StaticKeys | AssumeRole | Ambient, Field(discriminator="source")] 

438 

439 

440class AwsSigV4Config(BaseModel): 

441 """AWS SigV4 per-request signing for an AWS-hosted upstream (e.g. Bedrock AgentCore). The 

442 gateway signs with its own AWS identity, never the caller's; `credentials` selects how that 

443 identity is obtained, defaulting to the ambient credential chain.""" 

444 

445 model_config = ConfigDict(frozen=True) 

446 kind: Literal[AuthSpecKind.aws_sigv4] = AuthSpecKind.aws_sigv4 

447 region: str 

448 service: str = "bedrock-agentcore" 

449 credentials: AwsCredentialSource = Ambient() 

450 

451 

452AuthConfig = Annotated[ 

453 AuthorizationCodeConfig 

454 | ClientCredentialsConfig 

455 | TokenExchangeConfig 

456 | IdJagConfig 

457 | ApiKeyConfig 

458 | PassthroughConfig 

459 | NoneConfig 

460 | AwsSigV4Config, 

461 Field(discriminator="kind"), 

462] 

463 

464 

465class Subject(BaseModel): 

466 """The validated inbound principal. NOT the v1 request object and NOT the LiteLLM key.""" 

467 

468 model_config = ConfigDict(frozen=True) 

469 

470 tenant_id: str 

471 subject_id: str 

472 # Opaque, already-validated inbound identity. Read by `token_exchange`, `passthrough`, and 

473 # `id_jag` (which falls back to the user's stored SSO assertion when it is absent). 

474 inbound_token: SecretStr | None = None 

475 

476 

477class ServerSpec(BaseModel): 

478 """The declared upstream. A v2-native type; the v1 -> v2 adapter maps onto this.""" 

479 

480 model_config = ConfigDict(frozen=True) 

481 

482 server_id: str 

483 resource: str # RFC 8707 audience URI this upstream's tokens are bound to 

484 config: AuthConfig 

485 

486 @property 

487 def auth_spec_kind(self) -> AuthSpecKind: 

488 return self.config.kind 

489 

490 

491def parse_auth_spec_kind(raw: str) -> Result[AuthSpecKind, CredError]: 

492 """Boundary parser — the *only* place an unknown mode is handled, and it fails closed. 

493 

494 Inside the core the mode is always a valid `AuthSpecKind`, so the resolver never needs a 

495 wildcard arm and basedpyright can prove its `match` exhaustive. 

496 """ 

497 try: 

498 return Ok(AuthSpecKind(raw)) 

499 except ValueError: 

500 return Error(CredError.of_unsupported_mode(f"unknown auth_spec_kind: {raw!r}"))