From dfa372a947adb4a4ca9e6dde30bdf0d168797d17 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 1 Jun 2026 19:02:11 +0000 Subject: [PATCH] fix(sso): set cookie path and add backend logout endpoint The token cookie was being set without an explicit path, causing it to default to the request path (/sso/callback). When the frontend tried to clear the cookie at / and /ui paths, it didn't match, so the cookie persisted and users got immediately logged back in after logout. Changes: - Set path="/", httponly=True, samesite="lax" on all token cookies - Add /sso/logout endpoint that properly deletes the httpOnly cookie - Update frontend to redirect to /sso/logout instead of trying to clear cookies client-side (which doesn't work for httpOnly cookies) - Add tests for the logout endpoint https://claude.ai/code/session_01VGbXWF2hBm2gHUBWCvgLaS --- litellm/proxy/management_endpoints/ui_sso.py | 10 +- litellm/proxy/proxy_server.py | 59 ++++++++++- .../proxy_admin_ui_tests/test_sso_sign_in.py | 7 ++ tests/test_litellm/proxy/test_sso_logout.py | 100 ++++++++++++++++++ .../src/components/navbar.tsx | 9 +- 5 files changed, 174 insertions(+), 11 deletions(-) create mode 100644 tests/test_litellm/proxy/test_sso_logout.py diff --git a/litellm/proxy/management_endpoints/ui_sso.py b/litellm/proxy/management_endpoints/ui_sso.py index d6082899c02..2833f32ac72 100644 --- a/litellm/proxy/management_endpoints/ui_sso.py +++ b/litellm/proxy/management_endpoints/ui_sso.py @@ -3376,7 +3376,15 @@ class SSOAuthenticationHandler: litellm_dashboard_ui += "?login=success" verbose_proxy_logger.info(f"Redirecting to {litellm_dashboard_ui}") redirect_response = RedirectResponse(url=litellm_dashboard_ui, status_code=303) - redirect_response.set_cookie(key="token", value=jwt_token) + secure_flag = request.url.scheme == "https" + redirect_response.set_cookie( + key="token", + value=jwt_token, + path="/", + httponly=True, + samesite="lax", + secure=secure_flag, + ) return redirect_response @staticmethod diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index b296792cd09..7cad1a850fa 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -13074,7 +13074,15 @@ async def login(request: Request): # noqa: PLR0915 # Create redirect response with cookie redirect_response = RedirectResponse(url=litellm_dashboard_ui, status_code=303) - redirect_response.set_cookie(key="token", value=jwt_token) + secure_flag = request.url.scheme == "https" + redirect_response.set_cookie( + key="token", + value=jwt_token, + path="/", + httponly=True, + samesite="lax", + secure=secure_flag, + ) return redirect_response @@ -13126,7 +13134,15 @@ async def login_v2(request: Request): # noqa: PLR0915 content={"redirect_url": litellm_dashboard_ui, "token": jwt_token}, status_code=status.HTTP_200_OK, ) - json_response.set_cookie(key="token", value=jwt_token) + secure_flag = request.url.scheme == "https" + json_response.set_cookie( + key="token", + value=jwt_token, + path="/", + httponly=True, + samesite="lax", + secure=secure_flag, + ) return json_response except Exception as e: verbose_proxy_logger.exception( @@ -13294,7 +13310,15 @@ async def login_v3_exchange(request: Request): }, status_code=status.HTTP_200_OK, ) - json_response.set_cookie(key="token", value=cached_data["token"]) + secure_flag = request.url.scheme == "https" + json_response.set_cookie( + key="token", + value=cached_data["token"], + path="/", + httponly=True, + samesite="lax", + secure=secure_flag, + ) return json_response except ProxyException: raise @@ -13312,6 +13336,35 @@ async def login_v3_exchange(request: Request): ) +@router.get("/sso/logout", include_in_schema=False) +async def sso_logout(request: Request): + """ + Logout endpoint that clears the httpOnly token cookie and redirects + to the configured PROXY_LOGOUT_URL or the login page. + """ + from litellm.proxy.utils import get_custom_url + + logout_url = os.getenv("PROXY_LOGOUT_URL") + if logout_url: + redirect_url = logout_url + else: + base_url = get_custom_url(str(request.base_url)) + if base_url.endswith("/"): + redirect_url = base_url + "ui/" + else: + redirect_url = base_url + "/ui/" + + redirect_response = RedirectResponse(url=redirect_url, status_code=303) + secure_flag = request.url.scheme == "https" + redirect_response.delete_cookie( + key="token", + path="/", + samesite="lax", + secure=secure_flag, + ) + return redirect_response + + @app.get("/onboarding/get_token", include_in_schema=False) async def onboarding(invite_link: str, request: Request): """ diff --git a/tests/proxy_admin_ui_tests/test_sso_sign_in.py b/tests/proxy_admin_ui_tests/test_sso_sign_in.py index 294a5c56199..5f977a56670 100644 --- a/tests/proxy_admin_ui_tests/test_sso_sign_in.py +++ b/tests/proxy_admin_ui_tests/test_sso_sign_in.py @@ -111,6 +111,13 @@ async def test_auth_callback_new_user(mock_google_sso, mock_env_vars, prisma_cli f"http://testserver/ui/?login=success" ) + # Verify token cookie is set with proper security attributes + set_cookie_header = response.headers.get("set-cookie", "") + assert "token=" in set_cookie_header + assert "path=/" in set_cookie_header.lower() + assert "httponly" in set_cookie_header.lower() + assert "samesite=lax" in set_cookie_header.lower() + # Verify that the user was added to the database user = await prisma_client.db.litellm_usertable.find_first( where={"user_id": unique_user_id} diff --git a/tests/test_litellm/proxy/test_sso_logout.py b/tests/test_litellm/proxy/test_sso_logout.py new file mode 100644 index 00000000000..511b9c77660 --- /dev/null +++ b/tests/test_litellm/proxy/test_sso_logout.py @@ -0,0 +1,100 @@ +""" +Unit tests for the /sso/logout endpoint and token cookie handling. +Tests the logout functionality without requiring database connection. +""" + +import pytest +from unittest.mock import MagicMock, patch +from fastapi import Request +from fastapi.responses import RedirectResponse, JSONResponse + + +@pytest.mark.asyncio +async def test_sso_logout_clears_cookie_and_redirects_to_ui(): + """ + Test that sso_logout clears the token cookie and redirects to /ui/ + when PROXY_LOGOUT_URL is not set. + """ + from litellm.proxy.proxy_server import sso_logout + + mock_request = Request( + scope={ + "type": "http", + "method": "GET", + "scheme": "http", + "server": ("testserver", 80), + "path": "/sso/logout", + "query_string": b"", + "headers": {}, + } + ) + + with patch.dict("os.environ", {}, clear=False): + if "PROXY_LOGOUT_URL" in __import__("os").environ: + del __import__("os").environ["PROXY_LOGOUT_URL"] + response = await sso_logout(mock_request) + + assert isinstance(response, RedirectResponse) + assert response.status_code == 303 + assert "/ui/" in response.headers["location"] + + set_cookie = response.headers.get("set-cookie", "") + assert "token=" in set_cookie + assert "path=/" in set_cookie.lower() + + +@pytest.mark.asyncio +async def test_sso_logout_redirects_to_proxy_logout_url(): + """ + Test that sso_logout redirects to PROXY_LOGOUT_URL when configured. + """ + from litellm.proxy.proxy_server import sso_logout + + mock_request = Request( + scope={ + "type": "http", + "method": "GET", + "scheme": "http", + "server": ("testserver", 80), + "path": "/sso/logout", + "query_string": b"", + "headers": {}, + } + ) + + with patch.dict( + "os.environ", {"PROXY_LOGOUT_URL": "https://example.com/logout"}, clear=False + ): + response = await sso_logout(mock_request) + + assert isinstance(response, RedirectResponse) + assert response.status_code == 303 + assert response.headers["location"] == "https://example.com/logout" + + +@pytest.mark.asyncio +async def test_sso_logout_sets_secure_flag_for_https(): + """ + Test that sso_logout sets the secure flag when the request is HTTPS. + """ + from litellm.proxy.proxy_server import sso_logout + + mock_request = Request( + scope={ + "type": "http", + "method": "GET", + "scheme": "https", + "server": ("testserver", 443), + "path": "/sso/logout", + "query_string": b"", + "headers": {}, + } + ) + + with patch.dict("os.environ", {}, clear=False): + if "PROXY_LOGOUT_URL" in __import__("os").environ: + del __import__("os").environ["PROXY_LOGOUT_URL"] + response = await sso_logout(mock_request) + + set_cookie = response.headers.get("set-cookie", "") + assert "secure" in set_cookie.lower() diff --git a/ui/litellm-dashboard/src/components/navbar.tsx b/ui/litellm-dashboard/src/components/navbar.tsx index e5a1490788c..2bb75e20b98 100644 --- a/ui/litellm-dashboard/src/components/navbar.tsx +++ b/ui/litellm-dashboard/src/components/navbar.tsx @@ -10,7 +10,7 @@ import { fetchProxySettings } from "@/utils/proxyUtils"; import { DownOutlined, MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons"; import { Tag } from "antd"; import Link from "next/link"; -import React, { useEffect, useState } from "react"; +import React, { useEffect } from "react"; import { BlogDropdown } from "./Navbar/BlogDropdown/BlogDropdown"; import { CommunityEngagementButtons } from "./Navbar/CommunityEngagementButtons/CommunityEngagementButtons"; import { NAV_PRODUCT_LINK_CLASS } from "./Navbar/navProductLinkClass"; @@ -36,7 +36,6 @@ const Navbar: React.FC = ({ onToggleSidebar, }) => { const baseUrl = getProxyBaseUrl(); - const [logoutUrl, setLogoutUrl] = useState(""); const { logoUrl } = useTheme(); const { data: healthData } = useHealthReadinessDetails(accessToken); const version = healthData?.litellm_version; @@ -61,15 +60,11 @@ const Navbar: React.FC = ({ initializeProxySettings(); }, [accessToken]); - useEffect(() => { - setLogoutUrl(proxySettings?.PROXY_LOGOUT_URL || ""); - }, [proxySettings]); - const handleLogout = () => { clearTokenCookies(); localStorage.removeItem("litellm_selected_worker_id"); localStorage.removeItem("litellm_worker_url"); - window.location.href = logoutUrl; + window.location.href = `${baseUrl}/sso/logout`; }; const handleWorkerSwitch = (workerId: string) => {