mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-15 23:31:29 +00:00
228 lines
8.5 KiB
Python
228 lines
8.5 KiB
Python
"""Provision isolated identities and obtain signed tokens from the test Keycloak realm."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import secrets
|
|
import warnings
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass, field
|
|
from typing import Final, Literal
|
|
|
|
import pytest
|
|
from e2e_http import (
|
|
AuthHeaders,
|
|
ExternalWrite,
|
|
NetworkError,
|
|
Result,
|
|
Success,
|
|
delete_external,
|
|
post_form_external,
|
|
post_json_external,
|
|
)
|
|
from pydantic import BaseModel, Field
|
|
|
|
KEYCLOAK_URL_ENV: Final = "E2E_KEYCLOAK_URL"
|
|
KEYCLOAK_REALM_ENV: Final = "E2E_KEYCLOAK_REALM"
|
|
KEYCLOAK_ADMIN_USER_ENV: Final = "E2E_KEYCLOAK_ADMIN_USER"
|
|
KEYCLOAK_ADMIN_PASSWORD_ENV: Final = "E2E_KEYCLOAK_ADMIN_PASSWORD"
|
|
|
|
DEFAULT_KEYCLOAK_URL: Final = "http://127.0.0.1:8480"
|
|
DEFAULT_REALM: Final = "litellm-e2e"
|
|
TESTS_CLIENT_ID: Final = "litellm-e2e-tests"
|
|
SHORT_LIVED_CLIENT_ID: Final = "litellm-e2e-shortlived"
|
|
ADMIN_CLIENT_ID: Final = "litellm-e2e-admin"
|
|
WRONG_AUDIENCE_CLIENT_ID: Final = "litellm-e2e-other-app"
|
|
|
|
_START_HINT: Final = (
|
|
"Start it with the `docker run ... quay.io/keycloak/keycloak` command in tests/e2e/CONTRIBUTING.md, "
|
|
f"and point {KEYCLOAK_URL_ENV} / {KEYCLOAK_ADMIN_USER_ENV} / {KEYCLOAK_ADMIN_PASSWORD_ENV} at it"
|
|
)
|
|
|
|
|
|
class TokenGrantForm(BaseModel):
|
|
"""The direct-access (password) grant an OAuth 2 token endpoint takes, form encoded."""
|
|
|
|
grant_type: Literal["password"] = "password"
|
|
client_id: str
|
|
username: str
|
|
password: str
|
|
|
|
|
|
class TokenResponse(BaseModel):
|
|
access_token: str = Field(repr=False)
|
|
|
|
|
|
class TokenRequestHeaders(BaseModel):
|
|
host: str | None = None
|
|
|
|
|
|
class GroupCreateBody(BaseModel):
|
|
name: str
|
|
|
|
|
|
class PasswordCredential(BaseModel):
|
|
type: Literal["password"] = "password"
|
|
value: str
|
|
temporary: bool = False
|
|
|
|
|
|
class UserCreateBody(BaseModel):
|
|
"""Keycloak's admin representation of a new user. `firstName` / `lastName` and
|
|
an empty `requiredActions` matter: a realm's default VERIFY_PROFILE action
|
|
otherwise leaves the account "not fully set up" and every grant fails."""
|
|
|
|
username: str
|
|
email: str
|
|
email_verified: bool = Field(default=True, alias="emailVerified")
|
|
first_name: str = Field(default="E2E", alias="firstName")
|
|
last_name: str = Field(default="Tester", alias="lastName")
|
|
enabled: bool = True
|
|
groups: tuple[str, ...]
|
|
credentials: tuple[PasswordCredential, ...]
|
|
required_actions: tuple[str, ...] = Field(default=(), alias="requiredActions")
|
|
|
|
|
|
def created_id(write: ExternalWrite, context: str) -> str:
|
|
"""The new resource's id, which Keycloak returns only as the last segment of
|
|
the Location header on a 201."""
|
|
if write.status_code != 201:
|
|
pytest.fail(f"Keycloak refused to create {context}: HTTP {write.status_code} {write.body[:300]}")
|
|
if not write.location or write.location.endswith("/"):
|
|
pytest.fail(f"Keycloak created {context} without a resource id in its Location header")
|
|
return write.location.rsplit("/", 1)[-1]
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Identity:
|
|
"""One provisioned IdP user: the `sub` the proxy will see, the credential the
|
|
test signs in with, and the group whose name the litellm team carries."""
|
|
|
|
user_id: str
|
|
username: str
|
|
password: str = field(repr=False)
|
|
group: str
|
|
group_id: str
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Keycloak:
|
|
base_url: str
|
|
realm: str
|
|
admin_username: str
|
|
admin_password: str = field(repr=False)
|
|
|
|
@property
|
|
def issuer(self) -> str:
|
|
return f"{self.base_url}/realms/{self.realm}"
|
|
|
|
@property
|
|
def jwks_url(self) -> str:
|
|
return f"{self.issuer}/protocol/openid-connect/certs"
|
|
|
|
def token_url(self, realm: str) -> str:
|
|
return f"{self.base_url}/realms/{realm}/protocol/openid-connect/token"
|
|
|
|
def _admin_url(self, path: str) -> str:
|
|
return f"{self.base_url}/admin/realms/{self.realm}{path}"
|
|
|
|
def _admin_headers(self) -> AuthHeaders:
|
|
"""A fresh admin token per call: the master realm's tokens are short lived,
|
|
and a cached one would expire in the middle of a slow test."""
|
|
form: Final = TokenGrantForm(client_id="admin-cli", username=self.admin_username, password=self.admin_password)
|
|
result: Final = post_form_external(self.token_url("master"), form=form, response_type=TokenResponse)
|
|
return AuthHeaders(authorization=f"Bearer {self._token(result, 'the Keycloak admin credential')}")
|
|
|
|
def _token(self, result: Result[TokenResponse], context: str) -> str:
|
|
match result:
|
|
case Success(data=granted):
|
|
return granted.access_token
|
|
case NetworkError(message=message):
|
|
return pytest.fail(f"No live Keycloak at {self.base_url} for {context}: {message}. {_START_HINT}")
|
|
case _:
|
|
return pytest.fail(f"Keycloak refused {context}: {result}")
|
|
|
|
def create_group(self, name: str) -> str:
|
|
return created_id(
|
|
post_json_external(
|
|
self._admin_url("/groups"), headers=self._admin_headers(), json=GroupCreateBody(name=name)
|
|
),
|
|
f"group {name}",
|
|
)
|
|
|
|
def create_user(self, *, username: str, email: str, password: str, group: str) -> str:
|
|
return created_id(
|
|
post_json_external(
|
|
self._admin_url("/users"),
|
|
headers=self._admin_headers(),
|
|
json=UserCreateBody(
|
|
username=username,
|
|
email=email,
|
|
groups=(group,),
|
|
credentials=(PasswordCredential(value=password),),
|
|
),
|
|
),
|
|
f"user {username}",
|
|
)
|
|
|
|
def delete_user(self, user_id: str) -> None:
|
|
self._delete(f"/users/{user_id}")
|
|
|
|
def delete_group(self, group_id: str) -> None:
|
|
self._delete(f"/groups/{group_id}")
|
|
|
|
def _delete(self, path: str) -> None:
|
|
try:
|
|
headers: Final = self._admin_headers()
|
|
except pytest.fail.Exception as exc:
|
|
warnings.warn(f"Keycloak cleanup could not authenticate for {path}: {exc}", RuntimeWarning, stacklevel=2)
|
|
return
|
|
result: Final = delete_external(self._admin_url(path), headers=headers)
|
|
if result.status_code not in (204, 404):
|
|
warnings.warn(
|
|
f"Keycloak cleanup failed for {path}: HTTP {result.status_code} {result.body[:300]}",
|
|
RuntimeWarning,
|
|
stacklevel=2,
|
|
)
|
|
|
|
def provision(self, *, marker: str, group: str, defer: Callable[[Callable[[], object]], None]) -> Identity:
|
|
"""Create `group` and a user in it, credentialed with a password generated
|
|
for this test alone, and hand back the identity a token can be minted for."""
|
|
group_id: Final = self.create_group(group)
|
|
defer(lambda: self.delete_group(group_id))
|
|
username: Final = f"e2e-jwt-user-{marker}"
|
|
password: Final = secrets.token_urlsafe(24)
|
|
user_id: Final = self.create_user(
|
|
username=username, email=f"{username}@example.com", password=password, group=group
|
|
)
|
|
defer(lambda: self.delete_user(user_id))
|
|
return Identity(user_id=user_id, username=username, password=password, group=group, group_id=group_id)
|
|
|
|
def access_token(
|
|
self, identity: Identity, *, client_id: str = TESTS_CLIENT_ID, issuer_host: str | None = None
|
|
) -> str:
|
|
"""Sign `identity` in through the direct-access grant and hand back the
|
|
access token Keycloak signed, exactly as it came off the wire."""
|
|
result: Final = post_form_external(
|
|
self.token_url(self.realm),
|
|
form=TokenGrantForm(client_id=client_id, username=identity.username, password=identity.password),
|
|
response_type=TokenResponse,
|
|
headers=TokenRequestHeaders(host=issuer_host),
|
|
)
|
|
return self._token(result, f"a token for {identity.username}")
|
|
|
|
|
|
def keycloak_from_env() -> Keycloak:
|
|
admin_username: Final = os.environ.get(KEYCLOAK_ADMIN_USER_ENV, "").strip()
|
|
admin_password: Final = os.environ.get(KEYCLOAK_ADMIN_PASSWORD_ENV, "").strip()
|
|
if not admin_username or not admin_password:
|
|
pytest.fail(
|
|
f"The JWT suite needs {KEYCLOAK_ADMIN_USER_ENV} and {KEYCLOAK_ADMIN_PASSWORD_ENV} to provision "
|
|
f"identities in its Keycloak realm, and neither may be empty. {_START_HINT}"
|
|
)
|
|
return Keycloak(
|
|
base_url=os.environ.get(KEYCLOAK_URL_ENV, DEFAULT_KEYCLOAK_URL).rstrip("/"),
|
|
realm=os.environ.get(KEYCLOAK_REALM_ENV, "").strip() or DEFAULT_REALM,
|
|
admin_username=admin_username,
|
|
admin_password=admin_password,
|
|
)
|