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
This commit is contained in:
mateo-berri 2026-06-01 19:02:11 +00:00
parent 76bf280d0a
commit dfa372a947
No known key found for this signature in database
5 changed files with 174 additions and 11 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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<NavbarProps> = ({
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<NavbarProps> = ({
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) => {