From fcbe23f6a8b9fe04b26fec6a17bfe538a6e09378 Mon Sep 17 00:00:00 2001 From: Oliver Jensen Date: Mon, 7 Sep 2026 12:47:39 +0200 Subject: [PATCH 1/6] feat(auth): force password reset for breached or admin-set passwords --- .../migration.sql | 3 + .../litellm_proxy_extras/schema.prisma | 2 + litellm/models/user.py | 2 + litellm/proxy/auth/login_utils.py | 70 +++++ litellm/proxy/auth/route_checks.py | 10 + .../internal_user_endpoints.py | 9 +- .../password_endpoints.py | 9 +- litellm/proxy/management_endpoints/ui_sso.py | 1 + litellm/proxy/proxy_server.py | 9 +- litellm/proxy/schema.prisma | 2 + litellm/types/proxy/ui_sso.py | 3 +- schema.prisma | 2 + .../proxy/auth/test_login_utils.py | 243 ++++++++++++++++++ .../proxy/auth/test_onboarding.py | 4 + .../proxy/auth/test_route_checks.py | 61 +++++ .../test_internal_user_endpoints.py | 4 + .../test_password_endpoints.py | 4 + .../ChangePasswordForm.integration.test.tsx | 46 +++- .../change-password/ChangePasswordForm.tsx | 23 +- .../app/(dashboard)/hooks/useAuthorized.ts | 1 + .../src/app/(dashboard)/layout.test.tsx | 57 +++- .../src/app/(dashboard)/layout.tsx | 11 +- .../src/contexts/AuthContext.tsx | 4 + ui/litellm-dashboard/src/lib/http/schema.d.ts | 13 +- 24 files changed, 579 insertions(+), 14 deletions(-) create mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20260904000000_add_password_reset_columns/migration.sql diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260904000000_add_password_reset_columns/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260904000000_add_password_reset_columns/migration.sql new file mode 100644 index 00000000000..960b0d4d7eb --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260904000000_add_password_reset_columns/migration.sql @@ -0,0 +1,3 @@ +ALTER TABLE "LiteLLM_UserTable" ADD COLUMN IF NOT EXISTS "password_reset_required" BOOLEAN; + +ALTER TABLE "LiteLLM_UserTable" ADD COLUMN IF NOT EXISTS "last_breach_check_at" TIMESTAMP(3); diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index 7d521d54791..a2b32dd8e73 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -241,6 +241,8 @@ model LiteLLM_UserTable { organization_id String? object_permission_id String? password String? + password_reset_required Boolean? + last_breach_check_at DateTime? teams String[] @default([]) user_role String? max_budget Float? diff --git a/litellm/models/user.py b/litellm/models/user.py index 82f78c28078..92aca87d303 100644 --- a/litellm/models/user.py +++ b/litellm/models/user.py @@ -24,6 +24,8 @@ class LiteLLM_UserTable(LiteLLMPydanticObjectBase): organization_id: str | None = None object_permission_id: str | None = None password: str | None = Field(default=None, exclude=True) + password_reset_required: bool | None = None + last_breach_check_at: datetime | None = None teams: list[str] = [] user_role: str | None = None max_budget: float | None = None diff --git a/litellm/proxy/auth/login_utils.py b/litellm/proxy/auth/login_utils.py index c0a76a4fc20..a5809ce0899 100644 --- a/litellm/proxy/auth/login_utils.py +++ b/litellm/proxy/auth/login_utils.py @@ -5,6 +5,7 @@ This module contains the core login logic that can be reused across different login endpoints (e.g., /login and /v2/login). """ +import asyncio import os import secrets from collections.abc import Mapping @@ -16,8 +17,10 @@ import jwt from fastapi import HTTPException import litellm +from litellm._logging import verbose_proxy_logger from litellm.constants import LITELLM_PROXY_ADMIN_NAME, LITELLM_UI_SESSION_DURATION from litellm.litellm_core_utils.duration_parser import duration_in_seconds +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler from litellm.proxy._types import ( LiteLLM_UserTable, LitellmUserRoles, @@ -27,6 +30,7 @@ from litellm.proxy._types import ( UserAPIKeyAuth, ) from litellm.proxy.auth.auth_utils import is_sso_provider_fully_configured +from litellm.proxy.auth.password_policy import is_breach_check_enabled, is_password_breached from litellm.proxy.management_endpoints.internal_user_endpoints import user_update from litellm.proxy.management_endpoints.key_management_endpoints import ( generate_key_helper_fn, @@ -44,6 +48,48 @@ from litellm.repositories.user_repository import UserRepository from litellm.secret_managers.main import get_secret_bool from litellm.types.proxy.ui_sso import ReturnedUITokenObject +BREACH_RECHECK_INTERVAL: Final = timedelta(hours=24) +PASSWORD_RESET_ALLOWED_ROUTES: Final = ("/user/password/change",) + + +def _breach_recheck_due(last_breach_check_at: datetime | None) -> bool: + if last_breach_check_at is None: + return True + last_checked_utc: Final = ( + last_breach_check_at + if last_breach_check_at.tzinfo is not None + else last_breach_check_at.replace(tzinfo=timezone.utc) + ) + return datetime.now(timezone.utc) - last_checked_utc >= BREACH_RECHECK_INTERVAL + + +async def screen_login_password_for_breach( + user_id: str, + password: str, + last_breach_check_at: datetime | None, + general_settings: Mapping[str, object], + prisma_client: PrismaClient, + client: AsyncHTTPHandler | None = None, +) -> None: + """Background task behind a successful password login: screens the password + against HIBP and stamps ``password_reset_required`` when breached, so the + NEXT login is restricted to the change-password flow. Never blocks or fails + the login it runs behind, and rechecks a given user at most once per + ``BREACH_RECHECK_INTERVAL``.""" + if not is_breach_check_enabled(general_settings): + return + if not _breach_recheck_due(last_breach_check_at): + return + breached: Final = await is_password_breached(password, general_settings, client) + update_data: Final = { + "last_breach_check_at": datetime.now(timezone.utc), + **({"password_reset_required": True} if breached else {}), + } + try: + await UserRepository(prisma_client).table.update(where={"user_id": user_id}, data=update_data) + except Exception as e: # noqa: BLE001 # fire-and-forget: a failed stamp must never surface into the login + verbose_proxy_logger.warning("Login-time breach screening could not update user %s: %s", user_id, e) + async def _rehash_password_if_needed(user_id: str, password: str, stored: str) -> None: """Rehash legacy password (SHA256) to scrypt on successful login.""" @@ -116,6 +162,7 @@ class LoginResult: user_email: str | None user_role: str login_method: Literal["sso", "username_password"] + password_reset_required: bool def __init__( self, @@ -124,12 +171,14 @@ class LoginResult: user_email: str | None, user_role: str, login_method: Literal["sso", "username_password"] = "username_password", + password_reset_required: bool = False, ): self.user_id = user_id self.key = key self.user_email = user_email self.user_role = user_role self.login_method = login_method + self.password_reset_required = password_reset_required async def authenticate_user( @@ -322,6 +371,17 @@ async def authenticate_user( if verify_password(password, _password): await _rehash_password_if_needed(_user_row.user_id, password, _password) + if prisma_client is not None: + asyncio.create_task( + screen_login_password_for_breach( + user_id=_user_row.user_id, + password=password, + last_breach_check_at=getattr(_user_row, "last_breach_check_at", None), + general_settings=general_settings, + prisma_client=prisma_client, + ) + ) + password_reset_required: Final = getattr(_user_row, "password_reset_required", None) is True if os.getenv("DATABASE_URL") is not None: response = await generate_key_helper_fn( request_type="key", @@ -335,6 +395,14 @@ async def authenticate_user( "spend": 0, "user_id": user_id, "team_id": "litellm-dashboard", + **( + { + "allowed_routes": list(PASSWORD_RESET_ALLOWED_ROUTES), + "metadata": {"password_reset_required": True}, + } + if password_reset_required + else {} + ), }, ) else: @@ -353,6 +421,7 @@ async def authenticate_user( user_email=user_email, user_role=cast(str, user_role), login_method="username_password", + password_reset_required=password_reset_required, ) else: raise ProxyException( @@ -426,4 +495,5 @@ def create_ui_token_object( auth_header_name=general_settings.get("litellm_key_header_name", "Authorization"), disabled_non_admin_personal_key_creation=disabled_non_admin_personal_key_creation, server_root_path=get_server_root_path(), + password_reset_required=login_result.password_reset_required, ) diff --git a/litellm/proxy/auth/route_checks.py b/litellm/proxy/auth/route_checks.py index e4a36e73373..c4109bdc2f1 100644 --- a/litellm/proxy/auth/route_checks.py +++ b/litellm/proxy/auth/route_checks.py @@ -187,6 +187,16 @@ class RouteChecks: if denied_auth_enforced_pass_through_route: raise RouteChecks._auth_pass_through_denied_exception(route=route) + if valid_token.metadata.get("password_reset_required") is True: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=( + "This account's password must be changed before the session can be used: " + "it was either found in a known data breach or set by an admin. " + "Change it via POST /user/password/change (UI: /ui/change-password), then log in again." + ), + ) + raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Virtual key is not allowed to call this route. Only allowed to call routes: {valid_token.allowed_routes}. Tried to call route: {route}", diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index b564e3b9f64..2d17aa69745 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -168,12 +168,17 @@ async def _hash_password_in_dict( """Validate and hash password field in-place if present. ``password_prevalidated`` skips the policy checks for callers that already - validated the password (the bulk path screens its whole batch upfront).""" + validated the password (the bulk path screens its whole batch upfront). + + An admin-set password is known to whoever set it, so the user is also + flagged for a forced password change at next login.""" if "password" in data and data["password"] is not None: if not password_prevalidated: validate_password_policy(data["password"], general_settings) await validate_password_not_breached(data["password"], general_settings) data["password"] = hash_password(data["password"]) + data["password_reset_required"] = True + data["last_breach_check_at"] = None def _strip_password_from_response(response) -> None: @@ -1625,7 +1630,7 @@ async def user_update( Parameters: - user_id: Optional[str] - Specify a user id. If not set, a unique id will be generated. - user_email: Optional[str] - Specify a user email. - - password: Optional[str] - Set the user's password (admin only). Must satisfy the configured password policy. Users change their own password with POST /user/password/change. + - password: Optional[str] - Set the user's password (admin only). Must satisfy the configured password policy. The user is required to change it at their next login. Users change their own password with POST /user/password/change. - user_alias: Optional[str] - A descriptive name for you to know who this user id refers to. - teams: Optional[list] - specify a list of team id's a user belongs to. - send_invite_email: Optional[bool] - Specify if an invite email should be sent. diff --git a/litellm/proxy/management_endpoints/password_endpoints.py b/litellm/proxy/management_endpoints/password_endpoints.py index c1d409cc08d..bd3c9722d4d 100644 --- a/litellm/proxy/management_endpoints/password_endpoints.py +++ b/litellm/proxy/management_endpoints/password_endpoints.py @@ -66,7 +66,8 @@ async def change_password( Requires the current password. The new password must satisfy the configured password policy (`general_settings.password_policy_*`: minimum length, character classes, and, when enabled, breached-password screening - via haveibeenpwned.com). + via haveibeenpwned.com). A successful change lifts any pending forced + password reset (`password_reset_required`) on the account. Parameters: - current_password: str - The user's current password. @@ -105,7 +106,11 @@ async def change_password( validate_password_policy(data.new_password, general_settings) await validate_password_not_breached(data.new_password, general_settings) - password_update: Final[prisma_types.LiteLLM_UserTableUpdateInput] = {"password": hash_password(data.new_password)} + password_update: Final[prisma_types.LiteLLM_UserTableUpdateInput] = { + "password": hash_password(data.new_password), + "password_reset_required": False, + "last_breach_check_at": None, + } await _user_table(prisma_client).update(where=find_user, data=password_update) verbose_proxy_logger.info("Password changed via /user/password/change for user_id=%s", user_id) diff --git a/litellm/proxy/management_endpoints/ui_sso.py b/litellm/proxy/management_endpoints/ui_sso.py index 1ba90725eff..5279ebcfcc8 100644 --- a/litellm/proxy/management_endpoints/ui_sso.py +++ b/litellm/proxy/management_endpoints/ui_sso.py @@ -3666,6 +3666,7 @@ class SSOAuthenticationHandler: auth_header_name=general_settings.get("litellm_key_header_name", "Authorization"), disabled_non_admin_personal_key_creation=disabled_non_admin_personal_key_creation, server_root_path=get_server_root_path(), + password_reset_required=False, ) from litellm.proxy.auth.login_utils import encode_ui_session_jwt diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index f6798d51566..e2dbd237016 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -16134,6 +16134,7 @@ async def onboarding(invite_link: str, request: Request): auth_header_name=general_settings.get("litellm_key_header_name", "Authorization"), disabled_non_admin_personal_key_creation=disabled_non_admin_personal_key_creation, server_root_path=get_server_root_path(), + password_reset_required=False, ) jwt_token: Final = jwt.encode( cast(dict, returned_ui_token_object), @@ -16243,6 +16244,7 @@ async def _generate_onboarding_ui_session_token(user_obj: _UserTableRow) -> str: auth_header_name=general_settings.get("litellm_key_header_name", "Authorization"), disabled_non_admin_personal_key_creation=disabled_non_admin_personal_key_creation, server_root_path=get_server_root_path(), + password_reset_required=False, ) assert master_key is not None return jwt.encode( @@ -16333,7 +16335,12 @@ async def claim_onboarding_link(data: InvitationClaim, request: Request): ### UPDATE USER OBJECT ### user_obj: Final[_UserTableRow | None] = await tx.litellm_usertable.update( - where={"user_id": invite_obj.user_id}, data={"password": hashed_pw} + where={"user_id": invite_obj.user_id}, + data={ + "password": hashed_pw, + "password_reset_required": False, + "last_breach_check_at": None, + }, ) if user_obj is None: diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 7d521d54791..a2b32dd8e73 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -241,6 +241,8 @@ model LiteLLM_UserTable { organization_id String? object_permission_id String? password String? + password_reset_required Boolean? + last_breach_check_at DateTime? teams String[] @default([]) user_role String? max_budget Float? diff --git a/litellm/types/proxy/ui_sso.py b/litellm/types/proxy/ui_sso.py index 0d7e0b99cf0..03b0b92a4d1 100644 --- a/litellm/types/proxy/ui_sso.py +++ b/litellm/types/proxy/ui_sso.py @@ -1,6 +1,6 @@ from typing import Literal -from typing_extensions import TypedDict +from typing_extensions import ReadOnly, TypedDict class ReturnedUITokenObject(TypedDict): @@ -17,6 +17,7 @@ class ReturnedUITokenObject(TypedDict): auth_header_name: str disabled_non_admin_personal_key_creation: bool server_root_path: str # e.g. `/litellm` + password_reset_required: ReadOnly[bool] class ParsedOpenIDResult(TypedDict, total=False): diff --git a/schema.prisma b/schema.prisma index 7d521d54791..a2b32dd8e73 100644 --- a/schema.prisma +++ b/schema.prisma @@ -241,6 +241,8 @@ model LiteLLM_UserTable { organization_id String? object_permission_id String? password String? + password_reset_required Boolean? + last_breach_check_at DateTime? teams String[] @default([]) user_role String? max_budget Float? diff --git a/tests/test_litellm/proxy/auth/test_login_utils.py b/tests/test_litellm/proxy/auth/test_login_utils.py index e209a491b0a..16547db527c 100644 --- a/tests/test_litellm/proxy/auth/test_login_utils.py +++ b/tests/test_litellm/proxy/auth/test_login_utils.py @@ -5,13 +5,17 @@ This module tests the refactored login logic that was moved from proxy_server.py to login_utils.py for better reusability. """ +import hashlib import os from contextlib import ExitStack +from datetime import datetime, timedelta, timezone from unittest.mock import AsyncMock, MagicMock, patch +import httpx import pytest from litellm.constants import LITELLM_PROXY_ADMIN_NAME +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler from litellm.proxy._types import ( LiteLLM_UserTable, LitellmUserRoles, @@ -24,8 +28,13 @@ from litellm.proxy.auth.login_utils import ( authenticate_user, get_ui_credentials, is_env_credential_login_enabled, + screen_login_password_for_breach, ) +# Successful DB-user logins schedule the background HIBP screen; disable it so +# no test ever does live network I/O to haveibeenpwned.com from CI. +_POLICY_NO_BREACH_CHECK = {"password_policy_check_breached_passwords": False} + def test_get_ui_credentials_prefers_explicit_password(): """The configured UI password should be returned when available.""" @@ -298,12 +307,14 @@ async def test_authenticate_user_email_case_insensitive_login(): password=correct_password, master_key=master_key, prisma_client=mock_prisma_client, + general_settings=_POLICY_NO_BREACH_CHECK, ) result_lower = await authenticate_user( username=stored_email, password=correct_password, master_key=master_key, prisma_client=mock_prisma_client, + general_settings=_POLICY_NO_BREACH_CHECK, ) assert result_mixed.user_id == result_lower.user_id == "test-user-123" @@ -541,6 +552,7 @@ async def test_authenticate_user_database_login_with_non_ascii_password(): password=password_with_special_char, master_key=master_key, prisma_client=mock_prisma_client, + general_settings=_POLICY_NO_BREACH_CHECK, ) assert isinstance(result, LoginResult) @@ -956,3 +968,234 @@ class TestIsEnvCredentialLoginEnabled: with ExitStack() as stack: _patch_sso_configured(stack, configured=False) assert is_env_credential_login_enabled({"disable_password_login_when_sso_enabled": True}) is True + + +def _db_user_row(*, password: str, password_reset_required: bool | None = None, last_breach_check_at=None): + hashed = hash_token(token=password) + row = MagicMock() + row.user_id = "reset-user-1" + row.user_email = "reset@example.com" + row.password = hashed + row.user_role = LitellmUserRoles.INTERNAL_USER + row.password_reset_required = password_reset_required + row.last_breach_check_at = last_breach_check_at + return row + + +def _prisma_with_user(row) -> MagicMock: + mock_prisma_client = MagicMock() + mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=row) + mock_prisma_client.db.litellm_usertable.update = AsyncMock(return_value=row) + return mock_prisma_client + + +_DB_LOGIN_ENV = { + "DATABASE_URL": "postgresql://test:test@localhost/test", + "UI_USERNAME": "admin", + "UI_PASSWORD": "admin-password", +} + + +class TestPasswordResetRequiredSessionMinting: + """A user flagged `password_reset_required` must receive a UI session key + restricted to the change-password endpoint (server-side enforcement, so a + script driving the management API with the session key is blocked too); + an unflagged user must keep getting an unrestricted key.""" + + async def _login(self, mock_prisma_client) -> tuple[LoginResult, dict]: + with patch.dict(os.environ, _DB_LOGIN_ENV): + with patch( + "litellm.proxy.auth.login_utils.generate_key_helper_fn", + new_callable=AsyncMock, + return_value={"token": "session-token"}, + ) as mock_generate_key: + result = await authenticate_user( + username="reset@example.com", + password="Str0ng!Passw0rd", + master_key="sk-1234", + prisma_client=mock_prisma_client, + general_settings=_POLICY_NO_BREACH_CHECK, + ) + return result, mock_generate_key.call_args.kwargs + + @pytest.mark.asyncio + async def test_flagged_user_gets_key_restricted_to_change_password(self): + 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["metadata"] == {"password_reset_required": True} + assert result.password_reset_required is True + + @pytest.mark.asyncio + async def test_unflagged_user_gets_unrestricted_key(self): + row = _db_user_row(password="Str0ng!Passw0rd", password_reset_required=None) + result, key_kwargs = await self._login(_prisma_with_user(row)) + + assert "allowed_routes" not in key_kwargs + assert "metadata" not in key_kwargs + assert result.password_reset_required is False + + @pytest.mark.asyncio + async def test_login_schedules_breach_screen_with_row_state(self): + """The login must hand the background screen the row's recheck timestamp, + or the 24h throttle can never work.""" + checked_at = datetime.now(timezone.utc) - timedelta(hours=1) + row = _db_user_row(password="Str0ng!Passw0rd", last_breach_check_at=checked_at) + mock_prisma_client = _prisma_with_user(row) + + with patch.dict(os.environ, _DB_LOGIN_ENV): + with patch( + "litellm.proxy.auth.login_utils.generate_key_helper_fn", + new_callable=AsyncMock, + return_value={"token": "session-token"}, + ): + with patch( + "litellm.proxy.auth.login_utils.screen_login_password_for_breach", + new_callable=AsyncMock, + ) as mock_screen: + await authenticate_user( + username="reset@example.com", + password="Str0ng!Passw0rd", + master_key="sk-1234", + prisma_client=mock_prisma_client, + general_settings=_POLICY_NO_BREACH_CHECK, + ) + + screen_kwargs = mock_screen.call_args.kwargs + assert screen_kwargs["user_id"] == "reset-user-1" + assert screen_kwargs["password"] == "Str0ng!Passw0rd" + assert screen_kwargs["last_breach_check_at"] == checked_at + assert screen_kwargs["prisma_client"] is mock_prisma_client + + +def _sha1_upper(password: str) -> str: + return hashlib.sha1(password.encode("utf-8"), usedforsecurity=False).hexdigest().upper() + + +def _client_with_transport(handler) -> AsyncHTTPHandler: + http_handler = AsyncHTTPHandler() + http_handler.client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + return http_handler + + +def _client_returning_breach_hit(password: str) -> AsyncHTTPHandler: + body = f"{_sha1_upper(password)[5:]}:42" + return _client_with_transport(lambda request: httpx.Response(200, text=body)) + + +def _client_returning_no_hit() -> AsyncHTTPHandler: + return _client_with_transport(lambda request: httpx.Response(200, text="0000000000000000000000000000000000A:3")) + + +def _client_never_called() -> AsyncHTTPHandler: + def handler(request: httpx.Request) -> httpx.Response: + raise AssertionError(f"unexpected HTTP call to {request.url}") + + return _client_with_transport(handler) + + +class TestScreenLoginPasswordForBreach: + """The fire-and-forget login-time screen: flags a breached password for a + forced reset, stamps the recheck timestamp, rechecks at most every 24h, + and never raises into the login it runs behind.""" + + @pytest.mark.asyncio + async def test_breached_password_sets_reset_flag_and_timestamp(self): + password = "Password123!" + mock_prisma_client = _prisma_with_user(None) + + await screen_login_password_for_breach( + user_id="reset-user-1", + password=password, + last_breach_check_at=None, + general_settings={}, + prisma_client=mock_prisma_client, + client=_client_returning_breach_hit(password), + ) + + update_kwargs = mock_prisma_client.db.litellm_usertable.update.call_args.kwargs + assert update_kwargs["where"] == {"user_id": "reset-user-1"} + assert update_kwargs["data"]["password_reset_required"] is True + assert isinstance(update_kwargs["data"]["last_breach_check_at"], datetime) + + @pytest.mark.asyncio + async def test_clean_password_stamps_timestamp_without_flag(self): + mock_prisma_client = _prisma_with_user(None) + + await screen_login_password_for_breach( + user_id="reset-user-1", + password="Str0ng!Passw0rd", + last_breach_check_at=None, + general_settings={}, + prisma_client=mock_prisma_client, + client=_client_returning_no_hit(), + ) + + update_kwargs = mock_prisma_client.db.litellm_usertable.update.call_args.kwargs + assert "password_reset_required" not in update_kwargs["data"] + assert isinstance(update_kwargs["data"]["last_breach_check_at"], datetime) + + @pytest.mark.asyncio + async def test_skips_hibp_when_checked_within_24_hours(self): + mock_prisma_client = _prisma_with_user(None) + + await screen_login_password_for_breach( + user_id="reset-user-1", + password="Password123!", + last_breach_check_at=datetime.now(timezone.utc) - timedelta(hours=23), + general_settings={}, + prisma_client=mock_prisma_client, + client=_client_never_called(), + ) + + mock_prisma_client.db.litellm_usertable.update.assert_not_called() + + @pytest.mark.asyncio + async def test_rechecks_when_last_check_is_older_than_24_hours(self): + password = "Password123!" + mock_prisma_client = _prisma_with_user(None) + + await screen_login_password_for_breach( + user_id="reset-user-1", + password=password, + last_breach_check_at=datetime.now(timezone.utc) - timedelta(hours=25), + general_settings={}, + prisma_client=mock_prisma_client, + client=_client_returning_breach_hit(password), + ) + + assert mock_prisma_client.db.litellm_usertable.update.call_args.kwargs["data"]["password_reset_required"] is True + + @pytest.mark.asyncio + async def test_skips_hibp_when_check_disabled(self): + mock_prisma_client = _prisma_with_user(None) + + await screen_login_password_for_breach( + user_id="reset-user-1", + password="Password123!", + last_breach_check_at=None, + general_settings=_POLICY_NO_BREACH_CHECK, + prisma_client=mock_prisma_client, + client=_client_never_called(), + ) + + mock_prisma_client.db.litellm_usertable.update.assert_not_called() + + @pytest.mark.asyncio + async def test_db_failure_never_raises_into_the_login(self): + password = "Password123!" + mock_prisma_client = _prisma_with_user(None) + mock_prisma_client.db.litellm_usertable.update = AsyncMock(side_effect=RuntimeError("db down")) + + assert ( + await screen_login_password_for_breach( + user_id="reset-user-1", + password=password, + last_breach_check_at=None, + general_settings={}, + prisma_client=mock_prisma_client, + client=_client_returning_breach_hit(password), + ) + is None + ) diff --git a/tests/test_litellm/proxy/auth/test_onboarding.py b/tests/test_litellm/proxy/auth/test_onboarding.py index 8939baedd50..0454aea1239 100644 --- a/tests/test_litellm/proxy/auth/test_onboarding.py +++ b/tests/test_litellm/proxy/auth/test_onboarding.py @@ -463,6 +463,10 @@ async def test_claim_token_sets_accepted_at_after_password_written(): call_kwargs = prisma.db.litellm_usertable.update.call_args assert call_kwargs.kwargs["where"] == {"user_id": "user-123"} assert "password" in call_kwargs.kwargs["data"] + # A freshly claimed, policy-screened password lifts any pending forced + # reset and re-arms the login-time breach screen. + assert call_kwargs.kwargs["data"]["password_reset_required"] is False + assert call_kwargs.kwargs["data"]["last_breach_check_at"] is None # is_accepted was flipped to True on the invitation link prisma.db.litellm_invitationlink.update.assert_called_once() diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index 4f58e3c86ff..83ebc3c9225 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -3717,6 +3717,67 @@ def test_non_admin_roles_can_change_own_password(user_role): assert allowed is None +def _password_reset_session_token() -> UserAPIKeyAuth: + """The UI session key `authenticate_user` mints for a user flagged + `password_reset_required`.""" + return UserAPIKeyAuth( + user_id="flagged_user", + allowed_routes=["/user/password/change"], + metadata={"password_reset_required": True}, + ) + + +def test_password_reset_session_can_reach_change_password(): + result = RouteChecks.is_virtual_key_allowed_to_call_route( + route="/user/password/change", + valid_token=_password_reset_session_token(), + ) + + assert result is True + + +@pytest.mark.parametrize( + "route", + [ + "/user/info", + "/key/generate", + "/user/update", + "/chat/completions", + ], +) +def test_password_reset_session_is_blocked_everywhere_else_with_reset_message(route): + """Server-side enforcement of the forced reset: a script that logs in via + /v2/login and drives the management API with the session key must get a 403 + naming the remediation endpoint, on every route but the change-password one.""" + with pytest.raises(HTTPException) as exc_info: + RouteChecks.is_virtual_key_allowed_to_call_route( + route=route, + valid_token=_password_reset_session_token(), + ) + + assert exc_info.value.status_code == 403 + assert "password must be changed" in str(exc_info.value.detail) + assert "/user/password/change" in str(exc_info.value.detail) + + +def test_restricted_key_without_reset_marker_keeps_generic_message(): + """The reset-specific 403 must not leak onto ordinary allowed_routes keys.""" + valid_token = UserAPIKeyAuth( + user_id="test_user", + allowed_routes=["/chat/completions"], + ) + + with pytest.raises(HTTPException) as exc_info: + RouteChecks.is_virtual_key_allowed_to_call_route( + route="/user/info", + valid_token=valid_token, + ) + + assert exc_info.value.status_code == 403 + assert "password must be changed" not in str(exc_info.value.detail) + assert "not allowed to call this route" in str(exc_info.value.detail) + + TEAM_CALLBACK_ROUTES = ( "/team/06bda574-5ca9-43d3-beb8-3b23c2f17112/callback", "/team/06bda574-5ca9-43d3-beb8-3b23c2f17112/callback/langfuse", diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py index f742c0012b1..d378bbdeb04 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py @@ -4141,6 +4141,10 @@ async def test_user_update_hashes_and_persists_strong_password(_admin_prisma, mo written_data = mock_prisma_client.update_data.call_args.kwargs["data"] assert written_data.get("password") is not None assert written_data["password"] != strong_password + # An admin-set password is known to the admin, so the user must be forced + # to change it at next login and the breach screen re-armed. + assert written_data["password_reset_required"] is True + assert written_data["last_breach_check_at"] is None @pytest.mark.asyncio diff --git a/tests/test_litellm/proxy/management_endpoints/test_password_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_password_endpoints.py index 4c6de608003..bd154ebab41 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_password_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_password_endpoints.py @@ -74,6 +74,10 @@ async def test_change_password_success_writes_new_scrypt_hash(): stored = update_kwargs["data"]["password"] assert stored != NEW_PASSWORD assert verify_password(NEW_PASSWORD, stored) + # A successful change lifts any pending forced reset and re-arms the + # login-time breach screen for the new password. + assert update_kwargs["data"]["password_reset_required"] is False + assert update_kwargs["data"]["last_breach_check_at"] is None @pytest.mark.asyncio diff --git a/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.integration.test.tsx index e78f170cf0c..c4cf8ebcf9d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.integration.test.tsx @@ -1,16 +1,19 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import ChangePasswordForm from "./ChangePasswordForm"; const mockChangePasswordCall = vi.fn(); const mockToastSuccess = vi.fn(); +const mockClearTokenCookies = vi.fn(); +let mockPasswordResetRequired = false; vi.mock("@/components/networking", () => ({ changePasswordCall: (...args: unknown[]) => mockChangePasswordCall(...args), + getProxyBaseUrl: () => "", })); vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ - default: () => ({ accessToken: "sk-session-token" }), + default: () => ({ accessToken: "sk-session-token", passwordResetRequired: mockPasswordResetRequired }), })); vi.mock("@/lib/toast", () => ({ @@ -20,6 +23,10 @@ vi.mock("@/lib/toast", () => ({ }, })); +vi.mock("@/utils/cookieUtils", () => ({ + clearTokenCookies: (...args: unknown[]) => mockClearTokenCookies(...args), +})); + const fillForm = (values: { current: string; next: string; confirm: string }) => { fireEvent.change(screen.getByLabelText("Current Password"), { target: { value: values.current } }); fireEvent.change(screen.getByLabelText("New Password"), { target: { value: values.next } }); @@ -31,6 +38,7 @@ const submit = () => fireEvent.click(screen.getByRole("button", { name: "Change describe("ChangePasswordForm", () => { beforeEach(() => { vi.clearAllMocks(); + mockPasswordResetRequired = false; }); it("sends the current and new password to the change endpoint and resets on success", async () => { @@ -65,4 +73,38 @@ describe("ChangePasswordForm", () => { expect(await screen.findByText("Current password is incorrect.")).toBeInTheDocument(); expect(mockToastSuccess).not.toHaveBeenCalled(); }); + + describe("forced password reset", () => { + it("shows the forced-reset warning only when the session is flagged", () => { + mockPasswordResetRequired = true; + render(); + + expect(screen.getByText(/must be changed before you can use the dashboard/)).toBeInTheDocument(); + }); + + it("hides the forced-reset warning for a normal session", () => { + render(); + + expect(screen.queryByText(/must be changed before you can use the dashboard/)).not.toBeInTheDocument(); + }); + + it("signs the user out to re-login after a successful forced change", async () => { + mockPasswordResetRequired = true; + mockChangePasswordCall.mockResolvedValue({ user_id: "user-123", message: "Password updated successfully." }); + const replaceMock = vi.fn(); + const realLocation = window.location; + Object.defineProperty(window, "location", { configurable: true, value: { replace: replaceMock } }); + + try { + render(); + fillForm({ current: "OldP@ssw0rd-2026", next: "NewP@ssw0rd-2026", confirm: "NewP@ssw0rd-2026" }); + submit(); + + await waitFor(() => expect(replaceMock).toHaveBeenCalledWith("/ui/login/")); + expect(mockClearTokenCookies).toHaveBeenCalled(); + } finally { + Object.defineProperty(window, "location", { configurable: true, value: realLocation }); + } + }); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.tsx index 4c51b7f3d15..b29f2a26389 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.tsx @@ -11,10 +11,12 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { FieldGroup } from "@/components/ui/field"; import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; -import { changePasswordCall } from "@/components/networking"; +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 { getLoginUrl } from "@/utils/returnUrlUtils"; const changePasswordSchema = z .object({ @@ -30,7 +32,7 @@ const changePasswordSchema = z type ChangePasswordValues = z.infer; export function ChangePasswordForm() { - const { accessToken } = useAuthorized(); + const { accessToken, passwordResetRequired } = useAuthorized(); const form = useZodForm(changePasswordSchema, { defaultValues: { currentPassword: "", newPassword: "", confirmNewPassword: "" }, }); @@ -43,6 +45,13 @@ export function ChangePasswordForm() { setIsPending(true); try { await changePasswordCall(accessToken, values.currentPassword, values.newPassword); + if (passwordResetRequired) { + // The session key was minted restricted; only a fresh login lifts it. + toast.success("Password updated. Please log in with your new password."); + clearTokenCookies(); + window.location.replace(getLoginUrl(getProxyBaseUrl())); + return; + } toast.success("Password updated"); form.reset(); } catch (error) { @@ -62,6 +71,16 @@ export function ChangePasswordForm() { policy.

+ {passwordResetRequired && ( + + + + Your password must be changed before you can use the dashboard. After updating it, you will be signed + out to log in again. + + + )} +
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts index 089153cec76..581ee8b2580 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts @@ -52,6 +52,7 @@ const useAuthorized = () => { disabledPersonalKeyCreation: decoded?.disabled_non_admin_personal_key_creation ?? null, loginMethod: decoded?.login_method ?? null, showSSOBanner: decoded?.login_method === "username_password", + passwordResetRequired: decoded?.password_reset_required === true, }; }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx index 3fe34610260..c930652cca0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import { AuthProvider } from "@/contexts/AuthContext"; import Layout from "./layout"; @@ -117,4 +117,59 @@ describe("(dashboard) Layout", () => { expect(screen.queryByTestId("dashboard-header")).not.toBeInTheDocument(); expect(screen.queryByTestId("sidebar")).not.toBeInTheDocument(); }); + + describe("forced password reset routing", () => { + const sessionCookie = (claims: Record) => { + const encode = (part: Record) => + btoa(JSON.stringify(part)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + const exp = Math.floor(Date.now() / 1000) + 3600; + return `${encode({ alg: "HS256", typ: "JWT" })}.${encode({ ...claims, exp })}.sig`; + }; + + afterEach(() => { + document.cookie = "token=; Max-Age=0; Path=/"; + }); + + it("routes a session flagged password_reset_required to the change-password page", async () => { + document.cookie = `token=${sessionCookie({ + user_id: "flagged-user", + key: "sk-session", + login_method: "username_password", + password_reset_required: true, + })}; Path=/`; + + render( + + +
+ + , + ); + + pendingUiConfig.resolve(); + + await waitFor(() => expect(replaceMock).toHaveBeenCalledWith(expect.stringContaining("/change-password"))); + }); + + it("does not reroute an unflagged session", async () => { + document.cookie = `token=${sessionCookie({ + user_id: "normal-user", + key: "sk-session", + login_method: "username_password", + })}; Path=/`; + + render( + + +
+ + , + ); + + pendingUiConfig.resolve(); + + expect(await screen.findByTestId("page-content")).toBeInTheDocument(); + expect(replaceMock).not.toHaveBeenCalled(); + }); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx index fa6df7f176a..e914aa5e346 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx @@ -146,7 +146,8 @@ function DashboardShell({ children }: { children: React.ReactNode }) { function LayoutContent({ children }: { children: React.ReactNode }) { const router = useRouter(); const searchParams = useSearchParams(); - const { accessToken, authLoading } = useAuth(); + const pathname = usePathname(); + const { accessToken, authLoading, passwordResetRequired } = useAuth(); const isInvitationFlow = Boolean(searchParams.get("invitation_id")); // Legacy invitation links point at /ui/?invitation_id=; the onboarding form now lives at its own @@ -157,6 +158,14 @@ function LayoutContent({ children }: { children: React.ReactNode }) { } }, [authLoading, isInvitationFlow, router, searchParams]); + // A session flagged for a forced password reset can only reach the change-password + // endpoint server-side; keep the UI on the matching page. + useEffect(() => { + if (!authLoading && passwordResetRequired && !pathname?.endsWith("/change-password")) { + router.replace(migratedHref("change-password")); + } + }, [authLoading, passwordResetRequired, pathname, router]); + if (authLoading || isInvitationFlow) { return ; } diff --git a/ui/litellm-dashboard/src/contexts/AuthContext.tsx b/ui/litellm-dashboard/src/contexts/AuthContext.tsx index 123feb18a6c..c68c8b9d81a 100644 --- a/ui/litellm-dashboard/src/contexts/AuthContext.tsx +++ b/ui/litellm-dashboard/src/contexts/AuthContext.tsx @@ -24,6 +24,7 @@ type AuthContextValue = { premiumUser: boolean; disabledPersonalKeyCreation: boolean; showSSOBanner: boolean; + passwordResetRequired: boolean; setToken: React.Dispatch>; setUserID: React.Dispatch>; @@ -46,6 +47,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const [premiumUser, setPremiumUser] = useState(false); const [disabledPersonalKeyCreation, setDisabledPersonalKeyCreation] = useState(false); const [showSSOBanner, setShowSSOBanner] = useState(true); + const [passwordResetRequired, setPasswordResetRequired] = useState(false); // Load runtime UI config (populates proxyBaseUrl etc.) before clearing // authLoading, so any consumer that builds proxy-rooted URLs from authLoading=false @@ -124,6 +126,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (decoded.user_id) { setUserID(decoded.user_id); } + setPasswordResetRequired(decoded.password_reset_required === true); }, [token]); const value: AuthContextValue = { @@ -136,6 +139,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { premiumUser, disabledPersonalKeyCreation, showSSOBanner, + passwordResetRequired, setToken, setUserID, setUserRole, diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 8b29f518efa..fbd100fde7b 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -16787,7 +16787,8 @@ export interface paths { * Requires the current password. The new password must satisfy the * configured password policy (`general_settings.password_policy_*`: minimum * length, character classes, and, when enabled, breached-password screening - * via haveibeenpwned.com). + * via haveibeenpwned.com). A successful change lifts any pending forced + * password reset (`password_reset_required`) on the account. * * Parameters: * - current_password: str - The user's current password. @@ -16847,7 +16848,7 @@ export interface paths { * Parameters: * - user_id: Optional[str] - Specify a user id. If not set, a unique id will be generated. * - user_email: Optional[str] - Specify a user email. - * - password: Optional[str] - Set the user's password (admin only). Must satisfy the configured password policy. Users change their own password with POST /user/password/change. + * - password: Optional[str] - Set the user's password (admin only). Must satisfy the configured password policy. The user is required to change it at their next login. Users change their own password with POST /user/password/change. * - user_alias: Optional[str] - A descriptive name for you to know who this user id refers to. * - teams: Optional[list] - specify a list of team id's a user belongs to. * - send_invite_email: Optional[bool] - Specify if an invite email should be sent. @@ -30241,6 +30242,8 @@ export interface components { budget_reset_at?: string | null; /** Created At */ created_at?: string | null; + /** Last Breach Check At */ + last_breach_check_at?: string | null; /** Max Budget */ max_budget?: number | null; /** Max Parallel Requests */ @@ -30275,6 +30278,8 @@ export interface components { organization_id?: string | null; /** Organization Memberships */ organization_memberships?: components["schemas"]["LiteLLM_OrganizationMembershipTable"][] | null; + /** Password Reset Required */ + password_reset_required?: boolean | null; /** * Policies * @default [] @@ -30334,6 +30339,8 @@ export interface components { * @default 0 */ key_count: number; + /** Last Breach Check At */ + last_breach_check_at?: string | null; /** Max Budget */ max_budget?: number | null; /** Max Parallel Requests */ @@ -30368,6 +30375,8 @@ export interface components { organization_id?: string | null; /** Organization Memberships */ organization_memberships?: components["schemas"]["LiteLLM_OrganizationMembershipTable"][] | null; + /** Password Reset Required */ + password_reset_required?: boolean | null; /** * Policies * @default [] From 7e8683aef05d6e959a88504a7fdcd7bc4188885b Mon Sep 17 00:00:00 2001 From: Oliver Jensen Date: Wed, 9 Sep 2026 10:56:49 +0200 Subject: [PATCH 2/6] feat(auth): screen the login password inline and restrict the session on a fresh breach hit A breach found during a login previously only flagged the account for the NEXT login, handing out one free unrestricted 24h session. The HIBP screen is now awaited before the session key is minted (worst case one 5s window per user per 24h, fail-open unchanged), so a fresh hit restricts the current session and the dashboard routes straight to change-password. Also repairs two casualties of merge f5e47974db that the layout tests caught: the lost usePathname import and a call to migratedHref, which staging renamed to uiHref. --- litellm/proxy/auth/login_utils.py | 39 ++++----- .../proxy/auth/test_login_utils.py | 83 +++++++++++++------ .../change-password/ChangePasswordForm.tsx | 5 +- .../src/app/(dashboard)/layout.tsx | 4 +- 4 files changed, 79 insertions(+), 52 deletions(-) diff --git a/litellm/proxy/auth/login_utils.py b/litellm/proxy/auth/login_utils.py index a5809ce0899..75573561266 100644 --- a/litellm/proxy/auth/login_utils.py +++ b/litellm/proxy/auth/login_utils.py @@ -5,7 +5,6 @@ This module contains the core login logic that can be reused across different login endpoints (e.g., /login and /v2/login). """ -import asyncio import os import secrets from collections.abc import Mapping @@ -70,16 +69,16 @@ async def screen_login_password_for_breach( general_settings: Mapping[str, object], prisma_client: PrismaClient, client: AsyncHTTPHandler | None = None, -) -> None: - """Background task behind a successful password login: screens the password - against HIBP and stamps ``password_reset_required`` when breached, so the - NEXT login is restricted to the change-password flow. Never blocks or fails - the login it runs behind, and rechecks a given user at most once per - ``BREACH_RECHECK_INTERVAL``.""" +) -> bool: + """Screens a successfully verified login password against HIBP, stamps + ``password_reset_required`` when breached, and returns whether a breach was + found so the login it runs in can restrict the session it is about to mint. + Fails open (HIBP or DB trouble never fails the login) and rechecks a given + user at most once per ``BREACH_RECHECK_INTERVAL``.""" if not is_breach_check_enabled(general_settings): - return + return False if not _breach_recheck_due(last_breach_check_at): - return + return False breached: Final = await is_password_breached(password, general_settings, client) update_data: Final = { "last_breach_check_at": datetime.now(timezone.utc), @@ -87,8 +86,9 @@ async def screen_login_password_for_breach( } try: await UserRepository(prisma_client).table.update(where={"user_id": user_id}, data=update_data) - except Exception as e: # noqa: BLE001 # fire-and-forget: a failed stamp must never surface into the login + except Exception as e: # noqa: BLE001 # a failed stamp must never surface into the login verbose_proxy_logger.warning("Login-time breach screening could not update user %s: %s", user_id, e) + return breached async def _rehash_password_if_needed(user_id: str, password: str, stored: str) -> None: @@ -371,17 +371,14 @@ async def authenticate_user( if verify_password(password, _password): await _rehash_password_if_needed(_user_row.user_id, password, _password) - if prisma_client is not None: - asyncio.create_task( - screen_login_password_for_breach( - user_id=_user_row.user_id, - password=password, - last_breach_check_at=getattr(_user_row, "last_breach_check_at", None), - general_settings=general_settings, - prisma_client=prisma_client, - ) - ) - password_reset_required: Final = getattr(_user_row, "password_reset_required", None) is True + breached_now: Final = prisma_client is not None and await screen_login_password_for_breach( + user_id=_user_row.user_id, + password=password, + last_breach_check_at=getattr(_user_row, "last_breach_check_at", None), + general_settings=general_settings, + prisma_client=prisma_client, + ) + password_reset_required: Final = breached_now or getattr(_user_row, "password_reset_required", None) is True if os.getenv("DATABASE_URL") is not None: response = await generate_key_helper_fn( request_type="key", diff --git a/tests/test_litellm/proxy/auth/test_login_utils.py b/tests/test_litellm/proxy/auth/test_login_utils.py index 16547db527c..1afb459a74f 100644 --- a/tests/test_litellm/proxy/auth/test_login_utils.py +++ b/tests/test_litellm/proxy/auth/test_login_utils.py @@ -1036,38 +1036,57 @@ class TestPasswordResetRequiredSessionMinting: assert "metadata" not in key_kwargs assert result.password_reset_required is False - @pytest.mark.asyncio - async def test_login_schedules_breach_screen_with_row_state(self): - """The login must hand the background screen the row's recheck timestamp, - or the 24h throttle can never work.""" - checked_at = datetime.now(timezone.utc) - timedelta(hours=1) - row = _db_user_row(password="Str0ng!Passw0rd", last_breach_check_at=checked_at) - mock_prisma_client = _prisma_with_user(row) - + async def _login_with_screen_result(self, mock_prisma_client, breached: bool) -> tuple[LoginResult, dict, dict]: with patch.dict(os.environ, _DB_LOGIN_ENV): - with patch( + with patch( # test-quality-ok: asserting the minted key's restriction requires seeing its kwargs "litellm.proxy.auth.login_utils.generate_key_helper_fn", new_callable=AsyncMock, return_value={"token": "session-token"}, - ): - with patch( - "litellm.proxy.auth.login_utils.screen_login_password_for_breach", - new_callable=AsyncMock, - ) as mock_screen: - await authenticate_user( + ) as mock_generate_key: + with ( + patch( # test-quality-ok: authenticate_user has no HIBP client seam; the screen itself is tested against MockTransport below + "litellm.proxy.auth.login_utils.screen_login_password_for_breach", + new_callable=AsyncMock, + return_value=breached, + ) as mock_screen + ): + result = await authenticate_user( username="reset@example.com", password="Str0ng!Passw0rd", master_key="sk-1234", prisma_client=mock_prisma_client, general_settings=_POLICY_NO_BREACH_CHECK, ) + return result, mock_generate_key.call_args.kwargs, mock_screen.call_args.kwargs + + @pytest.mark.asyncio + async def test_login_screens_with_row_state_before_minting(self): + """The login must hand the screen the row's recheck timestamp, or the + 24h throttle can never work.""" + checked_at = datetime.now(timezone.utc) - timedelta(hours=1) + row = _db_user_row(password="Str0ng!Passw0rd", last_breach_check_at=checked_at) + mock_prisma_client = _prisma_with_user(row) + + _, _, screen_kwargs = await self._login_with_screen_result(mock_prisma_client, breached=False) - screen_kwargs = mock_screen.call_args.kwargs assert screen_kwargs["user_id"] == "reset-user-1" assert screen_kwargs["password"] == "Str0ng!Passw0rd" assert screen_kwargs["last_breach_check_at"] == checked_at assert screen_kwargs["prisma_client"] is mock_prisma_client + @pytest.mark.asyncio + async def test_fresh_breach_hit_restricts_the_current_session(self): + """A breach found during THIS login must restrict THIS session, not + just the next one.""" + row = _db_user_row(password="Str0ng!Passw0rd", password_reset_required=None) + mock_prisma_client = _prisma_with_user(row) + + 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["metadata"] == {"password_reset_required": True} + assert result.password_reset_required is True + def _sha1_upper(password: str) -> str: return hashlib.sha1(password.encode("utf-8"), usedforsecurity=False).hexdigest().upper() @@ -1096,16 +1115,17 @@ def _client_never_called() -> AsyncHTTPHandler: class TestScreenLoginPasswordForBreach: - """The fire-and-forget login-time screen: flags a breached password for a - forced reset, stamps the recheck timestamp, rechecks at most every 24h, - and never raises into the login it runs behind.""" + """The awaited login-time screen: flags a breached password for a forced + reset, stamps the recheck timestamp, rechecks at most every 24h, returns + the breach verdict so the login can restrict the session it is minting, + and never raises into the login.""" @pytest.mark.asyncio async def test_breached_password_sets_reset_flag_and_timestamp(self): password = "Password123!" mock_prisma_client = _prisma_with_user(None) - await screen_login_password_for_breach( + breached = await screen_login_password_for_breach( user_id="reset-user-1", password=password, last_breach_check_at=None, @@ -1114,6 +1134,7 @@ class TestScreenLoginPasswordForBreach: client=_client_returning_breach_hit(password), ) + assert breached is True update_kwargs = mock_prisma_client.db.litellm_usertable.update.call_args.kwargs assert update_kwargs["where"] == {"user_id": "reset-user-1"} assert update_kwargs["data"]["password_reset_required"] is True @@ -1123,7 +1144,7 @@ class TestScreenLoginPasswordForBreach: async def test_clean_password_stamps_timestamp_without_flag(self): mock_prisma_client = _prisma_with_user(None) - await screen_login_password_for_breach( + breached = await screen_login_password_for_breach( user_id="reset-user-1", password="Str0ng!Passw0rd", last_breach_check_at=None, @@ -1132,6 +1153,7 @@ class TestScreenLoginPasswordForBreach: client=_client_returning_no_hit(), ) + assert breached is False update_kwargs = mock_prisma_client.db.litellm_usertable.update.call_args.kwargs assert "password_reset_required" not in update_kwargs["data"] assert isinstance(update_kwargs["data"]["last_breach_check_at"], datetime) @@ -1140,7 +1162,7 @@ class TestScreenLoginPasswordForBreach: async def test_skips_hibp_when_checked_within_24_hours(self): mock_prisma_client = _prisma_with_user(None) - await screen_login_password_for_breach( + breached = await screen_login_password_for_breach( user_id="reset-user-1", password="Password123!", last_breach_check_at=datetime.now(timezone.utc) - timedelta(hours=23), @@ -1149,6 +1171,7 @@ class TestScreenLoginPasswordForBreach: client=_client_never_called(), ) + assert breached is False mock_prisma_client.db.litellm_usertable.update.assert_not_called() @pytest.mark.asyncio @@ -1156,7 +1179,7 @@ class TestScreenLoginPasswordForBreach: password = "Password123!" mock_prisma_client = _prisma_with_user(None) - await screen_login_password_for_breach( + breached = await screen_login_password_for_breach( user_id="reset-user-1", password=password, last_breach_check_at=datetime.now(timezone.utc) - timedelta(hours=25), @@ -1165,13 +1188,16 @@ class TestScreenLoginPasswordForBreach: client=_client_returning_breach_hit(password), ) - assert mock_prisma_client.db.litellm_usertable.update.call_args.kwargs["data"]["password_reset_required"] is True + assert breached is True + assert ( + mock_prisma_client.db.litellm_usertable.update.call_args.kwargs["data"]["password_reset_required"] is True + ) @pytest.mark.asyncio async def test_skips_hibp_when_check_disabled(self): mock_prisma_client = _prisma_with_user(None) - await screen_login_password_for_breach( + breached = await screen_login_password_for_breach( user_id="reset-user-1", password="Password123!", last_breach_check_at=None, @@ -1180,10 +1206,13 @@ class TestScreenLoginPasswordForBreach: client=_client_never_called(), ) + assert breached is False mock_prisma_client.db.litellm_usertable.update.assert_not_called() @pytest.mark.asyncio - async def test_db_failure_never_raises_into_the_login(self): + async def test_db_failure_never_raises_but_still_reports_the_breach(self): + """A failed flag write must not fail the login, but the breach verdict + still has to restrict the session being minted right now.""" password = "Password123!" mock_prisma_client = _prisma_with_user(None) mock_prisma_client.db.litellm_usertable.update = AsyncMock(side_effect=RuntimeError("db down")) @@ -1197,5 +1226,5 @@ class TestScreenLoginPasswordForBreach: prisma_client=mock_prisma_client, client=_client_returning_breach_hit(password), ) - is None + is True ) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.tsx index b29f2a26389..05a6bf3ae94 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.tsx @@ -75,8 +75,9 @@ export function ChangePasswordForm() { - Your password must be changed before you can use the dashboard. After updating it, you will be signed - out to log in again. + Your password must be changed before you can use the dashboard: it was either found in a known data + breach or set by an administrator as a temporary password. After updating it, you will be signed out to + log in again. )} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx index e914aa5e346..309d7dd1ac2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx @@ -7,7 +7,7 @@ import LoadingScreen from "@/components/common_components/LoadingScreen"; import { ThemeProvider } from "@/contexts/ThemeContext"; import { useAuth } from "@/contexts/AuthContext"; import SidebarProvider from "@/app/(dashboard)/components/SidebarProvider"; -import { useRouter, useSearchParams } from "next/navigation"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { DebugWarningBanner } from "@/components/DebugWarningBanner"; import { NoRedisWarningBanner } from "@/components/NoRedisWarningBanner"; import { EnvCredentialLoginWarningBanner } from "@/components/EnvCredentialLoginWarningBanner"; @@ -162,7 +162,7 @@ function LayoutContent({ children }: { children: React.ReactNode }) { // endpoint server-side; keep the UI on the matching page. useEffect(() => { if (!authLoading && passwordResetRequired && !pathname?.endsWith("/change-password")) { - router.replace(migratedHref("change-password")); + router.replace(uiHref("change-password")); } }, [authLoading, passwordResetRequired, pathname, router]); From c6f724b56f956817e368a1e2872aedc866eace91 Mon Sep 17 00:00:00 2001 From: Oliver Jensen Date: Wed, 9 Sep 2026 14:50:14 +0200 Subject: [PATCH 3/6] refactor(auth): type the breach-screen DB dicts and flatten the session-key kwargs Annotate screen_login_password_for_breach's update/where dicts with prisma input TypedDicts and replace authenticate_user's conditional dict splat with plain keyword arguments, clearing the LIT002 lines this branch added in login_utils.py. No behavior change: an unflagged login now passes allowed_routes=None and metadata={} explicitly, which are the parameter defaults --- litellm/proxy/auth/login_utils.py | 45 +++++++++---------- .../proxy/auth/test_login_utils.py | 4 +- 2 files changed, 22 insertions(+), 27 deletions(-) diff --git a/litellm/proxy/auth/login_utils.py b/litellm/proxy/auth/login_utils.py index 75573561266..2bf08414e98 100644 --- a/litellm/proxy/auth/login_utils.py +++ b/litellm/proxy/auth/login_utils.py @@ -10,7 +10,7 @@ import secrets from collections.abc import Mapping from datetime import datetime, timedelta, timezone from types import MappingProxyType -from typing import Final, Literal, cast +from typing import TYPE_CHECKING, Final, Literal, cast import jwt from fastapi import HTTPException @@ -47,6 +47,9 @@ from litellm.repositories.user_repository import UserRepository from litellm.secret_managers.main import get_secret_bool from litellm.types.proxy.ui_sso import ReturnedUITokenObject +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",) @@ -80,12 +83,15 @@ async def screen_login_password_for_breach( if not _breach_recheck_due(last_breach_check_at): return False breached: Final = await is_password_breached(password, general_settings, client) - update_data: Final = { - "last_breach_check_at": datetime.now(timezone.utc), - **({"password_reset_required": True} if breached else {}), - } + checked_at: Final = datetime.now(timezone.utc) + update_data: Final[prisma_types.LiteLLM_UserTableUpdateInput] = ( + {"last_breach_check_at": checked_at, "password_reset_required": True} + if breached + else {"last_breach_check_at": checked_at} + ) + find_user: Final[prisma_types.LiteLLM_UserTableWhereInput] = {"user_id": user_id} try: - await UserRepository(prisma_client).table.update(where={"user_id": user_id}, data=update_data) + await UserRepository(prisma_client).table.update(where=find_user, data=update_data) except Exception as e: # noqa: BLE001 # a failed stamp must never surface into the login verbose_proxy_logger.warning("Login-time breach screening could not update user %s: %s", user_id, e) return breached @@ -382,25 +388,14 @@ async def authenticate_user( if os.getenv("DATABASE_URL") is not None: response = await generate_key_helper_fn( request_type="key", - **{ - "user_role": user_role, - "duration": LITELLM_UI_SESSION_DURATION, - "key_max_budget": litellm.max_ui_session_budget, - "models": [], - "aliases": {}, - "config": {}, - "spend": 0, - "user_id": user_id, - "team_id": "litellm-dashboard", - **( - { - "allowed_routes": list(PASSWORD_RESET_ALLOWED_ROUTES), - "metadata": {"password_reset_required": True}, - } - if password_reset_required - else {} - ), - }, + user_role=user_role, + duration=LITELLM_UI_SESSION_DURATION, + key_max_budget=litellm.max_ui_session_budget, + spend=0, + user_id=user_id, + team_id="litellm-dashboard", + allowed_routes=list(PASSWORD_RESET_ALLOWED_ROUTES) if password_reset_required else None, + metadata={"password_reset_required": True} if password_reset_required else {}, ) else: raise ProxyException( diff --git a/tests/test_litellm/proxy/auth/test_login_utils.py b/tests/test_litellm/proxy/auth/test_login_utils.py index 1afb459a74f..0505c41707c 100644 --- a/tests/test_litellm/proxy/auth/test_login_utils.py +++ b/tests/test_litellm/proxy/auth/test_login_utils.py @@ -1032,8 +1032,8 @@ class TestPasswordResetRequiredSessionMinting: row = _db_user_row(password="Str0ng!Passw0rd", password_reset_required=None) result, key_kwargs = await self._login(_prisma_with_user(row)) - assert "allowed_routes" not in key_kwargs - assert "metadata" not in key_kwargs + assert key_kwargs["allowed_routes"] is None + assert not key_kwargs["metadata"] assert result.password_reset_required is False async def _login_with_screen_result(self, mock_prisma_client, breached: bool) -> tuple[LoginResult, dict, dict]: From ea99bd058586078eea62bf8411057da0a6b27dd2 Mon Sep 17 00:00:00 2001 From: Oliver Jensen Date: Wed, 9 Sep 2026 15:13:26 +0200 Subject: [PATCH 4/6] fix(lint): clear the one-over LIT002 and inline-object budget hits --- litellm/proxy/auth/login_utils.py | 11 ++++++----- .../src/app/(dashboard)/layout.test.tsx | 5 +++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/litellm/proxy/auth/login_utils.py b/litellm/proxy/auth/login_utils.py index 2bf08414e98..16b4ef7380c 100644 --- a/litellm/proxy/auth/login_utils.py +++ b/litellm/proxy/auth/login_utils.py @@ -84,11 +84,12 @@ async def screen_login_password_for_breach( return False breached: Final = await is_password_breached(password, general_settings, client) checked_at: Final = datetime.now(timezone.utc) - update_data: Final[prisma_types.LiteLLM_UserTableUpdateInput] = ( - {"last_breach_check_at": checked_at, "password_reset_required": True} - if breached - else {"last_breach_check_at": checked_at} - ) + breached_update: Final[prisma_types.LiteLLM_UserTableUpdateInput] = { + "last_breach_check_at": checked_at, + "password_reset_required": True, + } + recheck_update: Final[prisma_types.LiteLLM_UserTableUpdateInput] = {"last_breach_check_at": checked_at} + update_data: Final = breached_update if breached else recheck_update find_user: Final[prisma_types.LiteLLM_UserTableWhereInput] = {"user_id": user_id} try: await UserRepository(prisma_client).table.update(where=find_user, data=update_data) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx index c930652cca0..ae6575e2349 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx @@ -131,12 +131,13 @@ describe("(dashboard) Layout", () => { }); it("routes a session flagged password_reset_required to the change-password page", async () => { - document.cookie = `token=${sessionCookie({ + const flaggedClaims = { user_id: "flagged-user", key: "sk-session", login_method: "username_password", password_reset_required: true, - })}; Path=/`; + }; + document.cookie = `token=${sessionCookie(flaggedClaims)}; Path=/`; render( From 12e8a29e7bbf614b488b20e3b2200396c3909cd9 Mon Sep 17 00:00:00 2001 From: Oliver Jensen Date: Wed, 9 Sep 2026 15:22:00 +0200 Subject: [PATCH 5/6] test(auth): annotate the session-minting patch for the test-quality gate --- tests/test_litellm/proxy/auth/test_login_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_litellm/proxy/auth/test_login_utils.py b/tests/test_litellm/proxy/auth/test_login_utils.py index 0505c41707c..3baaa258879 100644 --- a/tests/test_litellm/proxy/auth/test_login_utils.py +++ b/tests/test_litellm/proxy/auth/test_login_utils.py @@ -1004,7 +1004,7 @@ class TestPasswordResetRequiredSessionMinting: async def _login(self, mock_prisma_client) -> tuple[LoginResult, dict]: with patch.dict(os.environ, _DB_LOGIN_ENV): - with patch( + with patch( # test-quality-ok: asserting the minted key's restriction requires seeing its kwargs "litellm.proxy.auth.login_utils.generate_key_helper_fn", new_callable=AsyncMock, return_value={"token": "session-token"}, From 522b6fe4fec854b2c74e8dce10c4f9ad67ccea12 Mon Sep 17 00:00:00 2001 From: Oliver Jensen Date: Wed, 9 Sep 2026 15:39:14 +0200 Subject: [PATCH 6/6] test(models): stop the password serialization test matching field-name substrings --- tests/test_litellm/models/test_models.py | 46 ++++++++---------------- 1 file changed, 14 insertions(+), 32 deletions(-) diff --git a/tests/test_litellm/models/test_models.py b/tests/test_litellm/models/test_models.py index aa6449c98dd..02d3a519586 100644 --- a/tests/test_litellm/models/test_models.py +++ b/tests/test_litellm/models/test_models.py @@ -93,9 +93,7 @@ class TestCredentials: assert item.credential_values is None def test_create_credential_item_requires_values_or_model_id(self): - with pytest.raises( - ValueError, match="Either credential_values or model_id must be set" - ): + with pytest.raises(ValueError, match="Either credential_values or model_id must be set"): CreateCredentialItem(credential_name="bad", credential_info={}) @@ -113,12 +111,8 @@ class TestModel: assert model.team_public_model_name == "my-gpt4" def test_is_blocked(self): - model_blocked = LiteLLM_ProxyModelTable( - model_id="m1", model_name="test", litellm_params={}, blocked=True - ) - model_unblocked = LiteLLM_ProxyModelTable( - model_id="m2", model_name="test", litellm_params={}, blocked=False - ) + model_blocked = LiteLLM_ProxyModelTable(model_id="m1", model_name="test", litellm_params={}, blocked=True) + model_unblocked = LiteLLM_ProxyModelTable(model_id="m2", model_name="test", litellm_params={}, blocked=False) assert model_blocked.is_blocked assert not model_unblocked.is_blocked @@ -160,9 +154,7 @@ class TestModel: assert model.blocked is True def test_team_helpers_none_when_no_model_info(self): - model = LiteLLM_ProxyModelTable( - model_id="m1", model_name="gpt-4", litellm_params={}, model_info=None - ) + model = LiteLLM_ProxyModelTable(model_id="m1", model_name="gpt-4", litellm_params={}, model_info=None) assert model.team_id is None assert model.team_public_model_name is None @@ -264,9 +256,7 @@ class TestTeam: assert team.model_max_budget == {"gpt-4": 5.0} def test_cached_team(self): - cached = LiteLLM_TeamTableCachedObj( - team_id="t1", last_refreshed_at=1234567890.0 - ) + cached = LiteLLM_TeamTableCachedObj(team_id="t1", last_refreshed_at=1234567890.0) assert cached.last_refreshed_at == 1234567890.0 def test_deleted_team(self): @@ -308,6 +298,8 @@ class TestUser: assert user_no_models.has_model_access("any-model") def test_password_hash_excluded_from_serialization(self): + import json + from litellm.proxy._types import LiteLLM_UserTableWithKeyCount secret = "$2b$12$abcdefghijklmnopqrstuv" @@ -315,14 +307,12 @@ class TestUser: assert user.password == secret assert "password" not in user.model_dump() - assert "password" not in user.model_dump_json() + assert "password" not in json.loads(user.model_dump_json()) - with_keys = LiteLLM_UserTableWithKeyCount( - user_id="u1", user_email="a@b.c", password=secret, key_count=2 - ) + with_keys = LiteLLM_UserTableWithKeyCount(user_id="u1", user_email="a@b.c", password=secret, key_count=2) assert with_keys.password == secret assert "password" not in with_keys.model_dump() - assert "password" not in with_keys.model_dump_json() + assert "password" not in json.loads(with_keys.model_dump_json()) class TestVerificationToken: @@ -443,9 +433,7 @@ class TestEndUserTable: class TestBudgetTableFull: def test_full_adds_server_managed_fields(self): now = datetime.now() - budget = LiteLLM_BudgetTableFull( - budget_id="b1", max_budget=10.0, created_at=now, budget_reset_at=now - ) + budget = LiteLLM_BudgetTableFull(budget_id="b1", max_budget=10.0, created_at=now, budget_reset_at=now) assert budget.created_at == now assert budget.budget_reset_at == now assert budget.max_budget == 10.0 @@ -457,9 +445,7 @@ class TestBudgetTableFull: class TestTeamMemberTable: def test_tracks_user_within_team(self): - member = LiteLLM_TeamMemberTable( - user_id="u1", team_id="t1", spend=3.0, budget_id="b1", max_budget=5.0 - ) + member = LiteLLM_TeamMemberTable(user_id="u1", team_id="t1", spend=3.0, budget_id="b1", max_budget=5.0) assert member.user_id == "u1" assert member.team_id == "t1" assert member.spend == 3.0 @@ -549,9 +535,7 @@ class TestSpendLogs: assert log.updated_at == updated_at def test_error_logs_creation(self): - log = LiteLLM_ErrorLogs( - request_id="r1", startTime=None, endTime=None, status_code="500" - ) + log = LiteLLM_ErrorLogs(request_id="r1", startTime=None, endTime=None, status_code="500") assert log.request_id == "r1" assert log.status_code == "500" @@ -569,9 +553,7 @@ class TestManagedTables: def test_managed_object_table_requires_purpose(self): with pytest.raises(ValidationError): - LiteLLM_ManagedObjectTable( - unified_object_id="o1", model_object_id="m1", file_object={} - ) + LiteLLM_ManagedObjectTable(unified_object_id="o1", model_object_id="m1", file_object={}) def test_managed_vector_stores_table(self): table = LiteLLM_ManagedVectorStoresTable(