fix(proxy): revoke UI session tokens on logout and password change (#42463)

* fix(proxy): revoke UI session tokens on logout and password change

Adds POST /session/logout to revoke the presented UI session key server
side (previously logout was client-side only and the key stayed valid
until expiry). Password changes now revoke the user's other UI sessions:
self-change keeps the caller's session, admin reset and onboarding claim
revoke all. The BYOK OAuth cookie auth now re-resolves the embedded key
against the DB so revoked sessions get a 401.

* fix(proxy): satisfy B008 budget and backend allowlist for /session/logout

* refactor(proxy): satisfy type-discipline budget in session_endpoints
This commit is contained in:
Oliver Jensen 2026-09-23 10:31:38 +02:00 committed by GitHub
parent 59db39626f
commit bc3b5b1d5b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1018 additions and 35 deletions

View file

@ -26,6 +26,7 @@ BACKEND_PATH_PREFIXES: tuple[str, ...] = (
"/v2/login",
"/v3/login",
"/logout",
"/session/logout",
"/token",
"/onboarding/",
"/audit",

View file

@ -83,8 +83,14 @@ def _oauth_token_error(code: str, status: int = 400) -> JSONResponse:
def _user_id_from_session_cookie(request: Request) -> str | None:
"""Return user_id from the UI ``token`` cookie (HS256-signed with
``master_key``), or None if missing/invalid.
"""Return user_id from the UI ``token`` cookie, or None if missing/invalid."""
user_id, _ = _session_identity_from_cookie(request)
return user_id
def _session_identity_from_cookie(request: Request) -> tuple[str | None, str | None]:
"""Return ``(user_id, session_key)`` from the UI ``token`` cookie
(HS256-signed with ``master_key``), or ``(None, None)`` if missing/invalid.
The /token endpoint in this file ALSO issues master-key-signed JWTs
(type="byok_session") for MCP-client-side use. They must not be
@ -98,10 +104,10 @@ def _user_id_from_session_cookie(request: Request) -> str | None:
from litellm.proxy.proxy_server import master_key
if not master_key:
return None
return None, None
token: Final = request.cookies.get("token")
if not token:
return None
return None, None
try:
payload: Final = jwt.decode(
token,
@ -113,21 +119,68 @@ def _user_id_from_session_cookie(request: Request) -> str | None:
options={"require": ["exp"]},
)
except jwt.InvalidTokenError:
return None
return None, None
if payload.get("type") == "byok_session":
return None
return None, None
if payload.get("login_method") not in ("sso", "username_password"):
return None
return None, None
user_id: Final = payload.get("user_id")
return user_id if isinstance(user_id, str) and user_id else None
if not isinstance(user_id, str) or not user_id:
return None, None
session_key: Final = payload.get("key")
return user_id, session_key if isinstance(session_key, str) and session_key else None
async def _session_key_is_live(session_key: str | None) -> bool:
"""Whether the session key embedded in the UI cookie still resolves.
The cookie JWT stays signature-valid until ``exp``; the DB-backed session
key inside it is what ``POST /session/logout`` and password-change
revocation actually kill. Trusting the signature alone would let a
logged-out cookie keep authorizing BYOK credential writes, so re-resolve
the key here.
EXPERIMENTAL_UI_LOGIN blob tokens (non-``sk-``) have no DB row and are
unrevocable by construction (scoped out of revocation); they pass through
on their bounded 10-minute lifetime, as before.
"""
from litellm.proxy._types import hash_token
from litellm.proxy.auth.auth_checks import get_key_object
from litellm.proxy.proxy_server import (
prisma_client,
proxy_logging_obj,
user_api_key_cache,
)
if session_key is None:
# Older cookies predating the ``key`` claim: nothing to resolve.
return True
if not session_key.startswith("sk-"):
return True
if prisma_client is None:
return True
try:
await get_key_object(
hashed_token=hash_token(session_key),
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
except Exception:
return False
return True
async def _byok_session_auth(request: Request) -> UserAPIKeyAuth:
"""Require the UI session cookie. Programmatic BYOK management uses
"""Require the UI session cookie, with the embedded session key
re-resolved against the DB so a revoked (logged-out) session cannot
authorize BYOK writes. Programmatic BYOK management uses
``POST /v1/mcp/server/{id}/user-credential`` instead."""
user_id: Final = _user_id_from_session_cookie(request)
user_id, session_key = _session_identity_from_cookie(request)
if not user_id:
raise HTTPException(status_code=401, detail="login_required")
if not await _session_key_is_live(session_key):
raise HTTPException(status_code=401, detail="login_required")
return UserAPIKeyAuth(api_key="byok_session_cookie", user_id=user_id)

View file

@ -913,6 +913,7 @@ class LiteLLMRoutes(enum.Enum):
"/user/list", # org admins checked in endpoint; non-admins get 403
"/management/v1/users/bulk_delete", # proxy admins delete anyone, org admins only their orgs' users; others 403
"/user/password/change", # endpoint only ever writes the caller's own row
"/session/logout", # endpoint only ever revokes the caller's own session key
"/model/{model_id}/update",
"/prompt/list",
"/prompt/info",
@ -1948,6 +1949,10 @@ class ChangePasswordResponse(LiteLLMPydanticObjectBase):
message: str
class SessionLogoutResponse(LiteLLMPydanticObjectBase):
message: str
class DeleteUserRequest(LiteLLMPydanticObjectBase):
user_ids: list[str] # required

View file

@ -57,7 +57,7 @@ if TYPE_CHECKING:
from prisma import types as prisma_types
BREACH_RECHECK_INTERVAL: Final = timedelta(hours=24)
PASSWORD_RESET_ALLOWED_ROUTES: Final = ("/user/password/change",)
PASSWORD_RESET_ALLOWED_ROUTES: Final = ("/user/password/change", "/session/logout")
PASSWORD_SESSION_METADATA: Final = MappingProxyType({"login_method": "username_password"})

View file

@ -915,6 +915,10 @@ class RouteChecks:
if route == "/user/password/change":
return
# Self-service logout; the endpoint only revokes the caller's own session key.
if route == "/session/logout":
return
# Hard-block known write routes regardless of HTTP method (defensive
# — these are POSTs in practice, but pinning them here protects
# against future GET-shaped writes).

View file

@ -1583,6 +1583,23 @@ async def _update_single_user_helper(
response = inserted_user_row # pyright: ignore[reportAssignmentType] # insert_data returns a prisma row
if response is not None:
if "password" in non_default_values:
# An admin set this user's password, which implies the old one may be
# compromised; kill every existing UI session for the target. Revoke-all
# (no keep) — the caller is the admin, not the target, so the caller's
# own session is not among these.
from litellm.proxy.management_endpoints.session_endpoints import (
revoke_ui_session_keys,
)
target_user_id: Final = non_default_values.get("user_id")
if isinstance(target_user_id, str):
await revoke_ui_session_keys(
user_id=target_user_id,
user_api_key_dict=user_api_key_dict,
litellm_changed_by=litellm_changed_by,
)
await _schedule_user_update_audit_log(
response=response,
existing_user_row=existing_user_row,

View file

@ -26,6 +26,7 @@ from litellm.proxy._types import (
from litellm.proxy.auth.login_utils import PASSWORD_SESSION_METADATA
from litellm.proxy.auth.password_policy import validate_password_not_breached, validate_password_policy
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.management_endpoints.session_endpoints import revoke_ui_session_keys
from litellm.proxy.management_helpers.audit_logs import create_object_audit_log
from litellm.proxy.utils import hash_password, verify_password
from litellm.repositories.prisma_protocols import TableActions
@ -141,6 +142,15 @@ async def change_password(
}
await _user_table(prisma_client).update(where=find_user, data=password_update)
# The old password may have been compromised; revoke every other UI session
# so a holder of a stolen session token is cut off. The caller's own session
# is kept — they just proved they hold the current password.
await revoke_ui_session_keys(
user_id=user_id,
user_api_key_dict=user_api_key_dict,
keep_hashed_token=user_api_key_dict.token,
)
verbose_proxy_logger.info("Password changed via /user/password/change for user_id=%s", user_id)
await create_object_audit_log(
object_id=user_id,

View file

@ -0,0 +1,175 @@
"""
UI session revocation.
POST /session/logout revoke the UI session key this request authenticated with.
revoke_ui_session_keys revoke every UI session key a user holds (password writes).
Logging out of the dashboard was purely client-side (cookies cleared, redirect);
the DB-backed virtual key minted at login stayed valid until
LITELLM_UI_SESSION_DURATION elapsed, so a captured token kept working access
after logout, and changing a password did not invalidate existing sessions.
Deliberately NOT reusing /key/delete: its `can_modify_verification_token`
ownership checks can reject low-privilege roles, and a self-revoke endpoint
that takes no body cannot be aimed at other keys.
"""
from typing import TYPE_CHECKING, Annotated, Final, cast
from fastapi import APIRouter, Depends, HTTPException, Response
from pydantic import TypeAdapter
from litellm._logging import verbose_proxy_logger
from litellm.constants import UI_SESSION_TOKEN_TEAM_ID
from litellm.proxy._types import (
CommonProxyErrors,
HTTPExceptionErrorDetail,
LiteLLM_VerificationToken,
SessionLogoutResponse,
UserAPIKeyAuth,
)
from litellm.proxy.auth.auth_checks import delete_cache_key_objects
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.management_endpoints.key_management_endpoints import (
_persist_deleted_verification_tokens,
)
from litellm.repositories.verification_token_repository import (
VerificationTokenRepository,
)
if TYPE_CHECKING:
from prisma import types as prisma_types
router: Final = APIRouter()
_TOKEN_LIST: Final = TypeAdapter(list[str])
def _error_detail(message: str) -> HTTPExceptionErrorDetail:
detail: Final[HTTPExceptionErrorDetail] = {"error": message}
return detail
async def revoke_ui_session_keys(
user_id: str,
user_api_key_dict: UserAPIKeyAuth,
*,
keep_hashed_token: str | None = None,
litellm_changed_by: str | None = None,
) -> int:
"""Revoke every UI session key belonging to ``user_id``, except
``keep_hashed_token`` (the caller's own session on a self-service password
change; the other password-write paths revoke all).
Best-effort: the password write this runs after has already committed, so a
revocation failure is logged loudly rather than failing the request the
unrevoked keys still expire at LITELLM_UI_SESSION_DURATION.
Returns the number of sessions revoked.
"""
from litellm.proxy.proxy_server import prisma_client, proxy_logging_obj, user_api_key_cache
if prisma_client is None:
return 0
try:
where_user_sessions: Final[prisma_types.LiteLLM_VerificationTokenWhereInput] = {
"user_id": user_id,
"team_id": UI_SESSION_TOKEN_TEAM_ID,
}
rows: Final = cast( # cast-ok: find_many returns prisma rows shaped like the pydantic model
"tuple[LiteLLM_VerificationToken, ...]",
tuple(await VerificationTokenRepository(prisma_client).table.find_many(where=where_user_sessions)),
)
revoked_rows: Final = tuple(row for row in rows if row.token is not None and row.token != keep_hashed_token)
if not revoked_rows:
return 0
revoked_tokens: Final = _TOKEN_LIST.validate_python(tuple(row.token for row in revoked_rows))
await _persist_deleted_verification_tokens(
keys=revoked_rows,
prisma_client=prisma_client,
user_api_key_dict=user_api_key_dict,
litellm_changed_by=litellm_changed_by,
)
where_revoked: Final[prisma_types.LiteLLM_VerificationTokenWhereInput] = {"token": {"in": revoked_tokens}}
await VerificationTokenRepository(prisma_client).table.delete_many(where=where_revoked)
await delete_cache_key_objects(
hashed_tokens=revoked_tokens,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
verbose_proxy_logger.info(
"Revoked %s UI session key(s) for user_id=%s after password change",
len(revoked_tokens),
user_id,
)
return len(revoked_tokens)
except Exception: # noqa: BLE001 # the password write committed; revocation must not undo that
verbose_proxy_logger.exception(
"Failed to revoke UI session keys for user_id=%s; existing sessions remain valid until they expire",
user_id,
)
return 0
@router.post(
"/session/logout",
tags=("UI Session",),
)
async def session_logout(
response: Response,
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
) -> SessionLogoutResponse:
"""
Revoke the UI session key this request authenticated with.
Only accepts UI session keys (minted by dashboard login); any other
credential is refused, so this can never be used to delete arbitrary keys.
Revokes only the presented session, not the user's other sessions.
Idempotent: logging out an already-revoked session succeeds.
"""
from litellm.proxy.proxy_server import prisma_client, proxy_logging_obj, user_api_key_cache
if prisma_client is None:
raise HTTPException(
status_code=500,
detail=_error_detail(CommonProxyErrors.db_not_connected_error.value),
)
if user_api_key_dict.team_id != UI_SESSION_TOKEN_TEAM_ID:
raise HTTPException(
status_code=403,
detail=_error_detail("Only UI session tokens can be revoked through this endpoint."),
)
hashed_token: Final = user_api_key_dict.token
revoked = False
if hashed_token is not None:
where_token: Final[prisma_types.LiteLLM_VerificationTokenWhereUniqueInput] = {"token": hashed_token}
row: Final = await VerificationTokenRepository(prisma_client).table.find_unique(where=where_token)
# A missing row means the session is already revoked (or an
# EXPERIMENTAL_UI_LOGIN blob token); logout is idempotent either way.
if row is not None:
caller_row: Final = cast( # cast-ok: find_unique returns a prisma row shaped like the pydantic model
"LiteLLM_VerificationToken", row
)
await _persist_deleted_verification_tokens(
keys=(caller_row,),
prisma_client=prisma_client,
user_api_key_dict=user_api_key_dict,
)
await VerificationTokenRepository(prisma_client).table.delete_many(where=where_token)
revoked = True
await delete_cache_key_objects(
hashed_tokens=(hashed_token,),
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
# The server set this cookie at login (set_session_token_cookie); clear it
# here too so logout works even if the client-side clear is skipped.
response.delete_cookie("token")
return SessionLogoutResponse(
message="Session revoked." if revoked else "Session already revoked.",
)

View file

@ -630,6 +630,9 @@ from litellm.proxy.management_endpoints.prompt_caching_requests import (
from litellm.proxy.management_endpoints.router_settings_endpoints import (
router as router_settings_router,
)
from litellm.proxy.management_endpoints.session_endpoints import (
router as session_management_router,
)
from litellm.proxy.management_endpoints.tag_management_endpoints import (
router as tag_management_router,
)
@ -17012,6 +17015,19 @@ async def claim_onboarding_link(data: InvitationClaim, request: Request):
if user_obj and hasattr(user_obj, "__dict__"):
user_obj.__dict__.pop("password", None)
# The password just changed via an invitation/reset link; any UI session
# minted under the old password may be in hostile hands. Revoke them all —
# the caller holds only the short-lived onboarding JWT, and the fresh
# session key is minted below, after this sweep.
from litellm.proxy.management_endpoints.session_endpoints import (
revoke_ui_session_keys,
)
await revoke_ui_session_keys(
user_id=invite_obj.user_id,
user_api_key_dict=UserAPIKeyAuth(user_id=invite_obj.user_id),
)
try:
jwt_token: Final = await _generate_onboarding_ui_session_token(user_obj=user_obj)
except Exception as e:
@ -19431,6 +19447,7 @@ app.include_router(health_router)
app.include_router(key_management_router)
app.include_router(internal_user_router)
app.include_router(password_management_router)
app.include_router(session_management_router)
app.include_router(team_router)
app.include_router(ui_sso_router)
app.include_router(organization_router)

View file

@ -903,6 +903,61 @@ def test_authorize_post_accepts_ui_session_cookie(unauthenticated_client):
assert _byok_auth_codes[code]["user_id"] == "browser-user-42"
def test_authorize_post_rejects_cookie_with_revoked_session_key(unauthenticated_client):
"""The cookie JWT stays signature-valid until ``exp``, but logout /
password-change revocation deletes the DB-backed session key sealed
inside it. A cookie whose embedded key no longer resolves must not
authorize BYOK writes."""
import jwt as _jwt
with (
patch("litellm.proxy.proxy_server.master_key", "test-master-key"),
patch("litellm.proxy.proxy_server.prisma_client", MagicMock()),
patch(
"litellm.proxy.auth.auth_checks.get_key_object",
new=AsyncMock(side_effect=Exception("key not found")),
),
):
cookie_jwt = _jwt.encode(
{
"user_id": "browser-user-42",
"key": "sk-revoked-session-key",
"login_method": "sso",
"exp": int(time.time()) + 3600,
},
"test-master-key",
algorithm="HS256",
)
resp = _authorize_post_with_cookie(unauthenticated_client, cookie_jwt)
assert resp.status_code == 401
def test_authorize_post_accepts_cookie_with_live_session_key(unauthenticated_client):
"""A cookie whose embedded session key still resolves keeps working."""
import jwt as _jwt
with (
patch("litellm.proxy.proxy_server.master_key", "test-master-key"),
patch("litellm.proxy.proxy_server.prisma_client", MagicMock()),
patch(
"litellm.proxy.auth.auth_checks.get_key_object",
new=AsyncMock(return_value=UserAPIKeyAuth(user_id="browser-user-42")),
),
):
cookie_jwt = _jwt.encode(
{
"user_id": "browser-user-42",
"key": "sk-live-session-key",
"login_method": "sso",
"exp": int(time.time()) + 3600,
},
"test-master-key",
algorithm="HS256",
)
resp = _authorize_post_with_cookie(unauthenticated_client, cookie_jwt)
assert resp.status_code == 302
def test_authorize_post_rejects_cookie_signed_with_wrong_key(unauthenticated_client):
"""A cookie JWT signed with a different key than the proxy's master_key
must not grant access otherwise an attacker who can forge a JWT

View file

@ -2137,7 +2137,7 @@ class TestPasswordResetRequiredSessionMinting:
row = _db_user_row(password="Str0ng!Passw0rd", password_reset_required=True)
result, key_kwargs = await self._login(_prisma_with_user(row))
assert key_kwargs["allowed_routes"] == ["/user/password/change"]
assert key_kwargs["allowed_routes"] == ["/user/password/change", "/session/logout"]
assert key_kwargs["metadata"] == {"login_method": "username_password", "password_reset_required": True}
assert result.password_reset_required is True
@ -2198,7 +2198,7 @@ class TestPasswordResetRequiredSessionMinting:
result, key_kwargs, _ = await self._login_with_screen_result(mock_prisma_client, breached=True)
assert key_kwargs["allowed_routes"] == ["/user/password/change"]
assert key_kwargs["allowed_routes"] == ["/user/password/change", "/session/logout"]
assert key_kwargs["metadata"] == {"login_method": "username_password", "password_reset_required": True}
assert result.password_reset_required is True

View file

@ -477,6 +477,72 @@ async def test_claim_token_sets_accepted_at_after_password_written():
assert outer_claims["key"] == "sk-generated-key"
@pytest.mark.asyncio
async def test_claim_token_revokes_existing_ui_sessions():
"""A claimed invite/reset link changes the password; any UI session minted
under the old password may be in hostile hands and must be revoked. The
sweep runs before the fresh session key is minted, so revoke-all is safe."""
from litellm.proxy.proxy_server import claim_onboarding_link
invite = _make_invite(is_accepted=False)
user = _make_user()
prisma = _make_prisma(invite, user)
request = _make_claim_request(_make_onboarding_token())
data = InvitationClaim(
invitation_link="invite-abc",
user_id="user-123",
password="NewP@ssw0rd123",
)
mock_token_response = {"token": "sk-generated-key", "user_id": "user-123"}
revoke_mock = AsyncMock(return_value=1)
mint_order: list[str] = []
async def _mint(*args, **kwargs):
mint_order.append("mint")
return mock_token_response
async def _revoke(*args, **kwargs):
mint_order.append("revoke")
return 1
revoke_mock.side_effect = _revoke
with (
patch("litellm.proxy.proxy_server.prisma_client", prisma),
patch("litellm.proxy.proxy_server.master_key", "sk-test"),
patch( # test-quality-ok: claim_onboarding_link reads proxy_server module globals; no injection seam
"litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK
),
patch("litellm.proxy.proxy_server.premium_user", False),
patch(
"litellm.proxy.proxy_server.generate_key_helper_fn",
new_callable=AsyncMock,
side_effect=_mint,
),
patch(
"litellm.proxy.management_endpoints.session_endpoints.revoke_ui_session_keys",
revoke_mock,
),
patch(
"litellm.proxy.proxy_server.get_custom_url",
return_value="http://localhost:4000/",
),
patch(
"litellm.proxy.proxy_server.get_disabled_non_admin_personal_key_creation",
return_value=False,
),
patch("litellm.proxy.proxy_server.get_server_root_path", return_value=""),
):
await claim_onboarding_link(data=data, request=request)
revoke_mock.assert_awaited_once()
assert revoke_mock.await_args.kwargs["user_id"] == "user-123"
# The sweep must precede the mint or it would kill the fresh session too.
assert mint_order == ["revoke", "mint"]
@pytest.mark.asyncio
async def test_claim_token_rolls_back_invite_when_session_key_mint_fails():
"""A session key failure must not leave the invite permanently consumed."""

View file

@ -4905,3 +4905,69 @@ async def test_delete_user_writes_deleted_audit_log_for_user_keys(mocker):
assert audit_row.object_id == user_key.token
assert audit_row.changed_by
assert json.loads(audit_row.before_value)["token"] == user_key.token
@pytest.mark.asyncio
async def test_user_update_password_revokes_target_sessions(_admin_prisma, mocker):
"""An admin-set password implies the old one may be compromised: every UI
session belonging to the target user must be revoked after the write."""
from litellm.proxy.management_endpoints.internal_user_endpoints import (
_update_single_user_helper,
)
mocker.patch( # test-quality-ok: same module-global mocking every test in this file already uses
"litellm.proxy.proxy_server.general_settings",
{"password_policy_check_breached_passwords": False},
)
mock_prisma_client = _admin_prisma
existing_user = mocker.MagicMock()
existing_user.model_dump.return_value = {"user_id": "target-user"}
existing_user.user_id = "target-user"
mock_prisma_client.db.litellm_usertable.find_first = mocker.AsyncMock(return_value=existing_user)
mock_prisma_client.update_data = mocker.AsyncMock(return_value={"user_id": "target-user"})
mock_prisma_client.jsonify_object = mocker.MagicMock(side_effect=lambda x: x)
revoke_mock = mocker.patch(
"litellm.proxy.management_endpoints.session_endpoints.revoke_ui_session_keys",
new=mocker.AsyncMock(return_value=2),
)
user_request = UpdateUserRequest(user_id="target-user", password="Str0ng!Passw0rd")
admin_caller = UserAPIKeyAuth(user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN)
await _update_single_user_helper(user_request=user_request, user_api_key_dict=admin_caller)
revoke_mock.assert_awaited_once()
revoke_kwargs = revoke_mock.await_args.kwargs
assert revoke_kwargs["user_id"] == "target-user"
# Revoke-all: the admin's own session is not among the target's sessions.
assert revoke_kwargs.get("keep_hashed_token") is None
@pytest.mark.asyncio
async def test_user_update_without_password_revokes_nothing(_admin_prisma, mocker):
"""A non-password /user/update must not touch the target's sessions."""
from litellm.proxy.management_endpoints.internal_user_endpoints import (
_update_single_user_helper,
)
mock_prisma_client = _admin_prisma
existing_user = mocker.MagicMock()
existing_user.model_dump.return_value = {"user_id": "target-user"}
existing_user.user_id = "target-user"
mock_prisma_client.db.litellm_usertable.find_first = mocker.AsyncMock(return_value=existing_user)
mock_prisma_client.update_data = mocker.AsyncMock(return_value={"user_id": "target-user"})
mock_prisma_client.jsonify_object = mocker.MagicMock(side_effect=lambda x: x)
revoke_mock = mocker.patch(
"litellm.proxy.management_endpoints.session_endpoints.revoke_ui_session_keys",
new=mocker.AsyncMock(return_value=0),
)
user_request = UpdateUserRequest(user_id="target-user", user_email="new@example.com")
admin_caller = UserAPIKeyAuth(user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN)
await _update_single_user_helper(user_request=user_request, user_api_key_dict=admin_caller)
revoke_mock.assert_not_awaited()

View file

@ -380,6 +380,73 @@ async def test_change_password_failure_emits_no_audit_log():
audit_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_change_password_revokes_other_sessions_keeping_callers():
"""A successful change revokes the user's other UI sessions (the old
password may be compromised) while keeping the session that just proved
it holds the current password."""
from litellm.proxy._types import ChangePasswordRequest
prisma = _make_prisma(_make_user_row(hash_password(CURRENT_PASSWORD)))
revoke_mock = AsyncMock(return_value=0)
caller = UserAPIKeyAuth(
user_id="user-123",
token="hashed-caller-token",
team_id=UI_TEAM_ID,
metadata=dict(PASSWORD_SESSION_METADATA),
)
with (
patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
"litellm.proxy.proxy_server.prisma_client", prisma
),
patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
"litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK
),
patch(
"litellm.proxy.management_endpoints.password_endpoints.revoke_ui_session_keys",
revoke_mock,
),
):
await change_password(
data=ChangePasswordRequest(current_password=CURRENT_PASSWORD, new_password=NEW_PASSWORD),
user_api_key_dict=caller,
)
revoke_mock.assert_awaited_once()
revoke_kwargs = revoke_mock.await_args.kwargs
assert revoke_kwargs["user_id"] == "user-123"
assert revoke_kwargs["keep_hashed_token"] == "hashed-caller-token"
@pytest.mark.asyncio
async def test_change_password_failure_revokes_no_sessions():
from litellm.proxy._types import ChangePasswordRequest
prisma = _make_prisma(_make_user_row(hash_password(CURRENT_PASSWORD)))
revoke_mock = AsyncMock(return_value=0)
with (
patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
"litellm.proxy.proxy_server.prisma_client", prisma
),
patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
"litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK
),
patch(
"litellm.proxy.management_endpoints.password_endpoints.revoke_ui_session_keys",
revoke_mock,
),
):
with pytest.raises(HTTPException):
await change_password(
data=ChangePasswordRequest(current_password="not-the-password", new_password=NEW_PASSWORD),
user_api_key_dict=_caller(),
)
revoke_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_change_password_requires_db():
from litellm.proxy._types import ChangePasswordRequest

