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
« 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)."""
4import os
5from ipaddress import ip_address
6from typing import TYPE_CHECKING, Any, Final, NoReturn
7from urllib.parse import ParseResult, urlparse, urlsplit, urlunparse, urlunsplit
9from fastapi import HTTPException, Request
10from starlette.types import Scope
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
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
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"}
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}
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"
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"
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"
52# Default allowlist for trusted native redirect URIs.
53_DEFAULT_NATIVE_REDIRECT_URIS: Final[list[str]] = [
54 "cursor://anysphere.cursor-mcp/oauth/callback",
55]
57_warned_invalid_proxy_base_url: str | None = None
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.
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)
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}://"
87def _redact_mcp_resource_url(url: str | None) -> str | None:
88 """Reduce an MCP server URL to its origin (scheme + host + port) for logging.
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
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
130BYOK_RESOURCE_METADATA_PATH: Final = "/v1/mcp/oauth/protected-resource"
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}"'
138def get_request_base_url(request: Request) -> str:
139 """
140 Get the base URL for the request, considering X-Forwarded-* headers.
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
152 base_url: Final = str(request.base_url).rstrip("/")
153 parsed: Final = urlparse(base_url)
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
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")
162 scheme: Final = x_forwarded_proto if x_forwarded_proto else parsed.scheme
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}"
177 return urlunparse((scheme, _strip_default_port(scheme, netloc), parsed.path, "", "", ""))
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 ``/``.
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
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.
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
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)
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}"
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}"'
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.
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 )
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
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
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
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 )
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
386def _native_wildcard_prefix_matches(normalized: str, prefix: str) -> bool:
387 """Prefix match for ``entry*`` allowlist rows.
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] == "/"
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
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
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 )
438def is_loopback_redirect_host(parsed: ParseResult) -> bool:
439 """True when the redirect host is loopback (RFC 8252 section 7.3).
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
454def validate_redirect_uri_shape(parsed: ParseResult) -> bool:
455 """Validate redirect-URI *hygiene* and resolve allowlisted native callbacks.
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).
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
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
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
519 if is_loopback_redirect_host(parsed):
520 return True
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
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.
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 )
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")
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 )
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)
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 )
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 )
608 _oauth_invalid_request(
609 description,
610 hint=hint,
611 redirect_uri=redirect_uri,
612 )
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://``).
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).
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.
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)
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("/"), "", "", ""))
661def canonical_resource_uri(url: str) -> str | None:
662 """Canonicalize an upstream MCP server URL into an RFC 8707 resource identifier.
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.
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.
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)
688def resolve_upstream_resource(mcp_server: "MCPServer") -> str | None:
689 """Resolve the RFC 8707 ``resource`` value this server's upstream OAuth legs must carry.
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``).
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.
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
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.
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})