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

304 statements  

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

1"""Shared helpers for the MCP OAuth authorization endpoints 

2(BYOK + discoverable / pass-through OAuth proxy).""" 

3 

4import os 

5from ipaddress import ip_address 

6from typing import TYPE_CHECKING, Any, Final, NoReturn 

7from urllib.parse import ParseResult, urlparse, urlsplit, urlunparse, urlunsplit 

8 

9from fastapi import HTTPException, Request 

10from starlette.types import Scope 

11 

12from litellm._logging import verbose_logger 

13from litellm.proxy._experimental.mcp_server.auth.token_endpoint_auth import ( 

14 TokenEndpointClientAuth, 

15 build_token_endpoint_client_auth, 

16 normalize_token_endpoint_auth_method, 

17) 

18from litellm.proxy.auth.ip_address_utils import IPAddressUtils 

19from litellm.proxy.middleware.per_request_root_path_middleware import get_request_root_path 

20 

21if TYPE_CHECKING: 21 ↛ 22line 21 didn't jump to line 22 because the condition on line 21 was never true

22 from litellm.types.mcp_server.mcp_server_manager import MCPServer 

23 

24# RFC 6749 §5.1 / OAuth 2.1 draft-15 §4.1.3: token-endpoint responses 

25# must not be cached — both success and error bodies may reveal secrets. 

26TOKEN_NO_CACHE_HEADERS: Final = {"Cache-Control": "no-store", "Pragma": "no-cache"} 

27 

28# Stripped from netloc before same-origin comparison so 

29# ``llm.example.com`` matches ``llm.example.com:443`` (load balancers 

30# routinely set X-Forwarded-Port: 443 even when the client URL has no 

31# explicit port, which would otherwise break a literal netloc compare). 

32_DEFAULT_PORTS: Final = {"http": 80, "https": 443} 

33 

34# Sentinel ``upstream_resource`` value meaning "derive the RFC 8707 resource identifier from the 

35# server's own url". RFC 8707 requires an absolute URI, so this can never be a real resource value. 

36UPSTREAM_RESOURCE_AUTO: Final = "auto" 

37 

38# Env var for ops to allowlist additional redirect_uri origins beyond 

39# same-origin + loopback — needed for first-party OAuth clients hosted 

40# on sister domains (e.g. a web app on app.example.com registering as 

41# an OAuth client of the MCP proxy on llm.example.com). Comma-separated; 

42# each entry is ``host`` or ``host:port``; a ``*.`` prefix matches any 

43# subdomain. HTTPS only. 

44_TRUSTED_REDIRECT_ORIGINS_ENV: Final = "MCP_TRUSTED_REDIRECT_ORIGINS" 

45 

46# Comma-separated private-use URI allowlist for native MCP clients. 

47# A trailing ``*`` is a prefix match; end the prefix with ``/`` (e.g. 

48# ``myapp://host/oauth/*``) so ``.../oauth/callback*`` does not also 

49# match ``.../oauth/callback-2``. 

50_TRUSTED_NATIVE_REDIRECT_URIS_ENV: Final = "MCP_TRUSTED_NATIVE_REDIRECT_URIS" 

51 

52# Default allowlist for trusted native redirect URIs. 

53_DEFAULT_NATIVE_REDIRECT_URIS: Final[list[str]] = [ 

54 "cursor://anysphere.cursor-mcp/oauth/callback", 

55] 

56 

57_warned_invalid_proxy_base_url: str | None = None 

58 

59 

60def _oauth_invalid_request( 

61 error_description: str, 

62 *, 

63 hint: str | None = None, 

64 **extra: Any, 

65) -> NoReturn: 

66 """Raise ``invalid_request`` (RFC 6749) with a debuggable description. 

67 

68 FastAPI serializes ``detail`` as JSON. Callers still see ``error``: 

69 ``invalid_request``; ``error_description`` and ``hint`` explain what 

70 failed and how to fix it (e.g. reverse-proxy / PROXY_BASE_URL issues). 

71 """ 

72 detail: Final[dict[str, Any]] = { 

73 "error": "invalid_request", 

74 "error_description": error_description, 

75 } 

76 if hint: 

77 detail["hint"] = hint 

78 detail.update(extra) 

79 raise HTTPException(status_code=400, detail=detail) 

80 

81 

82def _origin_label(scheme: str, netloc: str) -> str: 

83 """Human-readable origin for error messages (scheme + host[:port]).""" 

84 return f"{scheme}://{netloc}" if netloc else f"{scheme}://" 

85 

86 

87def _redact_mcp_resource_url(url: str | None) -> str | None: 