View file

@ -0,0 +1,300 @@
"""
Tests for POST /session/logout and revoke_ui_session_keys
(litellm/proxy/management_endpoints/session_endpoints.py).
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException, Response
from litellm.constants import UI_SESSION_TOKEN_TEAM_ID
from litellm.proxy._types import LiteLLM_VerificationToken, UserAPIKeyAuth
from litellm.proxy.management_endpoints.session_endpoints import (
revoke_ui_session_keys,
session_logout,
)
HASHED_TOKEN = "hashed-session-token"
USER_ID = "user-123"
def _session_row(token: str = HASHED_TOKEN, user_id: str = USER_ID) -> LiteLLM_VerificationToken:
return LiteLLM_VerificationToken(token=token, team_id=UI_SESSION_TOKEN_TEAM_ID, user_id=user_id)
def _make_prisma(
find_unique_row: LiteLLM_VerificationToken | None = None,
find_many_rows: list[LiteLLM_VerificationToken] | None = None,
) -> MagicMock:
prisma = MagicMock()
table = prisma.db.litellm_verificationtoken
table.find_unique = AsyncMock(return_value=find_unique_row)
table.find_many = AsyncMock(return_value=find_many_rows or [])
table.delete_many = AsyncMock(return_value=1)
return prisma
def _ui_session_caller(token: str | None = HASHED_TOKEN) -> UserAPIKeyAuth:
return UserAPIKeyAuth(token=token, team_id=UI_SESSION_TOKEN_TEAM_ID, user_id=USER_ID)
def _patched_globals(prisma):
return (
patch( # test-quality-ok: endpoint reads proxy_server module globals; no injection seam
"litellm.proxy.proxy_server.prisma_client", prisma
),
patch( # test-quality-ok: endpoint reads proxy_server module globals; no injection seam
"litellm.proxy.proxy_server.proxy_logging_obj", None
),
patch( # test-quality-ok: endpoint reads proxy_server module globals; no injection seam
"litellm.proxy.proxy_server.user_api_key_cache", MagicMock()
),
)
@pytest.mark.asyncio
async def test_session_logout_revokes_presented_session():
prisma = _make_prisma(find_unique_row=_session_row())
persist_mock = AsyncMock()
evict_mock = AsyncMock()
p1, p2, p3 = _patched_globals(prisma)
with (
p1,
p2,
p3,
patch(
"litellm.proxy.management_endpoints.session_endpoints._persist_deleted_verification_tokens",
persist_mock,
),
patch(
"litellm.proxy.management_endpoints.session_endpoints.delete_cache_key_objects",
evict_mock,
),
):
response = await session_logout(
response=Response(),
user_api_key_dict=_ui_session_caller(),
)
assert response.message == "Session revoked."
delete_kwargs = prisma.db.litellm_verificationtoken.delete_many.call_args.kwargs
assert delete_kwargs["where"] == {"token": HASHED_TOKEN}
# Audit record persisted before the row is gone.
persist_mock.assert_awaited_once()
assert persist_mock.await_args.kwargs["keys"][0].token == HASHED_TOKEN
# Cache evicted + broadcast even on the delete path.
evict_mock.assert_awaited_once()
assert tuple(evict_mock.await_args.kwargs["hashed_tokens"]) == (HASHED_TOKEN,)
@pytest.mark.asyncio
async def test_session_logout_clears_token_cookie():
prisma = _make_prisma(find_unique_row=_session_row())
fastapi_response = Response()
p1, p2, p3 = _patched_globals(prisma)
with (
p1,
p2,
p3,
patch(
"litellm.proxy.management_endpoints.session_endpoints._persist_deleted_verification_tokens",
AsyncMock(),
),
patch(
"litellm.proxy.management_endpoints.session_endpoints.delete_cache_key_objects",
AsyncMock(),
),
):
await session_logout(
response=fastapi_response,
user_api_key_dict=_ui_session_caller(),
)
set_cookie_headers = [v.decode() for k, v in fastapi_response.raw_headers if k == b"set-cookie"]
assert any(h.startswith('token="";') or h.startswith("token=;") for h in set_cookie_headers)
@pytest.mark.asyncio
async def test_session_logout_refuses_non_ui_session_key():
"""The endpoint must not become a generic key-deletion oracle: a normal
virtual key (no UI team id) is refused outright."""
prisma = _make_prisma()
p1, p2, p3 = _patched_globals(prisma)
with p1, p2, p3:
with pytest.raises(HTTPException) as exc_info:
await session_logout(
response=Response(),
user_api_key_dict=UserAPIKeyAuth(token=HASHED_TOKEN, team_id="some-real-team", user_id=USER_ID),
)
assert exc_info.value.status_code == 403
prisma.db.litellm_verificationtoken.delete_many.assert_not_called()
@pytest.mark.asyncio
async def test_session_logout_is_idempotent_when_row_already_gone():
prisma = _make_prisma(find_unique_row=None)
evict_mock = AsyncMock()
p1, p2, p3 = _patched_globals(prisma)
with (
p1,
p2,
p3,
patch(
"litellm.proxy.management_endpoints.session_endpoints.delete_cache_key_objects",
evict_mock,
),
):
response = await session_logout(
response=Response(),
user_api_key_dict=_ui_session_caller(),
)
assert response.message == "Session already revoked."
prisma.db.litellm_verificationtoken.delete_many.assert_not_called()
# The cache entry may outlive the row; evict regardless.
evict_mock.assert_awaited_once()
@pytest.mark.asyncio
async def test_session_logout_requires_db():
p2 = patch("litellm.proxy.proxy_server.proxy_logging_obj", None)
p3 = patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock())
with (
patch( # test-quality-ok: endpoint reads proxy_server module globals; no injection seam
"litellm.proxy.proxy_server.prisma_client", None
),
p2,
p3,
):
with pytest.raises(HTTPException) as exc_info:
await session_logout(
response=Response(),
user_api_key_dict=_ui_session_caller(),
)
assert exc_info.value.status_code == 500
@pytest.mark.asyncio
async def test_revoke_ui_session_keys_revokes_all_and_broadcasts():
rows = [_session_row(token="t1"), _session_row(token="t2"), _session_row(token="t3")]
prisma = _make_prisma(find_many_rows=rows)
persist_mock = AsyncMock()
evict_mock = AsyncMock()
p1, p2, p3 = _patched_globals(prisma)
with (
p1,
p2,
p3,
patch(
"litellm.proxy.management_endpoints.session_endpoints._persist_deleted_verification_tokens",
persist_mock,
),
patch(
"litellm.proxy.management_endpoints.session_endpoints.delete_cache_key_objects",
evict_mock,
),
):
revoked = await revoke_ui_session_keys(
user_id=USER_ID,
user_api_key_dict=_ui_session_caller(),
)
assert revoked == 3
find_kwargs = prisma.db.litellm_verificationtoken.find_many.call_args.kwargs
assert find_kwargs["where"] == {"user_id": USER_ID, "team_id": UI_SESSION_TOKEN_TEAM_ID}
delete_kwargs = prisma.db.litellm_verificationtoken.delete_many.call_args.kwargs
assert delete_kwargs["where"] == {"token": {"in": ["t1", "t2", "t3"]}}
persist_mock.assert_awaited_once()
evict_mock.assert_awaited_once()
assert evict_mock.await_args.kwargs["hashed_tokens"] == ["t1", "t2", "t3"]
@pytest.mark.asyncio
async def test_revoke_ui_session_keys_keeps_callers_session():
rows = [_session_row(token="t1"), _session_row(token=HASHED_TOKEN), _session_row(token="t3")]
prisma = _make_prisma(find_many_rows=rows)
p1, p2, p3 = _patched_globals(prisma)
with (
p1,
p2,
p3,
patch(
"litellm.proxy.management_endpoints.session_endpoints._persist_deleted_verification_tokens",
AsyncMock(),
),
patch(
"litellm.proxy.management_endpoints.session_endpoints.delete_cache_key_objects",
AsyncMock(),
),
):
revoked = await revoke_ui_session_keys(
user_id=USER_ID,
user_api_key_dict=_ui_session_caller(),
keep_hashed_token=HASHED_TOKEN,
)
assert revoked == 2
delete_kwargs = prisma.db.litellm_verificationtoken.delete_many.call_args.kwargs
assert delete_kwargs["where"] == {"token": {"in": ["t1", "t3"]}}
@pytest.mark.asyncio
async def test_revoke_ui_session_keys_noop_when_no_sessions():
prisma = _make_prisma(find_many_rows=[])
p1, p2, p3 = _patched_globals(prisma)
with p1, p2, p3:
revoked = await revoke_ui_session_keys(
user_id=USER_ID,
user_api_key_dict=_ui_session_caller(),
)
assert revoked == 0
prisma.db.litellm_verificationtoken.delete_many.assert_not_called()
@pytest.mark.asyncio
async def test_revoke_ui_session_keys_failure_is_swallowed():
"""The password write has already committed when this runs; a revocation
failure must not fail the caller's request."""
prisma = _make_prisma(find_many_rows=[_session_row(token="t1")])
prisma.db.litellm_verificationtoken.delete_many = AsyncMock(side_effect=RuntimeError("db down"))
p1, p2, p3 = _patched_globals(prisma)
with (
p1,
p2,
p3,
patch(
"litellm.proxy.management_endpoints.session_endpoints._persist_deleted_verification_tokens",
AsyncMock(),
),
):
revoked = await revoke_ui_session_keys(
user_id=USER_ID,
user_api_key_dict=_ui_session_caller(),
)
assert revoked == 0
@pytest.mark.asyncio
async def test_revoke_ui_session_keys_noop_without_db():
with patch( # test-quality-ok: helper reads proxy_server module globals; no injection seam
"litellm.proxy.proxy_server.prisma_client", None
):
revoked = await revoke_ui_session_keys(
user_id=USER_ID,
user_api_key_dict=_ui_session_caller(),
)
assert revoked == 0

