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:
roman-dovgan 2026-09-01 20:08:01 +01:00
parent 1603f04b39
commit 917b25666e
6 changed files with 233 additions and 7 deletions

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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"

View file

@ -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"