88 """Reduce an MCP server URL to its origin (scheme + host + port) for logging. 

89 

90 Everything else is dropped: userinfo (``user:pass@``), the query string, the 

91 fragment, and the path, because hosted MCP servers routinely embed the 

92 credential in the path (e.g. ``/mcp/s/<token>``) and this value is persisted 

93 in spend-log metadata that a caller who can invoke the tool can read back. 

94 Returns None when the URL has no host to identify (nothing safe to log). 

95 """ 

96 if not isinstance(url, str) or not url: 

97 return None 

98 try: 

99 parts: Final = urlsplit(url) 

100 hostname: Final = parts.hostname 

101 port: Final = parts.port 

102 except ValueError: 

103 return None 

104 if not hostname: 

105 return None 

106 netloc: Final = f"{hostname}:{port}" if port else hostname 

107 return urlunsplit((parts.scheme, netloc, "", "", "")) or None 

108 

109 

110def _resolve_proxy_base_url_env() -> str | None: 

111 global _warned_invalid_proxy_base_url 

112 configured: Final = os.environ.get("PROXY_BASE_URL", "").strip() 

113 if not configured: 113 ↛ 115line 113 didn't jump to line 115 because the condition on line 113 was always true

114 return None 

115 parsed: Final = urlparse(configured) 

116 if parsed.scheme in ("http", "https") and parsed.netloc: 

117 normalized: Final = urlunparse((parsed.scheme, parsed.netloc, parsed.path, "", "", "")) 

118 return normalized.rstrip("/") 

119 if _warned_invalid_proxy_base_url != configured: 

120 verbose_logger.warning( 

121 "PROXY_BASE_URL=%r is not a valid http(s) URL (missing scheme " 

122 "or host) and will be ignored for MCP OAuth origin resolution. " 

123 "Set it to a full URL like https://litellm.example.com.", 

124 configured, 

125 ) 

126 _warned_invalid_proxy_base_url = configured 

127 return None 

128 

129 

130BYOK_RESOURCE_METADATA_PATH: Final = "/v1/mcp/oauth/protected-resource" 

131 

132 

133def get_byok_www_authenticate() -> str: 

134 base_url: Final = _resolve_proxy_base_url_env() or get_request_root_path().rstrip("/") 

135 return f'Bearer resource_metadata="{base_url}{BYOK_RESOURCE_METADATA_PATH}"' 

136 

137 

138def get_request_base_url(request: Request) -> str: 

139 """ 

140 Get the base URL for the request, considering X-Forwarded-* headers. 

141 

142 Resolution order: ``PROXY_BASE_URL`` env var, then X-Forwarded-* when 

143 the caller is a trusted proxy (``use_x_forwarded_for`` enabled AND 

144 caller in ``mcp_trusted_proxy_ranges``), otherwise the request's 

145 literal ``base_url``. Untrusted callers cannot poison OAuth-discovery 

146 / redirect_uri values by injecting headers. 

147 """ 

148 configured: Final = _resolve_proxy_base_url_env() 

149 if configured: 149 ↛ 150line 149 didn't jump to line 150 because the condition on line 149 was never true

150 return configured 

151 

152 base_url: Final = str(request.base_url).rstrip("/") 

153 parsed: Final = urlparse(base_url) 

154 

155 if not IPAddressUtils.is_request_from_trusted_proxy(request): 155 ↛ 158line 155 didn't jump to line 158 because the condition on line 155 was always true

156 return base_url 

157 

158 x_forwarded_proto: Final = request.headers.get("X-Forwarded-Proto") 

159 x_forwarded_host: Final = request.headers.get("X-Forwarded-Host") 

160 x_forwarded_port: Final = request.headers.get("X-Forwarded-Port") 

161 

162 scheme: Final = x_forwarded_proto if x_forwarded_proto else parsed.scheme 

163 

164 if x_forwarded_host: 

165 # X-Forwarded-Host may already include port (e.g., "example.com:8080") 

166 if ":" in x_forwarded_host and not x_forwarded_host.startswith("["): 

167 netloc = x_forwarded_host 

168 elif x_forwarded_port: 

169 netloc = f"{x_forwarded_host}:{x_forwarded_port}" 

170 else: 

171 netloc = x_forwarded_host 

172 else: 

173 netloc = parsed.netloc 

174 if x_forwarded_port and ":" not in netloc: 

175 netloc = f"{netloc}:{x_forwarded_port}" 

176 

177 return urlunparse((scheme, _strip_default_port(scheme, netloc), parsed.path, "", "", "")) 

178 

179 

180def well_known_root_suffix() -> str: 

