mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-01 02:02:20 +00:00
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:
parent
76bf280d0a
commit
dfa372a947
5 changed files with 174 additions and 11 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
100
tests/test_litellm/proxy/test_sso_logout.py
Normal file
100
tests/test_litellm/proxy/test_sso_logout.py
Normal 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()
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue