fix: use LITELLM_PROXY_BASE_URL for redirect_uri, close popup on unmount, no handleError in poll

- Add _get_callback_base_url() helper that prefers LITELLM_PROXY_BASE_URL
  env var over X-Forwarded-Host-derived URL, avoiding header spoofing in
  the security-sensitive OAuth2 redirect_uri construction.
- OAuth2ConnectButton.tsx: close popup on component unmount so orphaned
  popups don't persist when the user navigates away.
- getMcpOAuth2Status: remove handleError() call before throwing so the
  polling loop's catch block is the single error surface and no unexpected
  UI notifications fire on transient 2-second poll failures.
This commit is contained in:
Ishaan Jaffer 2026-03-07 11:13:16 -08:00
parent ba97bc28af
commit f2d20baf9d
3 changed files with 28 additions and 6 deletions

View file

@ -59,6 +59,22 @@ router = APIRouter(tags=["mcp"])
# ---------------------------------------------------------------------------
def _get_callback_base_url(request: "Request") -> str:
"""Return the base URL to use for the OAuth2 redirect_uri.
Prefers a statically-configured base URL (via the LITELLM_PROXY_BASE_URL
environment variable) over the request-derived URL to avoid trusting
potentially-spoofable X-Forwarded-Host headers in the OAuth2 security
context. Falls back to the request-derived URL when the env var is unset.
"""
import os
static_base = os.environ.get("LITELLM_PROXY_BASE_URL", "").rstrip("/")
if static_base:
return static_base
return get_request_base_url(request)
def _purge_expired_states() -> None:
now = time.time()
expired = [k for k, v in _pending_oauth2_states.items() if v["expires_at"] < now]
@ -206,7 +222,7 @@ async def openapi_oauth2_connect(
"expires_at": timestamp + _STATE_TTL_SECONDS,
}
base_url = get_request_base_url(request)
base_url = _get_callback_base_url(request)
callback_url = f"{base_url}/v1/mcp/oauth2/callback"
# NOTE: PKCE (RFC 7636 / OAuth 2.1) is not implemented here because this is
@ -316,7 +332,7 @@ async def openapi_oauth2_callback(
status_code=500,
)
base_url = get_request_base_url(request)
base_url = _get_callback_base_url(request)
callback_url = f"{base_url}/v1/mcp/oauth2/callback"
token_request_data = {

View file

@ -107,10 +107,15 @@ export const OAuth2ConnectButton: React.FC<OAuth2ConnectButtonProps> = ({
}
};
// Clean up on unmount
// Clean up on unmount: stop polling and close any open popup so it doesn't
// stay open after the user navigates away from the page.
useEffect(() => {
return () => {
stopPolling();
if (popupRef.current && !popupRef.current.closed) {
popupRef.current.close();
}
popupRef.current = null;
};
}, []);

View file

@ -6442,15 +6442,16 @@ export const getMcpOAuth2Status = async (
});
if (!response.ok) {
const errorData = await response.json();
// Do NOT call handleError here: this function is used inside a polling
// loop that catches and ignores errors. Calling handleError would cause
// UI notifications to fire on every failed poll tick.
const errorData = await response.json().catch(() => ({}));
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
return await response.json();
} catch (error) {
console.error("Failed to get MCP OAuth2 status:", error);
throw error;
}
};