181 """The ``SERVER_ROOT_PATH`` segment inserted into a ``.well-known`` path (RFC 8414 / 9728 

182 path insertion), empty for a root-mounted proxy or an explicit ``/``. 

183 

184 The discovery route registrations and the 401 challenges that advertise those routes both 

185 derive their path from this one function, so the ``resource_metadata`` URL a client is told 

186 to fetch cannot drift from the route that actually serves it. 

187 """ 

188 root: Final = os.getenv("SERVER_ROOT_PATH", "") 

189 return "" if root == "/" else root 

190 

191 

192def get_route_relative_request_path(scope: Scope) -> str: 

193 """The request path the MCP route shapes are written against: the raw ASGI path with the 

194 deployment's ``root_path`` removed. 

195 

196 ``scope["path"]`` and ``_original_path`` are both raw request-line paths, so on a sub-path 

197 deployment they still carry the ``SERVER_ROOT_PATH`` prefix (``/litellm/{server}/mcp``) while 

198 every route shape compared against them is root-relative. Mirrors the segment-boundary strip in 

199 :func:`litellm.proxy.auth.auth_utils.get_request_route`, which the rest of the MCP auth path 

200 already routes through, so ``/litellmfoo`` is not truncated under ``root_path=/litellm``.""" 

201 raw_path = str(scope.get("_original_path") or scope.get("path", "") or "") 

202 root_path = str(scope.get("app_root_path") or scope.get("root_path") or "").rstrip("/") 

203 if root_path and (raw_path == root_path or raw_path.startswith(f"{root_path}/")): 

204 return raw_path[len(root_path) :] 

205 return raw_path 

206 

207 

208def get_passthrough_resource_metadata_url(scope: Scope, server_name: str) -> str: 

209 """The per-server protected-resource metadata URL matching the spelling the request 

210 arrived on, so a strict RFC 9728 client resolves the same route the proxy registered. 

211 ``_original_path`` preserves the ``/{server}/mcp`` spelling through the 

212 ``dynamic_mcp_route`` rewrite; the ``SERVER_ROOT_PATH`` segment is inserted exactly as 

213 the route decorators insert it (see :func:`well_known_root_suffix`).""" 

214 request: Final = Request(scope) 

215 base_url: Final = get_request_base_url(request) 

216 _path: Final = get_route_relative_request_path(scope) 

217 

218 if _path.startswith(f"/{server_name}/mcp"): 

219 return f"{base_url}/.well-known/oauth-protected-resource{well_known_root_suffix()}/{server_name}/mcp" 

220 return f"{base_url}/.well-known/oauth-protected-resource{well_known_root_suffix()}/mcp/{server_name}" 

221 

222 

223def get_passthrough_www_authenticate( 

224 scope: Scope, 

225 server_name: str, 

226 invalid_token: bool = False, 

227) -> str: 

228 """The RFC 9728 ``WWW-Authenticate`` value advertising the per-server 

229 protected-resource metadata, with the RFC 6750 ``invalid_token`` error code when the 

230 caller presented a bearer that failed rather than no credential at all.""" 

231 resource_metadata_url: Final = get_passthrough_resource_metadata_url( 

232 scope=scope, 

233 server_name=server_name, 

234 ) 

235 error_attr: Final = 'error="invalid_token", ' if invalid_token else "" 

236 return f'Bearer {error_attr}resource_metadata="{resource_metadata_url}"' 

237 

238 

239def validate_loopback_redirect_uri(redirect_uri: str) -> None: 

240 """Require a loopback ``redirect_uri`` (OAuth 2.1 §4.1.2.1 + RFC 8252 

241 §7.3 native-app pattern). MCP clients are native apps that listen on 

242 a localhost port; rejecting non-loopback URIs prevents a malicious 

243 client from pointing the callback at its own server to capture the 

244 authorization code — the credential-theft primitive behind VERIA-57 

245 and pNr1PHa9. 

246 

247 Accepts the literal ``localhost`` plus any IP in the loopback ranges 

248 (IPv4 ``127.0.0.0/8`` and IPv6 ``::1``). A string match on 

249 ``"127.0.0.1"`` alone would miss ``127.0.0.2`` and the full-form 

250 IPv6 loopback ``0:0:0:0:0:0:0:1``. 

251 """ 

252 parsed: Final = _parse_redirect_uri_for_validation(redirect_uri) 

253 if parsed.scheme not in ("http", "https"): 

254 _oauth_invalid_request( 

255 f"redirect_uri scheme {parsed.scheme!r} is not allowed; use http or https.", 

256 ) 

257 if parsed.fragment: 

258 _oauth_invalid_request( 

259 "redirect_uri must not contain a URL fragment (#...).", 

260 ) 

261 host: Final = (parsed.hostname or "").lower() 

262 if host == "localhost": 

263 return 

