mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-15 09:12:18 +00:00
fix(proxy): tie 401 challenges and get_custom_url to the per-request root_path
The per-request root_path middleware sets scope["root_path"] to the prefix the client actually called, but the OAuth 401 challenges (raise_user_oauth_challenge / raise_token_exchange_challenge) still built their resource_metadata from SERVER_ROOT_PATH. On a pod fronting several prefixes, the challenge advertised a discovery URL under a different prefix than the discovery document served — the two disagreed on where the resource metadata lives, and a strict RFC 9728 client refused the challenge. Route the challenges through a small ContextVar the middleware populates so they read the same effective root_path Starlette resolves the request under. The same accessor fixes get_custom_url: when a request lives under a SERVER_ROOT_PATHS-matched prefix, request.base_url already carries it, so appending the SERVER_ROOT_PATH scalar on top produced e.g. /tenant-a/legacy/sso/callback — a path that does not exist. Reading the per-request prefix instead (and relying on join_paths's tail-dedup) keeps SSO login/callback URLs under one prefix — the one the request actually arrived on. Fallback: outside a request (module-load-time UI URL builders, background tasks) the ContextVar is unset and the accessor reads SERVER_ROOT_PATH, matching get_server_root_path() so scalar-only deployments are byte-identical.
This commit is contained in:
parent
1603f04b39
commit
917b25666e
6 changed files with 233 additions and 7 deletions
|
|
@ -148,7 +148,10 @@ from litellm.proxy._types import (
|
|||
from litellm.proxy.auth.ip_address_utils import IPAddressUtils
|
||||
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper
|
||||
from litellm.proxy.common_utils.user_api_key_cache import get_management_object_ttl
|
||||
from litellm.proxy.utils import PrismaClient, ProxyLogging, get_server_root_path
|
||||
from litellm.proxy.middleware.per_request_root_path_middleware import (
|
||||
get_request_root_path,
|
||||
)
|
||||
from litellm.proxy.utils import PrismaClient, ProxyLogging
|
||||
from litellm.repositories.table_repositories import MCPServerRepository
|
||||
from litellm.types.llms.custom_http import httpxSpecialProvider
|
||||
from litellm.types.mcp import (
|
||||
|
|
@ -3548,7 +3551,7 @@ class MCPServerManager:
|
|||
if err.tag == "unauthorized" and isinstance(spec.config, AuthorizationCodeConfig):
|
||||
# authorization_code's missing per-user token -> the per-server browser-OAuth
|
||||
# challenge, built here where the full MCPServer is in hand.
|
||||
raise_user_oauth_challenge(server, root_path=get_server_root_path())
|
||||
raise_user_oauth_challenge(server, root_path=get_request_root_path())
|
||||
if err.tag == "unauthorized" and isinstance(spec.config, TokenExchangeConfig):
|
||||
# token_exchange (OBO): a missing/rejected subject token -> the RFC 9728 challenge
|
||||
# pointing at the IdP the client must SSO with to obtain one, rather than an opaque
|
||||
|
|
@ -3556,7 +3559,7 @@ class MCPServerManager:
|
|||
# Access) threads its claims blob into the challenge for the client to satisfy.
|
||||
raise_token_exchange_challenge(
|
||||
server,
|
||||
root_path=get_server_root_path(),
|
||||
root_path=get_request_root_path(),
|
||||
claims=err.unauthorized.claims,
|
||||
)
|
||||
raise_public(err)
|
||||
|
|
@ -3591,7 +3594,7 @@ class MCPServerManager:
|
|||
if err.tag == "unauthorized":
|
||||
raise_token_exchange_challenge(
|
||||
resolved_server,
|
||||
root_path=get_server_root_path(),
|
||||
root_path=get_request_root_path(),
|
||||
claims=err.unauthorized.claims,
|
||||
)
|
||||
raise_public(err)
|
||||
|
|
|
|||
|
|
@ -3841,9 +3841,11 @@ if MCP_AVAILABLE:
|
|||
from litellm.proxy._experimental.mcp_server.outbound_credentials.adapter import ( # noqa: PLC0415
|
||||
raise_token_exchange_challenge,
|
||||
)
|
||||
from litellm.proxy.utils import get_server_root_path # noqa: PLC0415
|
||||
from litellm.proxy.middleware.per_request_root_path_middleware import ( # noqa: PLC0415
|
||||
get_request_root_path,
|
||||
)
|
||||
|
||||
raise_token_exchange_challenge(server, root_path=get_server_root_path())
|
||||
raise_token_exchange_challenge(server, root_path=get_request_root_path())
|
||||
|
||||
# token_exchange (OBO) with a subject present: run the exchange here at the transport
|
||||
# edge, so a rejected subject raises the RFC 9728 challenge (and a gateway fault its
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ Opt-in: with ``SERVER_ROOT_PATHS`` unset the middleware is not added at all.
|
|||
|
||||
import os
|
||||
from collections.abc import Sequence
|
||||
from contextvars import ContextVar
|
||||
from typing import Final
|
||||
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
|
@ -21,6 +22,31 @@ from litellm._logging import verbose_proxy_logger
|
|||
|
||||
SERVER_ROOT_PATHS_ENV: Final = "SERVER_ROOT_PATHS"
|
||||
|
||||
# The effective ``root_path`` for the currently-handled request. Populated by
|
||||
# ``PerRequestRootPathMiddleware`` from the (possibly-mutated) scope so code
|
||||
# that emits URLs off the request path — the 401 challenges' resource_metadata
|
||||
# and ``get_custom_url``'s SSO callbacks among them — can pick up the prefix
|
||||
# the client actually called without threading scope through every call site.
|
||||
# ``None`` means "middleware did not run" (the ``SERVER_ROOT_PATHS`` env is
|
||||
# unset, so no per-request prefix exists); readers fall back to the scalar
|
||||
# ``SERVER_ROOT_PATH`` in that case, which matches the pre-middleware behavior.
|
||||
_request_root_path_var: Final[ContextVar[str | None]] = ContextVar("_request_root_path_var", default=None)
|
||||
|
||||
|
||||
def get_request_root_path() -> str:
|
||||
"""Return the effective ``root_path`` for the current request.
|
||||
|
||||
Reads the value ``PerRequestRootPathMiddleware`` stashed for this request;
|
||||
falls back to the ``SERVER_ROOT_PATH`` env when the middleware did not run
|
||||
(i.e. ``SERVER_ROOT_PATHS`` is unset — the scalar-only deployment). The
|
||||
fallback keeps the return value identical to ``get_server_root_path()`` for
|
||||
every deployment that has not opted into the multi-prefix mechanism.
|
||||
"""
|
||||
value: Final = _request_root_path_var.get()
|
||||
if value is not None:
|
||||
return value
|
||||
return os.getenv("SERVER_ROOT_PATH", "")
|
||||
|
||||
|
||||
def normalize_root_paths(raw_paths: Sequence[str]) -> tuple[str, ...]:
|
||||
"""Strip whitespace and trailing slashes, dedupe, order longest-first;
|
||||
|
|
@ -78,4 +104,14 @@ class PerRequestRootPathMiddleware:
|
|||
if path == prefix or path.startswith(prefix + "/"):
|
||||
scope["root_path"] = prefix
|
||||
break
|
||||
# Stash the effective root_path (matched prefix, or the scope's
|
||||
# existing value when nothing matched — i.e. FastAPI's scalar
|
||||
# SERVER_ROOT_PATH) so code that emits URLs off the request path
|
||||
# picks the same prefix the router will resolve the request under.
|
||||
token: Final = _request_root_path_var.set(str(scope.get("root_path", "")))
|
||||
try:
|
||||
await self.app(scope, receive, send)
|
||||
finally:
|
||||
_request_root_path_var.reset(token)
|
||||
return
|
||||
await self.app(scope, receive, send)
|
||||
|
|
|
|||
|
|
@ -7189,7 +7189,19 @@ def get_custom_url(request_base_url: str, route: str | None = None) -> str:
|
|||
else:
|
||||
base_url = request_base_url
|
||||
|
||||
server_root_path: Final = get_server_root_path()
|
||||
# get_request_root_path() returns the prefix the router is actually
|
||||
# resolving this request under: the matched SERVER_ROOT_PATHS entry when
|
||||
# PerRequestRootPathMiddleware ran, otherwise the SERVER_ROOT_PATH scalar.
|
||||
# This keeps the emitted URL under one prefix — the one the client called —
|
||||
# instead of stacking the scalar onto a request already living under a
|
||||
# dynamic prefix (which would produce /tenant-a/legacy/... — a path that
|
||||
# doesn't exist). join_paths()'s tail-dedup then collapses the append when
|
||||
# base_url (i.e. request.base_url) already ends in the same prefix.
|
||||
from litellm.proxy.middleware.per_request_root_path_middleware import ( # noqa: PLC0415
|
||||
get_request_root_path,
|
||||
)
|
||||
|
||||
server_root_path: Final = get_request_root_path()
|
||||
if route is not None:
|
||||
if server_root_path != "":
|
||||
# First join base_url with server_root_path, then with route
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from fastapi.testclient import TestClient
|
|||
|
||||
from litellm.proxy.middleware.per_request_root_path_middleware import (
|
||||
PerRequestRootPathMiddleware,
|
||||
get_request_root_path,
|
||||
get_server_root_paths,
|
||||
normalize_root_paths,
|
||||
)
|
||||
|
|
@ -200,3 +201,102 @@ class TestEndToEndRouting:
|
|||
def test_unlisted_prefix_404s(self):
|
||||
client = _routed_client(["/tenant-a"])
|
||||
assert client.get("/tenant-c/where").status_code == 404
|
||||
|
||||
|
||||
class TestGetRequestRootPath:
|
||||
"""``get_request_root_path`` is the accessor that plumbs the middleware's
|
||||
resolved prefix to code that doesn't have scope in hand — the 401 challenge
|
||||
builders in ``mcp_server_manager`` / ``server`` and ``get_custom_url`` on the
|
||||
SSO callback path. Reading the SERVER_ROOT_PATH scalar there would emit URLs
|
||||
under a prefix the client didn't call, and stack a second prefix onto ones it
|
||||
did (the two review points this fixture pins)."""
|
||||
|
||||
def test_falls_back_to_server_root_path_env_outside_a_request(self, monkeypatch):
|
||||
# Outside a request the middleware's ContextVar is unset. The scalar
|
||||
# env still owns the answer, so pre-middleware call sites (module-load
|
||||
# UI URL builders, background tasks) behave exactly as they did.
|
||||
monkeypatch.setenv("SERVER_ROOT_PATH", "/legacy")
|
||||
assert get_request_root_path() == "/legacy"
|
||||
|
||||
def test_returns_empty_string_when_no_env_and_no_request(self, monkeypatch):
|
||||
monkeypatch.delenv("SERVER_ROOT_PATH", raising=False)
|
||||
assert get_request_root_path() == ""
|
||||
|
||||
def test_returns_matched_prefix_inside_a_request(self, monkeypatch):
|
||||
# With both env vars set, a request matching a SERVER_ROOT_PATHS prefix
|
||||
# must see that prefix — not the SERVER_ROOT_PATH scalar — so the URL
|
||||
# it emits stays under the prefix the router will resolve it against.
|
||||
monkeypatch.setenv("SERVER_ROOT_PATH", "/legacy")
|
||||
|
||||
seen: list[str] = []
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/where")
|
||||
def where():
|
||||
seen.append(get_request_root_path())
|
||||
return {}
|
||||
|
||||
app.add_middleware(PerRequestRootPathMiddleware, root_paths=["/tenant-a"])
|
||||
client = TestClient(app)
|
||||
|
||||
assert client.get("/tenant-a/where").status_code == 200
|
||||
assert seen == ["/tenant-a"]
|
||||
|
||||
def test_unmatched_request_falls_through_to_scope_scalar(self, monkeypatch):
|
||||
# A request the middleware saw but did not match keeps whatever
|
||||
# scope["root_path"] the app was mounted under (the scalar). The
|
||||
# ContextVar still reflects the effective per-request answer, so
|
||||
# emitted URLs and the router agree even on the fallback path.
|
||||
monkeypatch.setenv("SERVER_ROOT_PATH", "/legacy")
|
||||
seen: list[str] = []
|
||||
|
||||
app = FastAPI(root_path="/legacy")
|
||||
|
||||
@app.get("/where")
|
||||
def where():
|
||||
seen.append(get_request_root_path())
|
||||
return {}
|
||||
|
||||
app.add_middleware(PerRequestRootPathMiddleware, root_paths=["/tenant-a"])
|
||||
client = TestClient(app)
|
||||
|
||||
assert client.get("/legacy/where").status_code == 200
|
||||
assert seen == ["/legacy"]
|
||||
|
||||
def test_each_request_sees_its_own_prefix(self, monkeypatch):
|
||||
# Two sequential requests through the same app must each see the
|
||||
# prefix they arrived under, so one tenant's client is never sent
|
||||
# the URL of another tenant's origin.
|
||||
monkeypatch.delenv("SERVER_ROOT_PATH", raising=False)
|
||||
seen: list[tuple[str, str]] = []
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/where")
|
||||
def where(tag: str):
|
||||
seen.append((tag, get_request_root_path()))
|
||||
return {}
|
||||
|
||||
app.add_middleware(PerRequestRootPathMiddleware, root_paths=["/tenant-a", "/tenant-b"])
|
||||
client = TestClient(app)
|
||||
assert client.get("/tenant-a/where?tag=a").status_code == 200
|
||||
assert client.get("/tenant-b/where?tag=b").status_code == 200
|
||||
assert seen == [("a", "/tenant-a"), ("b", "/tenant-b")]
|
||||
|
||||
def test_context_var_reset_after_request(self, monkeypatch):
|
||||
# A ContextVar left set after the request finishes would poison the
|
||||
# module-load-time callers that read it lazily (they'd think they were
|
||||
# inside a request under the last-seen prefix).
|
||||
monkeypatch.setenv("SERVER_ROOT_PATH", "/legacy")
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/where")
|
||||
def where():
|
||||
return {"prefix": get_request_root_path()}
|
||||
|
||||
app.add_middleware(PerRequestRootPathMiddleware, root_paths=["/tenant-a"])
|
||||
client = TestClient(app)
|
||||
assert client.get("/tenant-a/where").json() == {"prefix": "/tenant-a"}
|
||||
# After the request finishes, the scalar-env fallback owns the answer
|
||||
# again — nothing was left stashed from the last request's scope.
|
||||
assert get_request_root_path() == "/legacy"
|
||||
|
|
|
|||
|
|
@ -314,3 +314,76 @@ def test_normalize_route_for_root_path_error_path_when_route_not_under_root(
|
|||
_clear_url_env(monkeypatch)
|
||||
monkeypatch.setenv("SERVER_ROOT_PATH", "/proxy")
|
||||
assert normalize_route_for_root_path("/other/v1/chat") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_custom_url under a per-request prefix (SERVER_ROOT_PATHS):
|
||||
# when a request lives under a dynamic prefix, request.base_url already
|
||||
# contains it. Appending the SERVER_ROOT_PATH scalar on top produced
|
||||
# double-prefixed SSO callback / login URLs (a path that doesn't exist on
|
||||
# the deployment). These pin that the emitted URL now stays under one
|
||||
# prefix — the one the request actually arrived on.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_custom_url_uses_per_request_prefix_when_middleware_ran(monkeypatch):
|
||||
"""The middleware stashes the effective per-request prefix in a ContextVar.
|
||||
``get_custom_url`` reads that in preference to the SERVER_ROOT_PATH scalar,
|
||||
and ``join_paths``'s tail-dedup collapses the append so a request whose
|
||||
``base_url`` already ends in ``/tenant-a`` does not become
|
||||
``/tenant-a/legacy/route``."""
|
||||
_clear_url_env(monkeypatch)
|
||||
monkeypatch.setenv("SERVER_ROOT_PATH", "/legacy")
|
||||
|
||||
from litellm.proxy.middleware.per_request_root_path_middleware import (
|
||||
_request_root_path_var,
|
||||
)
|
||||
|
||||
token = _request_root_path_var.set("/tenant-a")
|
||||
try:
|
||||
# request.base_url already carries the tenant prefix; the scalar
|
||||
# SERVER_ROOT_PATH must not be re-appended on top.
|
||||
result = get_custom_url(
|
||||
request_base_url="https://request.example.com/tenant-a/",
|
||||
route="/v1/chat",
|
||||
)
|
||||
finally:
|
||||
_request_root_path_var.reset(token)
|
||||
|
||||
assert result == "https://request.example.com/tenant-a/v1/chat"
|
||||
|
||||
|
||||
def test_get_custom_url_no_double_prefix_when_both_env_vars_configured(monkeypatch):
|
||||
"""Regression guard for the review point: with SERVER_ROOT_PATH also set
|
||||
(scalar-legacy) and the request matched by SERVER_ROOT_PATHS (per-request),
|
||||
the emitted URL is under one prefix — the per-request one — never both."""
|
||||
_clear_url_env(monkeypatch)
|
||||
monkeypatch.setenv("SERVER_ROOT_PATH", "/legacy")
|
||||
monkeypatch.setenv("SERVER_ROOT_PATHS", "/tenant-a")
|
||||
|
||||
from litellm.proxy.middleware.per_request_root_path_middleware import (
|
||||
_request_root_path_var,
|
||||
)
|
||||
|
||||
token = _request_root_path_var.set("/tenant-a")
|
||||
try:
|
||||
# No "/legacy" ever appears — the fix pins the reviewer's expected
|
||||
# behavior: only one prefix should apply per request.
|
||||
result = get_custom_url("https://api.example.com/tenant-a", "/sso/callback")
|
||||
finally:
|
||||
_request_root_path_var.reset(token)
|
||||
|
||||
assert result == "https://api.example.com/tenant-a/sso/callback"
|
||||
assert "/legacy" not in result
|
||||
|
||||
|
||||
def test_get_custom_url_scalar_only_still_stamps_root_path(monkeypatch):
|
||||
"""Pre-middleware deployments have not opted into SERVER_ROOT_PATHS at all;
|
||||
the ContextVar stays unset and the SERVER_ROOT_PATH scalar owns the answer
|
||||
— the behavior every existing scalar-only deployment relies on."""
|
||||
_clear_url_env(monkeypatch)
|
||||
monkeypatch.setenv("SERVER_ROOT_PATH", "/legacy")
|
||||
|
||||
result = get_custom_url("https://request.example.com", "/v1/chat")
|
||||
|
||||
assert result == "https://request.example.com/legacy/v1/chat"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue