mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-05 02:41:56 +00:00
Some checks are pending
Unit Tests / misc (push) Waiting to run
CI Coverage / assert-ci-coverage (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
CodSpeed Benchmarks / benchmarks (push) Waiting to run
Helm unit test / unit-test (push) Waiting to run
Publish basedpyright base counts / publish (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
Code Quality Checks / code-quality (push) Waiting to run
Code Quality Checks / python-310-import-smoke (push) Waiting to run
UI Unit Tests / ui-unit-tests (push) Waiting to run
Postgres Tests / proxy-security (push) Waiting to run
Postgres Tests / schema-migration (push) Waiting to run
Postgres Tests / proxy-behavior (push) Waiting to run
LiteLLM Rust / rust-lint (push) Waiting to run
LiteLLM Rust / rust-test (push) Waiting to run
LiteLLM Rust / rust-wheel (push) Waiting to run
Unit Tests: Documentation Validation / documentation (push) Waiting to run
Unit Tests: Proxy DB Operations / assert-shard-coverage (push) Waiting to run
Unit Tests: Proxy DB Operations / auth-checks (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / budgets (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / custom-logging (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / db-and-spend (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / endpoints-and-responses (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / guardrails-hooks (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / jwt-and-keys (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / key-generation (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / logging-misc (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / proxy-runtime (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / proxy-server-core (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / proxy-utils (push) Blocked by required conditions
Unit Tests / caching-local (push) Waiting to run
Unit Tests / core-utils (push) Waiting to run
Unit Tests / enterprise-package (push) Waiting to run
Unit Tests / enterprise-routing (push) Waiting to run
Unit Tests / integrations (push) Waiting to run
Unit Tests / All Other Providers (push) Waiting to run
Unit Tests / Vertex AI (push) Waiting to run
Unit Tests / mcp-integration (push) Waiting to run
Unit Tests / proxy-auth (push) Waiting to run
Unit Tests / proxy-endpoints (push) Waiting to run
Unit Tests / proxy-extras (push) Waiting to run
Unit Tests / proxy-server (push) Waiting to run
Unit Tests / proxy-infra (push) Waiting to run
Unit Tests / responses-caching-types (push) Waiting to run
GitHub Actions Security Analysis / zizmor (push) Waiting to run
* refactor(auth): bind UI/CLI session tokens to their own AES-GCM context UI and CLI session tokens are now always encrypted with AES-256-GCM and a fixed session associated-data value, and the session-token check only accepts AES-GCM values carrying that same value. Stored secrets keep their current encryption and decrypt unchanged, so nothing needs migrating. encrypt_value_helper and decrypt_value_helper take an optional aad. XSalsa20 cannot bind associated data, so an AAD-bound value is always written as AES-256-GCM, and an AAD-bound decrypt refuses the legacy format. Session tokens issued before the upgrade stop validating, so UI and CLI users sign in once more after upgrading. * test(e2e): cover real SSO login through the dashboard and the lite CLI Adds two specs under tests/e2e/ui/oidc, run by playwright.oidc.config.ts against a live Keycloak stack. The dashboard spec checks that the SSO session authorizes the Virtual Keys and Models data requests. The CLI spec runs a real lite login in an isolated HOME with the keyring disabled, then lists models and sends one chat completion with the stored session. The main Playwright config now ignores oidc/. * fix(auth): encode UI/CLI session tokens as unpadded base64url Session tokens carried the v2:gcm: storage prefix and base64 padding. Basic-auth parsers split on the first colon and browsers reject ':' and '=' in WebSocket subprotocols, so Langfuse pass-through and the realtime playground could not use them Tokens are now plain unpadded base64url, the same header-safe shape as any bearer token * fix(auth): prefix UI/CLI session tokens with litellm_login_ A prefix-less token starts with sk- about once in 262,144 logins and is then routed as a virtual key, so that login gets a 401. The prefix also makes session tokens easy to spot in logs The prefix doubles as the token's AES-GCM associated data, so the visible kind and the encrypted kind cannot disagree --------- Co-authored-by: ryan-crabbe-berri <ryan@berri.ai>
91 lines
4 KiB
Python
91 lines
4 KiB
Python
"""Live e2e: UI/CLI session tokens are accepted only while valid and only when minted as session tokens.
|
|
|
|
The runner mints its own session tokens under the proxy's salt key, so the valid and expired cases run in
|
|
seconds instead of waiting out a real login's expiry.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Final
|
|
|
|
import pytest
|
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
from e2e_config import MASTER_KEY, unique_marker
|
|
from e2e_http import UnauthorizedError, unwrap
|
|
from lifecycle import ResourceManager
|
|
from models import KeyGenerateBody, KeyLoggingCallback, KeyLoggingCallbackVars, KeyMetadata
|
|
from other_client import OtherClient
|
|
|
|
pytestmark = pytest.mark.e2e
|
|
|
|
SALT_KEY: Final = os.environ.get("LITELLM_SALT_KEY") or MASTER_KEY
|
|
SESSION_TOKEN_PREFIX: Final = "litellm_login_"
|
|
ENCRYPTED_PREFIX: Final = "litellm_enc::"
|
|
|
|
|
|
def _admin_session_token(expires_at: datetime) -> str:
|
|
claims: Final = json.dumps(
|
|
{
|
|
"token": f"ui-token-{unique_marker()}",
|
|
"user_id": f"e2e-session-{unique_marker()}",
|
|
"user_role": "proxy_admin",
|
|
"team_id": "litellm-dashboard",
|
|
"expires": expires_at.isoformat(),
|
|
}
|
|
)
|
|
nonce: Final = os.urandom(12)
|
|
sealed: Final = AESGCM(hashlib.sha256(SALT_KEY.encode()).digest()).encrypt(
|
|
nonce, claims.encode(), SESSION_TOKEN_PREFIX.encode()
|
|
)
|
|
return SESSION_TOKEN_PREFIX + base64.urlsafe_b64encode(nonce + sealed).decode().rstrip("=")
|
|
|
|
|
|
class TestSessionToken:
|
|
@pytest.mark.covers("other.auth.session_token.valid_allows")
|
|
def test_unexpired_session_token_reaches_admin_route(self, client: OtherClient) -> None:
|
|
token: Final = _admin_session_token(datetime.now(timezone.utc) + timedelta(minutes=10))
|
|
listing: Final = unwrap(client.list_users_as(token))
|
|
assert listing.total >= 0, f"an unexpired admin session token did not reach /user/list: {listing}"
|
|
|
|
@pytest.mark.covers("other.auth.session_token.expired_denied")
|
|
def test_expired_session_token_is_denied(self, client: OtherClient) -> None:
|
|
token: Final = _admin_session_token(datetime.now(timezone.utc) - timedelta(minutes=1))
|
|
result: Final = client.list_users_as(token)
|
|
assert isinstance(result, UnauthorizedError), f"an expired session token must get 401, got {result}"
|
|
assert "expired" in result.body.lower(), f"expected the expired-key error, got {result.body[:300]}"
|
|
|
|
@pytest.mark.covers("other.auth.session_token.encrypted_value_denied")
|
|
def test_encrypted_stored_value_is_not_a_bearer_token(
|
|
self, client: OtherClient, resources: ResourceManager
|
|
) -> None:
|
|
stored_value: Final = f'{{"token": "{unique_marker()}", "user_role": "proxy_admin"}}'
|
|
key: Final = client.proxy.generate_key(
|
|
KeyGenerateBody(
|
|
key_alias=f"e2e-session-{unique_marker()}",
|
|
metadata=KeyMetadata(
|
|
logging=[
|
|
KeyLoggingCallback(
|
|
callback_name="langfuse",
|
|
callback_vars=KeyLoggingCallbackVars(langfuse_secret_key=stored_value),
|
|
)
|
|
]
|
|
),
|
|
)
|
|
)
|
|
resources.defer(lambda: client.proxy.delete_key(key))
|
|
|
|
metadata: Final = client.proxy.key_info(key).metadata
|
|
assert metadata is not None and metadata.logging, f"/key/info dropped the logging metadata: {metadata}"
|
|
encrypted: Final = metadata.logging[0].callback_vars.langfuse_secret_key
|
|
assert encrypted is not None and encrypted.startswith(ENCRYPTED_PREFIX), (
|
|
f"expected /key/info to return the stored secret encrypted, got {encrypted!r}"
|
|
)
|
|
|
|
for bearer in (encrypted.removeprefix(ENCRYPTED_PREFIX), encrypted):
|
|
result = client.list_users_as(bearer)
|
|
assert isinstance(result, UnauthorizedError), f"an encrypted stored value must get 401, got {result}"
|