264 try: 

265 if ip_address(host).is_loopback: 

266 return 

267 except ValueError: 

268 # Unparseable host (malformed IPv6, etc.) — treat as invalid, 

269 # don't let it bubble up as a 500. 

270 pass 

271 _oauth_invalid_request( 

272 "redirect_uri must use a loopback host (localhost or 127.0.0.0/8).", 

273 hint="Native MCP clients should register a callback on http://127.0.0.1:<port>/...", 

274 ) 

275 

276 

277def _strip_default_port(scheme: str, netloc: str) -> str: 

278 """Return ``netloc`` lowercased with the scheme's default port 

279 stripped. ``Llm.Example.com:443`` with scheme ``https`` becomes 

280 ``llm.example.com``. Used so a literal netloc comparison between 

281 the proxy's origin and the client redirect_uri survives a load- 

282 balancer that sets ``X-Forwarded-Port: 443``. 

283 """ 

284 if not netloc: 

285 return netloc 

286 lowered: Final = netloc.lower() 

287 if lowered.startswith("["): 

288 # IPv6 literal: port (if any) appears after the "]". 

289 close: Final = lowered.rfind("]") 

290 if close != -1 and lowered[close + 1 :].startswith(":"): 

291 try: 

292 port = int(lowered[close + 2 :]) 

293 except ValueError: 

294 return lowered 

295 if _DEFAULT_PORTS.get(scheme) == port: 

296 return lowered[: close + 1] 

297 return lowered 

298 if ":" in lowered: 

299 host, _, port_str = lowered.rpartition(":") 

300 try: 

301 port = int(port_str) 

302 except ValueError: 

303 return lowered 

304 if _DEFAULT_PORTS.get(scheme) == port: 

305 return host 

306 return lowered 

307 

308 

309def _parse_trusted_redirect_origins() -> list[str]: 

310 """Parse ``MCP_TRUSTED_REDIRECT_ORIGINS`` into normalized entries. 

311 Empty / unset env var → empty list. Entries are lowercased and any 

312 scheme / path component the operator included is stripped. Default 

313 ``:443`` is also stripped from non-wildcard entries so 

314 ``app.example.com:443`` matches a redirect_netloc whose own ``:443`` 

315 has already been normalized away — the allowlist path is https-only, 

316 so ``:443`` is the only default port that can legitimately appear. 

317 """ 

318 raw: Final = os.environ.get(_TRUSTED_REDIRECT_ORIGINS_ENV, "").strip() 

319 if not raw: 

320 return [] 

321 entries: Final[list[str]] = [] 

322 for token in raw.split(","): 

323 entry = token.strip().lower() 

324 if not entry: 

325 continue 

326 if "://" in entry: 

327 entry = entry.split("://", 1)[1] 

328 entry = entry.split("/", 1)[0] 

329 if not entry: 

330 continue 

331 # Wildcards don't express port constraints; leave them alone. 

332 if not entry.startswith("*."): 

333 entry = _strip_default_port("https", entry) 

334 if entry: 

335 entries.append(entry) 

336 return entries 

337 

338 

339def _matches_trusted_origin_entry(netloc: str, entry: str) -> bool: 

340 """``entry`` is either ``host[:port]`` (exact match after port 

341 normalization) or ``*.suffix`` (subdomain wildcard; matches any 

342 strictly-deeper subdomain of ``suffix`` but not ``suffix`` itself). 

343 ``netloc`` is the already-port-normalized, lowercased netloc of 

344 the redirect_uri being validated. 

345 """ 

346 if entry.startswith("*."): 

347 suffix: Final = entry[2:] 

348 if not suffix or suffix.startswith("."): 

349 return False 

350 # Strip port from netloc for wildcard host comparison; 

351 # wildcards don't express port constraints. 

352 host: Final = netloc.split(":", 1)[0] if ":" in netloc else netloc 

353 return host != suffix and host.endswith("." + suffix) 

354 return netloc == entry 

355 

356 

357def _normalize_native_redirect_uri( 

358 parsed, 

359) -> str: 

360 """Lowercase scheme, netloc, and path for allowlist comparison.""" 

361 return urlunparse( 

362 ( 

363 (parsed.scheme or "").lower(), 

364 (parsed.netloc or "").lower(), 

365 (parsed.path or "").lower(), 

366 "", 

367 "", 

368 "", 

369 ) 

370 ) 

371 

372 

373def _parse_trusted_native_redirect_uris() -> list[str]: 

374 """Built-in native MCP callbacks plus ``MCP_TRUSTED_NATIVE_REDIRECT_URIS``.""" 

375 entries: Final[list[str]] = [uri.lower() for uri in _DEFAULT_NATIVE_REDIRECT_URIS] 