View file

@ -15,7 +15,7 @@ import { changePasswordCall, getProxyBaseUrl } from "@/components/networking";
import { extractProxyErrorMessage } from "@/lib/http/client";
import { useZodForm } from "@/lib/forms/useZodForm";
import { toast } from "@/lib/toast";
import { clearTokenCookies } from "@/utils/cookieUtils";
import { revokeSessionAndClearClientState } from "@/app/(dashboard)/hooks/useLogout";
import { getLoginUrl } from "@/utils/returnUrlUtils";
const changePasswordSchema = z
@ -47,8 +47,10 @@ export function ChangePasswordForm() {
await changePasswordCall(accessToken, values.currentPassword, values.newPassword);
if (passwordResetRequired) {
// The session key was minted restricted; only a fresh login lifts it.
// Revoke it server-side too (best-effort) so it doesn't sit valid
// until the expiry reaper gets to it.
toast.success("Password updated. Please log in with your new password.");
clearTokenCookies();
await revokeSessionAndClearClientState(accessToken);
window.location.replace(getLoginUrl(getProxyBaseUrl()));
return;
}

View file

@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const sessionLogoutCall = vi.hoisted(() => vi.fn());
const clearTokenCookies = vi.hoisted(() => vi.fn());
const clearStoredReturnUrl = vi.hoisted(() => vi.fn());
vi.mock("@/components/networking", () => ({
sessionLogoutCall,
}));
vi.mock("@/utils/cookieUtils", () => ({
clearTokenCookies,
}));
vi.mock("@/utils/returnUrlUtils", () => ({
clearStoredReturnUrl,
}));
vi.mock("@/app/(dashboard)/hooks/proxySettings/useProxySettings", () => ({
default: vi.fn(() => ({ PROXY_LOGOUT_URL: "" })),
}));
import { revokeSessionAndClearClientState } from "./useLogout";
describe("revokeSessionAndClearClientState", () => {
beforeEach(() => {
vi.clearAllMocks();
sessionLogoutCall.mockResolvedValue({ message: "Session revoked." });
localStorage.setItem("litellm_selected_worker_id", "w1");
localStorage.setItem("litellm_worker_url", "https://worker.example");
});
it("revokes the session server-side before clearing the token cookie", async () => {
const order: string[] = [];
sessionLogoutCall.mockImplementation(async () => {
order.push("revoke");
return { message: "Session revoked." };
});
clearTokenCookies.mockImplementation(() => {
order.push("clearCookies");
});
await revokeSessionAndClearClientState("sk-token");
expect(sessionLogoutCall).toHaveBeenCalledWith("sk-token");
// The cookie holds the credential that authenticates the revoke call, so
// clearing it first would orphan the server-side key.
expect(order).toEqual(["revoke", "clearCookies"]);
});
it("clears all client state", async () => {
await revokeSessionAndClearClientState("sk-token");
expect(clearTokenCookies).toHaveBeenCalled();
expect(clearStoredReturnUrl).toHaveBeenCalled();
expect(localStorage.getItem("litellm_selected_worker_id")).toBeNull();
expect(localStorage.getItem("litellm_worker_url")).toBeNull();
});
it("still clears client state when the revoke call rejects", async () => {
sessionLogoutCall.mockRejectedValue(new Error("proxy unreachable"));
await revokeSessionAndClearClientState("sk-token");
expect(clearTokenCookies).toHaveBeenCalled();
expect(localStorage.getItem("litellm_selected_worker_id")).toBeNull();
});
it("skips the server call without a token but still clears client state", async () => {
await revokeSessionAndClearClientState(null);
expect(sessionLogoutCall).not.toHaveBeenCalled();
expect(clearTokenCookies).toHaveBeenCalled();
});
});

View file

@ -1,7 +1,29 @@
import { sessionLogoutCall } from "@/components/networking";
import { clearTokenCookies } from "@/utils/cookieUtils";
import { clearStoredReturnUrl } from "@/utils/returnUrlUtils";
import useProxySettings from "@/app/(dashboard)/hooks/proxySettings/useProxySettings";
/**
* Revokes the session key server-side, then clears client state. Exported for
* flows that navigate somewhere other than PROXY_LOGOUT_URL (worker switch,
* forced password reset). The server call must happen BEFORE the cookies are
* cleared (the token authenticates it) and is best-effort: local logout must
* still complete when the server is unreachable.
*/
export async function revokeSessionAndClearClientState(accessToken: string | null): Promise<void> {
if (accessToken) {
try {
await sessionLogoutCall(accessToken);
} catch {
// Best-effort: the key still expires server-side at its session TTL.
}
}
clearTokenCookies();
clearStoredReturnUrl();
localStorage.removeItem("litellm_selected_worker_id");
localStorage.removeItem("litellm_worker_url");
}
/**
* Shared sign-out handler. Used by both the top navbar and the sidebar footer so
* the two entry points can never drift on which client state gets cleared.
@ -10,10 +32,8 @@ export function useLogout(accessToken: string | null): () => void {
const proxySettings = useProxySettings(accessToken);
return () => {
clearTokenCookies();
clearStoredReturnUrl();
localStorage.removeItem("litellm_selected_worker_id");
localStorage.removeItem("litellm_worker_url");
window.location.href = proxySettings.PROXY_LOGOUT_URL || "";
void revokeSessionAndClearClientState(accessToken).finally(() => {
window.location.href = proxySettings.PROXY_LOGOUT_URL || "";
});
};
}

View file

@ -5,9 +5,8 @@ import { useWorker } from "@/hooks/useWorker";
import { getProxyBaseUrl } from "@/components/networking";
import { uiHref } from "@/utils/uiHref";
import { useTheme } from "@/contexts/ThemeContext";
import { clearTokenCookies } from "@/utils/cookieUtils";
import { clearStoredReturnUrl, getLoginUrl } from "@/utils/returnUrlUtils";
import useProxySettings from "@/app/(dashboard)/hooks/proxySettings/useProxySettings";
import { revokeSessionAndClearClientState, useLogout } from "@/app/(dashboard)/hooks/useLogout";
import { getLoginUrl } from "@/utils/returnUrlUtils";
import { Badge } from "@/components/ui/badge";
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
import Link from "next/link";
@ -38,7 +37,6 @@ const Navbar: React.FC<NavbarProps> = ({
onToggleSidebar,
}) => {
const baseUrl = getProxyBaseUrl();
const proxySettings = useProxySettings(accessToken);
const { logoUrl } = useTheme();
const { data: healthData } = useHealthReadinessDetails(accessToken);
const version = healthData?.litellm_version;
@ -50,19 +48,12 @@ const Navbar: React.FC<NavbarProps> = ({
const imageUrl = logoUrl || `${baseUrl}/get_image`;
const darkImageUrl = logoUrl || `${baseUrl}/get_image?theme=dark`;
const handleLogout = () => {
clearTokenCookies();
localStorage.removeItem("litellm_selected_worker_id");
localStorage.removeItem("litellm_worker_url");
window.location.href = proxySettings.PROXY_LOGOUT_URL || "";
};
const handleLogout = useLogout(accessToken);
const handleWorkerSwitch = (workerId: string) => {
clearTokenCookies();
clearStoredReturnUrl();
localStorage.removeItem("litellm_selected_worker_id");
localStorage.removeItem("litellm_worker_url");
window.location.href = `${getLoginUrl()}?worker=${encodeURIComponent(workerId)}`;
void revokeSessionAndClearClientState(accessToken).finally(() => {
window.location.href = `${getLoginUrl()}?worker=${encodeURIComponent(workerId)}`;
});
};
return (

View file

@ -1595,6 +1595,18 @@ export const claimOnboardingToken = async (
}
};
/**
* Revokes the UI session key server-side (POST /session/logout). Best-effort
* with a short timeout: logout must still complete locally when the server is
* unreachable, so callers swallow rejections.
*/
export const sessionLogoutCall = async (accessToken: string): Promise<{ message: string }> => {
return await apiClient.post(`/session/logout`, {
accessToken,
signal: AbortSignal.timeout(3000),
});
};
export const changePasswordCall = async (
accessToken: string,
currentPassword: string,

View file

@ -14485,6 +14485,31 @@ export interface paths {
patch?: never;
trace?: never;
};
"/session/logout": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Session Logout
* @description Revoke the UI session key this request authenticated with.
*
* Only accepts UI session keys (minted by dashboard login); any other
* credential is refused, so this can never be used to delete arbitrary keys.
* Revokes only the presented session, not the user's other sessions.
* Idempotent: logging out an already-revoked session succeeds.
*/
post: operations["session_logout_session_logout_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/settings": {
parameters: {
query?: never;
@ -38133,6 +38158,11 @@ export interface components {
/** Timeout */
timeout?: number | null;
};
/** SessionLogoutResponse */
SessionLogoutResponse: {
/** Message */
message: string;
};
/**
* ShadowEvalJobResponse
* @description A shadow-eval job over one or more targets, each with its own budget and stop state;
@ -60958,6 +60988,26 @@ export interface operations {
};
};
};
session_logout_session_logout_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SessionLogoutResponse"];
};
};
};
};
active_callbacks_settings_get: {
parameters: {
query?: never;