376 raw: Final = os.environ.get(_TRUSTED_NATIVE_REDIRECT_URIS_ENV, "").strip() 

377 if not raw: 

378 return entries 

379 for token in raw.split(","): 

380 entry = token.strip().lower() 

381 if entry and entry not in entries: 

382 entries.append(entry) 

383 return entries 

384 

385 

386def _native_wildcard_prefix_matches(normalized: str, prefix: str) -> bool: 

387 """Prefix match for ``entry*`` allowlist rows. 

388 

389 When the prefix does not end with ``/``, only exact matches or 

390 deeper path segments (``prefix/...``) are accepted — not siblings 

391 like ``prefix-2``. 

392 """ 

393 if not normalized.startswith(prefix): 

394 return False 

395 suffix: Final = normalized[len(prefix) :] 

396 if not suffix: 

397 return True 

398 if prefix.endswith("/"): 

399 return True 

400 return suffix[0] == "/" 

401 

402 

403def _matches_trusted_native_redirect_uri(parsed) -> bool: 

404 """Allowlisted private-use / custom-scheme OAuth callbacks for native MCP clients.""" 

405 if parsed.fragment: 

406 return False 

407 # Query strings are not part of registered redirect_uris (RFC 6749 §3.1.2). 

408 # Rejecting them prevents allowlist bypass via ``.../callback?injected=...``. 

409 if parsed.query: 

410 return False 

411 if not parsed.netloc: 

412 return False 

413 if parsed.username is not None or parsed.password is not None: 

414 return False 

415 if "\\" in parsed.netloc: 

416 return False 

417 

418 normalized: Final = _normalize_native_redirect_uri(parsed) 

419 for entry in _parse_trusted_native_redirect_uris(): 

420 if entry.endswith("*"): 

421 if _native_wildcard_prefix_matches(normalized, entry[:-1]): 

422 return True 

423 elif normalized == entry: 

424 return True 

425 return False 

426 

427 

428def _parse_redirect_uri_for_validation(redirect_uri: str) -> ParseResult: 

429 try: 

430 return urlparse(redirect_uri) 

431 except ValueError: 

432 _oauth_invalid_request( 

433 "redirect_uri is not a valid URL.", 

434 hint="Use a full absolute URL for redirect_uri (e.g. https://your-host/ui/mcp/oauth/callback).", 

435 ) 

436 

437 

438def is_loopback_redirect_host(parsed: ParseResult) -> bool: 

439 """True when the redirect host is loopback (RFC 8252 section 7.3). 

440 

441 Shared by every redirect-URI policy in the MCP OAuth surface so that none of them 

442 hand-rolls its own host list: a literal ``("localhost", "127.0.0.1", "::1")`` tuple 

443 silently misses the rest of 127.0.0.0/8 and IPv6-mapped forms. 

444 """ 

445 host: Final = (parsed.hostname or "").lower() 

446 if host == "localhost": 

447 return True 

448 try: 

449 return ip_address(host).is_loopback 

450 except ValueError: 

451 return False 

452 

453 

454def validate_redirect_uri_shape(parsed: ParseResult) -> bool: 

455 """Validate redirect-URI *hygiene* and resolve allowlisted native callbacks. 

456 

457 Returns True when ``parsed`` is an allowlisted native callback (the caller may accept 

458 it outright); returns False for http/https, leaving the trust decision to the caller; 

459 raises for a URI that no policy should ever accept (bad scheme, fragment, missing 

460 host, userinfo, backslash in the host). 

461 

462 This is deliberately separate from :func:`validate_trusted_redirect_uri`, which adds 

463 the *first-party* trust policy (same-origin, loopback, ops allowlist) appropriate to 

464 the proxy's own OAuth endpoints. Public dynamic-client registration accepts any https 

465 client and relies on PKCE plus the consent screen instead, so it shares this hygiene 

466 rule but not that trust policy. 

467 """ 

468 if parsed.scheme not in ("http", "https"): 

469 if _matches_trusted_native_redirect_uri(parsed): 

470 return True 

471 _oauth_invalid_request( 

472 f"redirect_uri scheme {parsed.scheme!r} is not allowed; use http/https " 

473 "or a registered native callback (e.g. cursor://).", 

474 hint="Add the full URI to MCP_TRUSTED_NATIVE_REDIRECT_URIS for custom native clients.", 

475 ) 

476 if parsed.fragment: 

477 _oauth_invalid_request( 

478 "redirect_uri must not contain a URL fragment (#...).", 

479 ) 

480 if not parsed.netloc: 

481 _oauth_invalid_request( 

482 "redirect_uri must include a host (e.g. https://your-host/path).", 

483 ) 

484 if parsed.username is not None or parsed.password is not None: 

485 _oauth_invalid_request( 

486 "redirect_uri must not contain userinfo (user:pass@host).", 

487 ) 

488 if "\\" in parsed.netloc: 

489 _oauth_invalid_request( 

490 "redirect_uri host must not contain backslashes.", 

491 ) 

492 return False 

493 

494 

495def _resolve_proxy_base_for_redirect(request: Request) -> str | None: 

496 try: 

497 return get_request_base_url(request) 

498 except Exception as exc: 

499 verbose_logger.warning( 

500 "validate_trusted_redirect_uri: could not determine proxy origin, " 

501 "falling back to loopback + allowlist. error=%s", 

502 exc, 

503 ) 

504 return None 

505 

506 

507def _trusted_redirect_uri_is_allowed( 

508 parsed: ParseResult, 

509 redirect_netloc: str, 

510 proxy_base: str | None, 

511) -> bool: 

512 if proxy_base: 

513 proxy_parsed: Final = urlparse(proxy_base) 

514 if parsed.scheme == proxy_parsed.scheme and redirect_netloc == _strip_default_port( 

515 proxy_parsed.scheme, proxy_parsed.netloc 

516 ): 

517 return True 

518 

519 if is_loopback_redirect_host(parsed): 

520 return True 

521 

522 if parsed.scheme == "https": 

523 for entry in _parse_trusted_redirect_origins(): 

524 if _matches_trusted_origin_entry(redirect_netloc, entry): 

525 return True 

526 return False 

527 

528 

529def _build_trusted_redirect_rejection_message( 

530 redirect_uri: str, 

531 parsed: ParseResult, 

532 redirect_netloc: str, 

533 proxy_base: str | None, 

534) -> str: 

535 """Build a client-facing rejection message. 

536 

537 Intentionally omits the proxy's resolved scheme / host / port to avoid 

538 leaking internal network topology (e.g. ``http://litellm-internal:4000``) 

539 through an unauthenticated endpoint. Full diagnostic detail — including 

540 the computed proxy base — is logged server-side by the caller. 

541 """ 

542 redirect_origin: Final = _origin_label(parsed.scheme, redirect_netloc) 

543 proxy_parsed: Final = urlparse(proxy_base) if proxy_base else None 

544 proxy_netloc_norm: Final = ( 

545 _strip_default_port(proxy_parsed.scheme, proxy_parsed.netloc) if proxy_parsed and proxy_parsed.netloc else "" 

546 ) 

547 

548 mismatch_parts: Final[list[str]] = [] 

549 if proxy_parsed and proxy_parsed.netloc: 

550 if parsed.scheme != proxy_parsed.scheme: 

551 mismatch_parts.append( 

552 f"scheme: redirect_uri uses {parsed.scheme!r}, but the proxy " 

553 "resolved a different scheme " 

554 "(TLS often terminates at ingress — set PROXY_BASE_URL to https://… " 

555 "or trust X-Forwarded-Proto from your ingress)" 

556 ) 

557 if redirect_netloc != proxy_netloc_norm: 

558 mismatch_parts.append(f"host/port: redirect_uri {redirect_netloc!r} does not match the proxy origin") 

559 

560 if mismatch_parts: 

561 return f"redirect_uri origin ({redirect_origin}) does not match the proxy origin. " + "; ".join(mismatch_parts) 

562 return ( 

563 f"redirect_uri ({redirect_uri!r}) is not allowed: not same-origin with " 

564 f"the proxy origin, not loopback, and not listed in " 

565 f"{_TRUSTED_REDIRECT_ORIGINS_ENV}." 

566 ) 

567 

568 

569def _raise_trusted_redirect_uri_rejected( 

570 request: Request, 

571 redirect_uri: str, 

572 parsed: ParseResult, 

573 redirect_netloc: str, 

574 proxy_base: str | None, 

575) -> NoReturn: 

576 description: Final = _build_trusted_redirect_rejection_message(redirect_uri, parsed, redirect_netloc, proxy_base) 

577 

578 hint: Final = ( 

579 "Align the proxy public URL with the browser URL. Set PROXY_BASE_URL to your " 

580 "HTTPS origin (e.g. https://litellm.example.com), or enable " 

581 "general_settings.use_x_forwarded_for with mcp_trusted_proxy_ranges for your " 

582 "ingress. If the redirect_uri is a legitimate separate-origin OAuth client " 

583 "(e.g. a web app registering with the proxy from another host via dynamic client " 

584 f"registration), add its origin to {_TRUSTED_REDIRECT_ORIGINS_ENV}. " 

585 "Verify: curl https://<host>/.well-known/oauth-authorization-server " 

586 "| jq .issuer — issuer must match window.location.origin in the UI." 

587 ) 

588 

589 verbose_logger.warning( 

590 "MCP OAuth: rejecting redirect_uri %r. %s " 

591 "Computed proxy base=%r (PROXY_BASE_URL=%r). " 

592 "Inbound headers: X-Forwarded-Proto=%r X-Forwarded-Host=%r " 

593 "X-Forwarded-Port=%r Host=%r. " 

594 "Trusted-redirect-origins env=%r. " 

595 "Trusted-native-redirect-uris env=%r.", 

596 redirect_uri, 

597 description, 

598 proxy_base, 

599 os.environ.get("PROXY_BASE_URL"), 

600 request.headers.get("X-Forwarded-Proto"), 

601 request.headers.get("X-Forwarded-Host"), 

602 request.headers.get("X-Forwarded-Port"), 

603 request.headers.get("Host"), 

604 os.environ.get(_TRUSTED_REDIRECT_ORIGINS_ENV), 

605 os.environ.get(_TRUSTED_NATIVE_REDIRECT_URIS_ENV), 

606 ) 

607 

608 _oauth_invalid_request( 

609 description, 

610 hint=hint, 

611 redirect_uri=redirect_uri, 

612 ) 

613 

614 

615def validate_trusted_redirect_uri(request: Request, redirect_uri: str) -> None: 

616 """Accept ``redirect_uri`` when it is (a) same-origin with the 

617 proxy's own request origin, (b) loopback, (c) listed in the 

618 ``MCP_TRUSTED_REDIRECT_ORIGINS`` ops allowlist, or (d) a built-in / 

619 env-configured native MCP client callback (e.g. ``cursor://``). 

620 

621 Same-origin is VERIA-57's threat-model-safe equivalent of loopback: 

622 an attacker who can host content on the proxy's own HTTPS origin 

623 has already compromised the proxy, so the open-redirect + code- 

624 theft primitive that motivated the loopback-only rule does not 

625 apply. The same reasoning extends to ops-trusted first-party 

626 hosts (e.g. an internal web app registering as an OAuth client of 

627 the proxy on a sister domain). 

628 

629 Allowlisted non-loopback hosts are accepted only when the 

630 redirect_uri scheme is ``https`` — an attacker on the network 

631 cannot elevate to https without controlling the host's TLS key. 

632 

633 Use this in the discoverable OAuth proxy endpoints that serve both 

634 native clients and the proxy's UI / cross-origin web clients. The 

635 BYOK endpoints, which only serve native MCP clients, retain 

636 :func:`validate_loopback_redirect_uri`. 

637 """ 

638 parsed: Final = _parse_redirect_uri_for_validation(redirect_uri) 

639 if validate_redirect_uri_shape(parsed): 

640 return 

641 redirect_netloc: Final = _strip_default_port(parsed.scheme, parsed.netloc) 

642 proxy_base: Final = _resolve_proxy_base_for_redirect(request) 

643 if _trusted_redirect_uri_is_allowed(parsed, redirect_netloc, proxy_base): 

644 return 

645 _raise_trusted_redirect_uri_rejected(request, redirect_uri, parsed, redirect_netloc, proxy_base) 

646 

647 

648def canonicalize_url_identity(url: str) -> str: 

649 """Normalize a URL to a comparable identity: lowercase scheme and host, drop the scheme's default 

650 port, and strip userinfo, params, query, fragment and a trailing slash while keeping IPv6 

651 brackets. The one URL-canonicalization primitive shared by the RFC 8707 resource emitter and the 

652 RFC 8414 issuer/authorize-endpoint comparison, so the default-port and IPv6 rules cannot be 

653 present in one and missing in the other. The netloc (not ``parsed.hostname``) carries the 

654 authority so ``[::1]:8080`` survives with its brackets intact.""" 

655 parsed: Final = urlparse(url) 

656 scheme: Final = parsed.scheme.lower() 

657 netloc: Final = _strip_default_port(scheme, parsed.netloc.rpartition("@")[2]) 

658 return urlunparse((scheme, netloc, parsed.path.rstrip("/"), "", "", "")) 

659 

660 

661def canonical_resource_uri(url: str) -> str | None: 

662 """Canonicalize an upstream MCP server URL into an RFC 8707 resource identifier. 

663 

664 Keeps only the scheme, host, port and path, which is the shape the MCP authorization spec's 

665 "Canonical Server URI" section describes and every one of its examples takes; the reference 

666 implementation is ``mcp.shared.auth_utils.resource_url_from_server_url``, and this is the stricter 

667 variant. The scheme and host are lowercased, the scheme's default port is dropped so 

668 ``https://host:443/mcp`` and ``https://host/mcp`` never present as two resources, and a trailing 

669 slash is dropped so ``https://host/mcp/`` and ``https://host/mcp`` do not either. 

670 

671 Userinfo, query and fragment are dropped rather than carried. A transport URL routinely holds 

672 credentials in exactly those components (``user:password@``, ``?api_key=``), while a resource 

673 indicator names the resource and nothing else; this value is published somewhere the transport 

674 URL never goes, into the authorization redirect the browser follows and into token request 

675 bodies, so carrying them would disclose them to the authorization server, its logs, and browser 

676 history. RFC 8707 forbids a fragment outright and says a resource SHOULD NOT carry a query. An 

677 upstream whose identifier genuinely needs more than this is served by setting 

678 ``upstream_resource`` explicitly, which is passed through untouched. 

679 

680 Returns ``None`` when the URL is not absolute, which cannot yield a valid resource identifier. 

681 """ 

682 parsed: Final = urlparse(url) 

683 if not parsed.scheme or not parsed.netloc: 

684 return None 

685 return canonicalize_url_identity(url) 

686 

687 

688def resolve_upstream_resource(mcp_server: "MCPServer") -> str | None: 

689 """Resolve the RFC 8707 ``resource`` value this server's upstream OAuth legs must carry. 

690 

691 The MCP authorization spec requires an MCP client to send ``resource`` on both the 

692 authorization request and every token request, naming the canonical URI of the MCP server the 

693 token is for. Authorization server temperaments are irreconcilable and undetectable, so this 

694 stays an explicit per-server opt-in: most SaaS providers ignore the parameter, some hard-reject 

695 it and express audience through scopes instead, and strict or MCP-native ones refuse to mint a 

696 correctly scoped token without it (``invalid_target``). 

697 

698 ``None`` or blank omits the parameter, which is the default and preserves the behavior of every 

699 server working today. ``"auto"`` derives the canonical URI from the server's own URL; it is not 

700 an absolute URI, so RFC 8707 guarantees it can never collide with a real resource value. Any 

701 other value is sent verbatim, because the identifier has to match what the authorization server 

702 expects exactly and normalizing it could break that match. 

703 

704 Every upstream leg for a server resolves through this one function, so the authorize request 

705 and the token requests cannot disagree; a token request naming a resource the authorization 

706 request never asked for is itself an ``invalid_target`` under RFC 8707. 

707 """ 

708 configured: Final = (mcp_server.upstream_resource or "").strip() 

709 if not configured: 

710 return None 

711 if configured.lower() != UPSTREAM_RESOURCE_AUTO: 

712 return configured 

713 if not mcp_server.url: 

714 verbose_logger.warning( 

715 "MCP server %s sets upstream_resource=auto but has no url to derive a resource " 

716 "identifier from; omitting the RFC 8707 resource parameter. Set upstream_resource to " 

717 "the exact resource identifier the authorization server expects instead.", 

718 mcp_server.server_id, 

719 ) 

720 return None 

721 canonical: Final = canonical_resource_uri(mcp_server.url) 

722 if canonical is None: 

723 verbose_logger.warning( 

724 "MCP server %s sets upstream_resource=auto but its url is not an absolute URI, so no " 

725 "RFC 8707 resource identifier could be derived; omitting the resource parameter", 

726 mcp_server.server_id, 

727 ) 

728 return canonical 

729 

730 

731def build_upstream_oauth2_token_request( 

732 mcp_server: "MCPServer", 

733 *, 

734 auth_method: object, 

735 client_id: str | None, 

736 client_secret: str | None, 

737) -> TokenEndpointClientAuth: 

738 """Client auth plus the RFC 8707 ``resource`` for one upstream plain-OAuth2 token request. 

739 

740 Resolving both in one call is what stops a leg authenticating without naming the resource its 

741 sibling legs named; the RFC 8693 legs (OBO, id_jag) carry ``audience`` and stay on 

742 ``build_token_endpoint_client_auth``. The client-auth inputs are passed in because a leg may 

743 authenticate as the caller's own client rather than the server's; ``resource`` always comes from 

744 the server, so no leg can choose or forget it. 

745 """ 

746 client_auth: Final = build_token_endpoint_client_auth( 

747 auth_method=normalize_token_endpoint_auth_method(auth_method), 

748 client_id=client_id, 

749 client_secret=client_secret, 

750 ) 

751 resource: Final = resolve_upstream_resource(mcp_server) 

752 if not resource: 

753 return client_auth 

754 return TokenEndpointClientAuth(headers=client_auth.headers, body={**client_auth.body, "resource": resource})