test(e2e): issue the JWT suite's tokens from a real Keycloak realm

The suite used to mint its own RS256 tokens from a stand-in issuer, which
could only ever prove the proxy agreed with the tests: the claims were
whatever the tests chose to sign. Every JWT bug worth catching lives in the
shape of what an identity provider really emits, so the suite now runs
against Keycloak (realm in idp_realm.json), provisions a group and a user per
test through its admin API, and signs in through the direct-access grant.

That changes what the tokens look like: sub is Keycloak's opaque user uuid
rather than a friendly name, groups arrives from a protocol mapper, aud is
the IdP's own audience, and the JWKS carries an encryption key beside the
signing key so the proxy has to select on kid. The expiry case now takes a
one-second token from a second client in the realm and waits for it to lapse
instead of forging a stale exp.

The proxy config the suite needs is unchanged. CI runs it against a Keycloak
deployed beside the ephemeral stack, which lives in the releaser repo.

Claude-Session: https://claude.ai/code/session_01EX13mWex6RaBo9PYnkAtFW
This commit is contained in:
ryan-crabbe-berri 2026-09-09 16:56:07 -07:00
parent 90ac77e58e
commit 2085a37b82
13 changed files with 502 additions and 479 deletions

View file

@ -19,7 +19,7 @@ Each subdirectory under `tests/e2e/` is one suite, scoped to an endpoint family
- `security/` - secret handling and log-leak protection
- `router/` - routing and reliability behavior (fallbacks, cooldowns)
- `load/` - performance-category tests, kept OUT of the main suite: throughput/load SLO tests are a different testing category from functional e2e (variance-driven, historically flaky) and live outside this suite until re-implemented as their own pipeline (LIT-5163); do not add a live load test that runs in the default collection. What remains here: the weekly session-anomaly test (`test_weekly_session_anomaly_e2e.py`, Claude Code-shaped multi-turn sessions against real providers with ceilings on error rate, cache read/write, turn time, and spend; marked `weekly` and deselected unless `E2E_WEEKLY_ANOMALY` is set, driven by `.github/workflows/weekly_load_anomaly.yml`) and markerless harness unit tests for the Locust/session-anomaly aggregation logic
- `other/` - the holding-pen suite for the `other.*` registry cluster with no home of its own yet: the master-key auth gate, JWT auth (RS256 tokens minted by the test-only issuer in `jwt_issuer.py`, whose JWKS the proxy's `JWT_PUBLIC_KEY_URL` points at; see CONTRIBUTING.md for the start command and config block), and the process-lifecycle health probes (liveness, public readiness, authenticated readiness diagnostics). Promote a cluster out once it is large/stable enough for its own suite
- `other/` - the holding-pen suite for the `other.*` registry cluster with no home of its own yet: the master-key auth gate, JWT auth (access tokens issued by a real Keycloak realm, `idp.py` plus `idp_realm.json`, whose JWKS the proxy's `JWT_PUBLIC_KEY_URL` points at; see CONTRIBUTING.md for the start command and config block), and the process-lifecycle health probes (liveness, public readiness, authenticated readiness diagnostics). Promote a cluster out once it is large/stable enough for its own suite
- `gateway/` - proxy configuration only (`litellm-config.yml`); no tests
- `claude_code/` - the Claude Code compatibility matrix: drives the real `claude` CLI (and HTTP probes) against a proxy for each feature x provider cell, reporting tagged-union outcomes via the `compat_result` fixture; ships its own driver/builder/publisher plus `_*_unit_tests/` trees. The HTTP probes ride the shared transport (`ProxyClient.count_tokens` / `ProxyClient.messages`); the CLI-driving path stays bespoke
- `ui/` - the Admin UI browser suite: Playwright in TypeScript, driving the dashboard served by a live proxy on port 4000 (seeded postgres + mock LLM upstream; see its `run_e2e.sh`). It is a self-contained npm package with its own lockfile and does not use the Python harness, pytest markers, or the shared transport; the Python rules in this file (typed models, `Result` unions, basedpyright zero-error gate) do not apply inside it. Its only Python file, `fixtures/mock_llm_server/server.py`, is excluded from the e2e basedpyright gate via the root `pyrightconfig.json`

View file

@ -27,17 +27,20 @@ The suites run against a live proxy, so bring one up first by running the litell
2. Bring up a Postgres and a Redis for the proxy to use. The repo-root `docker-compose.yml` already defines a Postgres on `5432`; a `docker run -p 6379:6379 redis:7` covers Redis. Point `DATABASE_URL` / `REDIS_HOST` / `REDIS_PORT` at whatever you run. Tests that read Redis directly default to the deployed shape (TLS + cluster mode) whenever `REDIS_HOST` is set, so for a local standalone Redis also set `REDIS_CLUSTER=false` and `REDIS_SSL=false` (plus `REDIS_PASSWORD` when your Redis requires auth)
3. Start the test-only JWT issuer the `other/` suite mints tokens from, then the litellm proxy against your config, and confirm both are live. The issuer is a fake identity provider (`jwt_issuer.py`) with an open mint endpoint, so it binds loopback only; it generates one RSA key per process and serves it as a JWKS, and the proxy caches that JWKS for `public_key_ttl` (600s) without refetching on an unknown `kid`, so restart the proxy whenever you restart the issuer:
3. Start the identity provider the `other/` suite authenticates against, then the litellm proxy against your config, and confirm both are live. It is a real Keycloak, running the realm in `tests/e2e/idp_realm.json`, and the proxy trusts it because `JWT_PUBLIC_KEY_URL` points at that realm's JWKS. The proxy caches the JWKS for `public_key_ttl` (600s) and does not refetch on an unknown `kid`, so restart the proxy whenever you recreate the container:
```bash
set -a && source .env && set +a
uv run python tests/e2e/jwt_issuer.py &
curl -fs http://127.0.0.1:4190/.well-known/jwks.json
JWT_PUBLIC_KEY_URL=http://127.0.0.1:4190/.well-known/jwks.json litellm --config <your-e2e-config>.yml --port 4000
docker run -d --name litellm-e2e-idp -p 8480:8080 \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
-v "$PWD/tests/e2e/idp_realm.json:/opt/keycloak/data/import/realm.json:ro" \
quay.io/keycloak/keycloak:26.7.3 start-dev --import-realm
curl -fs --retry 30 --retry-delay 2 --retry-all-errors http://127.0.0.1:8480/realms/litellm-e2e/.well-known/openid-configuration
JWT_PUBLIC_KEY_URL=http://127.0.0.1:8480/realms/litellm-e2e/protocol/openid-connect/certs litellm --config <your-e2e-config>.yml --port 4000
curl -fs http://localhost:4000/health/liveliness
```
The issuer's port is `E2E_JWT_ISSUER_PORT` (default `4190`, the port in the URLs above), read by both the issuer process and the tests, so set it in one place if you change it. JWT auth is an enterprise feature, so the proxy also needs `LITELLM_LICENSE` in its environment, and its config needs the JWT block below. `enable_jwt_auth` only routes bearer tokens with three dot-separated segments into the JWT path, so `sk-` virtual keys and the master key keep working for every other suite. `proxy_batch_write_at` is lowered so the JWT spend-attribution test sees its row well inside the poll deadline:
The tests reach Keycloak at `E2E_KEYCLOAK_URL` (default `http://127.0.0.1:8480`) and provision their identities through its admin API, so they also need `E2E_KEYCLOAK_ADMIN_USER` and `E2E_KEYCLOAK_ADMIN_PASSWORD` (`admin` / `admin` for the throwaway container above; the deployed stacks take theirs from a secret). JWT auth is an enterprise feature, so the proxy needs `LITELLM_LICENSE` in its environment, and its config needs the JWT block below. `enable_jwt_auth` only routes bearer tokens with three dot-separated segments into the JWT path, so `sk-` virtual keys and the master key keep working for every other suite. `proxy_batch_write_at` is lowered so the JWT spend-attribution test sees its row well inside the poll deadline:
```yaml
general_settings:
@ -50,7 +53,9 @@ The suites run against a live proxy, so bring one up first by running the litell
user_id_upsert: true
```
Leave `JWT_AUDIENCE` and `JWT_ISSUER` unset unless you also put matching `aud` / `iss` claims in the tokens the tests mint; the issuer sets `iss` to its own base URL
Leave `JWT_AUDIENCE` and `JWT_ISSUER` unset. Keycloak's access tokens carry `aud: account`, its own audience rather than the proxy's, and their `iss` is whatever base URL the token was requested through, so pinning either one only makes sense once the deployment fixes Keycloak's hostname
CI runs this suite against a Keycloak deployed beside the ephemeral stack, and that deployment (the JWT config block, `JWT_PUBLIC_KEY_URL`, and the admin credential handed to the run pod) lives in the project-releaser repo, not here. A stack without it fails the JWT tests rather than skipping them
4. Run a suite against it; the harness reads `LITELLM_PROXY_URL` (default `http://localhost:4000`):

View file

@ -9,9 +9,9 @@
- {id: other.auth.llm_chat.not_bearer_scheme_denied, module: other, tier: P0, area: auth, assertions: [not_bearer_scheme_denied], source: "vendor testing strategy §11.1 / LIT-4778", rationale: "NotBearer scheme on chat is 401/403"}
- {id: other.auth.realtime.missing_header_denied, module: other, tier: P1, area: auth, assertions: [missing_header_denied], source: "vendor testing strategy §9.19 / LIT-4778", rationale: "Realtime client-secret and calls routes reject requests without Authorization"}
- {id: other.config.responses.metadata_redis_ttl_bounded, module: other, tier: P0, area: config, assertions: [ttl_bounded], source: "responses + redis cache", rationale: "Responses store+metadata must not leave TTL-unbounded Redis entries (LIT-1201)"}
- {id: other.auth.jwt.valid_token_allows, module: other, tier: P0, area: auth, assertions: [valid_token_allows], source: "handle_jwt.py:1217-1256 auth_jwt / user_api_key_auth.py:1365-1377", rationale: "An RS256 JWT signed by the configured JWKS whose groups claim names an existing team is accepted on /chat/completions"}
- {id: other.auth.jwt.spend_attributed_to_claims, module: other, tier: P0, area: auth, assertions: [spend_attributed_to_claims], source: "handle_jwt.py:2224 auth_builder / user_api_key_auth.py:1438-1474", rationale: "The spend log row for a JWT-authenticated call carries the team_id from the groups claim and the user_id from sub, not a virtual key's identity. Single-group claim only: the proxy picks the team from a set, so attribution over several groups is unordered"}
- {id: other.auth.jwt.expired_denied, module: other, tier: P0, area: auth, assertions: [expired_denied], source: "handle_jwt.py:1244-1250", rationale: "Expired JWT rejected 401 (Token Expired) even with valid signature; leeway is 0"}
- {id: other.auth.jwt.valid_token_allows, module: other, tier: P0, area: auth, assertions: [valid_token_allows], source: "handle_jwt.py:1217-1256 auth_jwt / user_api_key_auth.py:1365-1377", rationale: "An access token issued by the configured IdP whose groups claim names an existing team is accepted on /chat/completions"}
- {id: other.auth.jwt.spend_attributed_to_claims, module: other, tier: P0, area: auth, assertions: [spend_attributed_to_claims], source: "handle_jwt.py:2224 auth_builder / user_api_key_auth.py:1438-1474", rationale: "The spend log row for a JWT-authenticated call carries the team_id from the groups claim and the user_id from sub, which for a real IdP is an opaque uuid, not a virtual key's identity. Single-group claim only: the proxy picks the team from a set, so attribution over several groups is unordered"}
- {id: other.auth.jwt.expired_denied, module: other, tier: P0, area: auth, assertions: [expired_denied], source: "handle_jwt.py:1244-1250", rationale: "A token the IdP issued with a one-second lifespan is rejected 401 (Token Expired) once it lapses, even though its signature still verifies; leeway is 0"}
- {id: other.auth.jwt.invalid_signature_denied, module: other, tier: P0, area: auth, assertions: [invalid_signature_denied], source: "handle_jwt.py:1158-1166 _decode_jwt_with_public_key", rationale: "A genuine token whose signature bytes were altered fails verification with 401"}
- {id: other.auth.jwt.unknown_team_denied, module: other, tier: P0, area: auth, assertions: [unknown_team_denied], source: "handle_jwt.py:1549-1632 find_team_with_model_access", rationale: "A verified JWT whose groups claim resolves to no existing team is denied with 403 naming the unresolved team, never silently admitted without a team. The proxy words it as a model-access denial, the same body an existing team without model access gets"}
- {id: other.auth.jwt.virtual_key_unaffected, module: other, tier: P0, area: auth, assertions: [virtual_key_unaffected], source: "handle_jwt.py:213 is_jwt / user_api_key_auth.py:1332-1333", rationale: "enable_jwt_auth only routes three-segment bearer tokens into the JWT branch, so sk- virtual keys keep working on the same proxy"}

View file

@ -15,7 +15,6 @@ from typing import Final
from dotenv import load_dotenv
from fixture_mode import deterministic_marker, parse_fixture_mode
from jwt_issuer import jwt_issuer_url
from provider_edge import provider_edge_api_base
# Local runs keep provider / DataDog keys in tests/e2e/.env (see CONTRIBUTING.md).
@ -44,9 +43,6 @@ UI_BASE_URL = os.environ.get("E2E_UI_BASE_URL", PROXY_BASE_URL).rstrip("/")
CHEAP_ANTHROPIC_MODEL = os.environ.get("E2E_CHEAP_ANTHROPIC_MODEL", "claude-haiku-4-5")
CHEAP_OPENAI_MODEL = os.environ.get("E2E_CHEAP_OPENAI_MODEL", "gpt-5.5")
# Test-only JWT issuer (jwt_issuer.py); the port is E2E_JWT_ISSUER_PORT (see CONTRIBUTING.md).
JWT_ISSUER_URL = jwt_issuer_url()
LINEAR_MCP_URL = os.environ.get("E2E_LINEAR_MCP_URL", "https://mcp.linear.app/mcp")
LINEAR_STORAGE_STATE = os.environ.get("E2E_LINEAR_STORAGE_STATE", "")

View file

@ -125,6 +125,20 @@ class ProbeResult(BaseModel):
return 200 <= self.status_code < 500 and self.status_code != 404
class ExternalWrite(BaseModel):
"""Outcome of a write to a non-proxy API (an identity provider's admin API)
that answers with a status and, on create, a Location header naming the new
resource rather than a JSON body."""
status_code: int
location: str = ""
body: str = ""
@property
def ok(self) -> bool:
return 200 <= self.status_code < 300
class StreamingResponse(BaseModel):
"""Raw outcome for calls whose body is provider-native or streamed: status, the
x-litellm-call-id header, the x-litellm-response-cost header (StandardLogging
@ -252,16 +266,17 @@ def assert_auth_denied(result: StreamingResponse, context: str) -> None:
f"{context}: expected 401/403, got {result.status_code}: {result.body[:300]}"
)
def _headers(headers: BaseModel) -> dict[str, str]:
dumped: dict[str, object] = headers.model_dump(by_alias=True, exclude_none=True)
def _flat(model: BaseModel) -> dict[str, str]:
dumped: dict[str, object] = model.model_dump(by_alias=True, exclude_none=True)
return {key: str(value) for key, value in dumped.items()}
def _headers(headers: BaseModel) -> dict[str, str]:
return _flat(headers)
def _params(params: BaseModel | None) -> dict[str, str]:
if params is None:
return {}
dumped: dict[str, object] = params.model_dump(by_alias=True, exclude_none=True)
return {key: str(value) for key, value in dumped.items()}
return _flat(params) if params is not None else {}
TRANSIENT_STATUSES: frozenset[int] = frozenset({529})
@ -386,20 +401,20 @@ def get_external[R: BaseModel](
return _classify(resp, response_type)
def post_external[R: BaseModel](
def post_form_external[R: BaseModel](
url: str,
*,
json: BaseModel,
form: BaseModel,
response_type: type[R],
timeout: float = 30.0,
) -> Result[R]:
"""POST an absolute URL outside the proxy (e.g. the e2e JWT issuer's mint
endpoint). Like get_external: no proxy base url, no proxy auth, and the same
tagged-union classification as every other call."""
"""POST an absolute URL outside the proxy as `application/x-www-form-urlencoded`,
the encoding OAuth 2 token endpoints take. Like get_external: no proxy base url,
no proxy auth, and the same tagged-union classification as every other call."""
try:
resp = requests.post(
url,
json=json.model_dump(by_alias=True, exclude_none=True),
data=_flat(form),
timeout=timeout,
)
except requests.RequestException as exc:
@ -407,6 +422,39 @@ def post_external[R: BaseModel](
return _classify(resp, response_type)
def post_json_external(
url: str,
*,
headers: BaseModel,
json: BaseModel,
timeout: float = 30.0,
) -> ExternalWrite:
"""POST an absolute URL outside the proxy under its own bearer, for an API that
answers a create with a status and a Location header rather than a JSON body."""
try:
resp = requests.post(
url,
headers=_headers(headers),
json=json.model_dump(by_alias=True, exclude_none=True),
timeout=timeout,
)
except requests.RequestException as exc:
return ExternalWrite(status_code=-1, body=str(exc))
return ExternalWrite(
status_code=resp.status_code,
location=resp.headers.get("Location", ""),
body=resp.text,
)
def delete_external(url: str, *, headers: BaseModel, timeout: float = 30.0) -> ExternalWrite:
try:
resp = requests.delete(url, headers=_headers(headers), timeout=timeout)
except requests.RequestException as exc:
return ExternalWrite(status_code=-1, body=str(exc))
return ExternalWrite(status_code=resp.status_code, body=resp.text)
def delete[R: BaseModel](
url: URL,
*,

226
tests/e2e/idp.py Normal file
View file

@ -0,0 +1,226 @@
"""The identity provider the JWT suite authenticates against: a real Keycloak
realm, imported from `idp_realm.json`.
A real IdP rather than a hand-rolled signer because every JWT bug this suite
exists to catch lives in the shape of what an IdP actually emits: `sub` is an
opaque uuid and not a friendly name, group membership arrives as a claim built
by a protocol mapper, the JWKS carries a signing key next to an encryption key
so the proxy has to select on `kid`, and `aud` is the IdP's own audience rather
than the proxy's. A stand-in issuer that mints exactly the claims the tests
assert on can only prove the proxy agrees with the tests.
Tests never hold a signing key. They provision an identity through Keycloak's
admin API (a group named after the litellm team, a user in it with a password
generated for that test alone), then ask Keycloak for an access token through
the direct-access grant, the same way a CLI or service account signs in. The
proxy's `JWT_PUBLIC_KEY_URL` points at this realm's JWKS, so the token the
tests carry is trusted for exactly one reason: Keycloak signed it.
The realm declares two clients. `litellm-e2e-tests` mints ordinary tokens; the
`litellm-e2e-shortlived` client sets `access.token.lifespan` to one second, so
the expiry test lets a genuine token expire instead of forging a stale `exp`.
Connection details come from the environment (`E2E_KEYCLOAK_URL` and the admin
credential). A missing or unreachable IdP is a hard failure naming the start
command, never a skip, so a stack deployed without it turns the run red.
"""
from __future__ import annotations
import os
import secrets
from dataclasses import dataclass
from typing import Final, Literal
import pytest
from pydantic import BaseModel, Field
from e2e_http import (
AuthHeaders,
ExternalWrite,
NetworkError,
Result,
Success,
delete_external,
post_form_external,
post_json_external,
)
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"
SHORT_LIVED_TOKEN_SECONDS: Final = 1
_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
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 not write.ok:
pytest.fail(f"Keycloak refused to create {context}: HTTP {write.status_code} {write.body[:300]}")
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
group: str
group_id: str
@dataclass(frozen=True, slots=True)
class Keycloak:
base_url: str
realm: str
admin_username: str
admin_password: str
@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:
delete_external(self._admin_url(f"/users/{user_id}"), headers=self._admin_headers())
def delete_group(self, group_id: str) -> None:
delete_external(self._admin_url(f"/groups/{group_id}"), headers=self._admin_headers())
def provision(self, *, marker: str, group: str) -> 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)
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
)
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) -> 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,
)
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,
)

56
tests/e2e/idp_realm.json Normal file
View file

@ -0,0 +1,56 @@
{
"realm": "litellm-e2e",
"enabled": true,
"sslRequired": "none",
"registrationAllowed": false,
"accessTokenLifespan": 300,
"clients": [
{
"clientId": "litellm-e2e-tests",
"enabled": true,
"publicClient": true,
"standardFlowEnabled": false,
"directAccessGrantsEnabled": true,
"protocolMappers": [
{
"name": "groups",
"protocol": "openid-connect",
"protocolMapper": "oidc-group-membership-mapper",
"consentRequired": false,
"config": {
"claim.name": "groups",
"full.path": "false",
"access.token.claim": "true",
"id.token.claim": "true",
"userinfo.token.claim": "true"
}
}
]
},
{
"clientId": "litellm-e2e-shortlived",
"enabled": true,
"publicClient": true,
"standardFlowEnabled": false,
"directAccessGrantsEnabled": true,
"attributes": {
"access.token.lifespan": "1"
},
"protocolMappers": [
{
"name": "groups",
"protocol": "openid-connect",
"protocolMapper": "oidc-group-membership-mapper",
"consentRequired": false,
"config": {
"claim.name": "groups",
"full.path": "false",
"access.token.claim": "true",
"id.token.claim": "true",
"userinfo.token.claim": "true"
}
}
]
}
]
}

View file

@ -1,230 +0,0 @@
"""Test-only fake identity provider for the e2e JWT suite. Never deploy it.
Run it next to the proxy (`uv run python tests/e2e/jwt_issuer.py`). On start it
generates one RSA signing key and keeps it for the life of the process, serving
the public half at `GET /.well-known/jwks.json` and signing whatever claims are
POSTed to `/token`. The proxy's `JWT_PUBLIC_KEY_URL` points at the JWKS URL, and
tests mint RS256 tokens by POSTing claims, so the private key never leaves this
process and no test holds it.
One key per process, rather than per pytest run, is what survives the proxy's
JWKS cache: the proxy caches the JWKS for `litellm_jwtauth.public_key_ttl`
(600s by default) and does not refetch on an unknown `kid`, so a key rotated
every run would be rejected until the cache expired. Restart the proxy whenever
you restart the issuer.
The mint endpoint takes no credential: anyone who can reach it gets a token the
proxy trusts. It therefore binds 127.0.0.1 only, must never be exposed beyond
loopback, and must only ever be trusted by a proxy under test.
"""
from __future__ import annotations
import logging
import os
import threading
import time
import uuid
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Final, Literal
import jwt
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from jwt.utils import to_base64url_uint
from pydantic import BaseModel, RootModel, ValidationError
JWT_ISSUER_PORT_ENV: Final = "E2E_JWT_ISSUER_PORT"
DEFAULT_JWT_ISSUER_PORT: Final = 4190
LOOPBACK_HOST: Final = "127.0.0.1"
JWKS_PATH: Final = "/.well-known/jwks.json"
TOKEN_PATH: Final = "/token"
DEFAULT_TOKEN_LIFETIME_SECONDS: Final = 300
ClaimValue = str | int | float | bool | None | list[str]
def jwt_issuer_port() -> int:
raw: Final = os.environ.get(JWT_ISSUER_PORT_ENV, "").strip()
return int(raw) if raw else DEFAULT_JWT_ISSUER_PORT
def jwt_issuer_url() -> str:
return f"http://{LOOPBACK_HOST}:{jwt_issuer_port()}"
class RsaJwk(BaseModel):
kty: Literal["RSA"] = "RSA"
alg: Literal["RS256"] = "RS256"
use: Literal["sig"] = "sig"
kid: str
n: str
e: str
class JwksDocument(BaseModel):
keys: tuple[RsaJwk, ...]
class TokenRequest(RootModel[Mapping[str, ClaimValue]]):
"""The JSON body of POST /token: the claims to sign, verbatim. Nested objects
are not supported; every value is a scalar or a list of strings."""
class MintedToken(BaseModel):
token: str
class IssuerError(BaseModel):
error: str
@dataclass(frozen=True, slots=True)
class SigningKey:
kid: str
private_pem: str
jwk: RsaJwk
def generate_signing_key(kid: str | None = None) -> SigningKey:
private_key: Final = rsa.generate_private_key(public_exponent=65537, key_size=2048)
numbers: Final = private_key.public_key().public_numbers()
resolved_kid: Final = kid if kid is not None else uuid.uuid4().hex
return SigningKey(
kid=resolved_kid,
private_pem=private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode(),
jwk=RsaJwk(
kid=resolved_kid,
n=to_base64url_uint(numbers.n).decode(),
e=to_base64url_uint(numbers.e).decode(),
),
)
def mint(
key: SigningKey,
claims: Mapping[str, ClaimValue],
*,
issuer: str,
now: int,
lifetime_seconds: int = DEFAULT_TOKEN_LIFETIME_SECONDS,
) -> str:
"""Sign `claims` as a compact RS256 JWT carrying `key.kid` in its header.
`iss`, `iat`, and `exp` are filled in when absent and left alone when the
caller sets them, so a test can mint an already-expired token."""
payload: Final[dict[str, ClaimValue]] = {
"iss": issuer,
"iat": now,
"exp": now + lifetime_seconds,
**claims,
}
return jwt.encode(payload, key.private_pem, algorithm="RS256", headers={"kid": key.kid})
class _IssuerServer(ThreadingHTTPServer):
daemon_threads = True
def __init__(self, bind: tuple[str, int], *, key: SigningKey, clock: Callable[[], int]) -> None:
super().__init__(bind, _IssuerHandler)
self.key: Final = key
self.clock: Final = clock
@property
def url(self) -> str:
host, port = self.server_address[0], self.server_address[1]
return f"http://{host}:{port}"
class _IssuerHandler(BaseHTTPRequestHandler):
def _issuer(self) -> _IssuerServer:
issuer: Final = self.server
assert isinstance(issuer, _IssuerServer)
return issuer
def do_GET(self) -> None:
if self.path != JWKS_PATH:
self._send(404, IssuerError(error=f"unknown path {self.path}; the JWKS is at {JWKS_PATH}"))
return
self._send(200, JwksDocument(keys=(self._issuer().key.jwk,)))
def do_POST(self) -> None:
if self.path != TOKEN_PATH:
self._send(404, IssuerError(error=f"unknown path {self.path}; mint tokens at {TOKEN_PATH}"))
return
length: Final = int(self.headers.get("Content-Length", "0"))
try:
request: Final = TokenRequest.model_validate_json(self.rfile.read(length))
except ValidationError as exc:
self._send(400, IssuerError(error=f"claims must be a JSON object of scalar or string-list values: {exc}"))
return
issuer: Final = self._issuer()
token: Final = mint(issuer.key, request.root, issuer=issuer.url, now=issuer.clock())
self._send(200, MintedToken(token=token))
def _send(self, status: int, body: BaseModel) -> None:
payload: Final = body.model_dump_json().encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
@dataclass(frozen=True, slots=True)
class RunningIssuer:
url: str
key: SigningKey
server: _IssuerServer
@property
def jwks_url(self) -> str:
return f"{self.url}{JWKS_PATH}"
def shutdown(self) -> None:
self.server.shutdown()
self.server.server_close()
def _wall_clock() -> int:
return int(time.time())
def start_jwt_issuer(
*,
port: int = 0,
key: SigningKey | None = None,
clock: Callable[[], int] = _wall_clock,
) -> RunningIssuer:
"""Serve the issuer on loopback in a daemon thread. `port=0` takes an
OS-assigned port for in-process tests; the CLI passes the documented one."""
server: Final = _IssuerServer((LOOPBACK_HOST, port), key=key or generate_signing_key(), clock=clock)
thread: Final = threading.Thread(target=server.serve_forever, name="e2e-jwt-issuer", daemon=True)
thread.start()
return RunningIssuer(url=server.url, key=server.key, server=server)
def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(message)s")
running: Final = start_jwt_issuer(port=jwt_issuer_port())
logging.getLogger(__name__).info(
"e2e jwt issuer listening on %s jwks=%s mint=POST %s%s kid=%s (test-only, loopback, ctrl-c to stop)",
running.url,
running.jwks_url,
running.url,
TOKEN_PATH,
running.key.kid,
)
try:
threading.Event().wait()
except KeyboardInterrupt:
running.shutdown()
if __name__ == "__main__":
main()

View file

@ -1078,18 +1078,6 @@ class UserListResponse(BaseModel):
total: int
class JwtClaimsBody(BaseModel):
"""Claims POSTed to the e2e JWT issuer's /token: exactly what the
`litellm_jwtauth` block in CONTRIBUTING.md reads (sub -> user_id, email ->
user_email, groups -> team ids), plus an explicit `exp` for the expired case;
the issuer fills in iss/iat/exp when they are left unset."""
sub: str
email: str
groups: Sequence[str]
exp: int | None = None
class OrgNewBody(BaseModel):
organization_alias: str
models: list[str] = []

View file

@ -1,27 +1,23 @@
"""Client for the `other` holding-pen suite: the auth gate (master key vs an
invalid key on an admin route), JWT auth against the test-only issuer
(jwt_issuer.py), and the process-lifecycle health probes (liveness, public
readiness, authenticated readiness diagnostics).
invalid key on an admin route), JWT auth against the suite's Keycloak realm
(idp.py), and the process-lifecycle health probes (liveness, public readiness,
authenticated readiness diagnostics).
Holds the shared ProxyClient so `resources` / `scoped_key` still clean up, and
adds only the routes these behaviors need. The health probes deliberately send
no auth header (public routes), so they go through the transport with an empty
headers model rather than a bearer. Tokens are minted by POSTing claims to the
issuer, so no test ever holds a signing key.
headers model rather than a bearer. JWT tests reach the identity provider
through `idp`, which provisions identities and mints tokens through Keycloak's
own endpoints, so no test ever holds a signing key.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Final
import pytest
from e2e_config import JWT_ISSUER_URL
from e2e_http import NetworkError, NoBody, ProbeResult, Result, Success, post_external
from jwt_issuer import TOKEN_PATH, MintedToken
from e2e_http import NoBody, ProbeResult, Result
from idp import Keycloak, keycloak_from_env
from models import (
JwtClaimsBody,
ReadinessDetailsResponse,
ReadinessResponse,
UserListParams,
@ -33,7 +29,11 @@ from proxy_client import ProxyClient
@dataclass(frozen=True, slots=True)
class OtherClient:
proxy: ProxyClient
jwt_issuer_url: str
@property
def idp(self) -> Keycloak:
"""Resolved per use, so the suite's non-JWT tests never need the IdP env."""
return keycloak_from_env()
def liveness(self) -> ProbeResult:
"""GET /health/liveliness. Unauthenticated; the probe returns status +
@ -77,21 +77,6 @@ class OtherClient:
response_type=UserListResponse,
)
def mint_jwt(self, claims: JwtClaimsBody) -> str:
"""Have the test-only issuer sign `claims` into a compact RS256 JWT. A
missing issuer is a hard failure naming the start command, not a skip."""
result: Final = post_external(f"{self.jwt_issuer_url}{TOKEN_PATH}", json=claims, response_type=MintedToken)
match result:
case Success(data=minted):
return minted.token
case NetworkError(message=message):
pytest.fail(
f"No live JWT issuer at {self.jwt_issuer_url}: {message}. Start it next to the proxy with "
"`uv run python tests/e2e/jwt_issuer.py` (see CONTRIBUTING.md)"
)
case _:
raise AssertionError(result)
def build_client(proxy: ProxyClient) -> OtherClient:
return OtherClient(proxy=proxy, jwt_issuer_url=JWT_ISSUER_URL)
return OtherClient(proxy=proxy)

View file

@ -1,54 +1,63 @@
"""Live e2e: RS256 JWTs minted by the test-only issuer (jwt_issuer.py) against a
"""Live e2e: access tokens issued by a real Keycloak realm (idp.py) against a
proxy running with `enable_jwt_auth: true` and the `litellm_jwtauth` block from
CONTRIBUTING.md (sub -> user_id, email -> user_email, groups -> team ids,
user_id_upsert).
Every case mints through the issuer, so the tests never hold a signing key: the
bad-signature case corrupts a genuine signature, the expired case asks the
issuer for a token whose `exp` is already in the past. Those identities get
their own freshly created team so a rejection can only be blamed on the token,
while the unknown-team case names a team that was never created. An acceptance
is proven twice, at the boundary (200 from a real provider) and in the spend log
the proxy attributes to the claims. The last case keeps a plain `sk-` virtual
key working on the same proxy, guarding against the flag turning JWT on for
everyone.
Every identity is provisioned in Keycloak for the test that uses it: a group
named after the litellm team, and a user in that group whose password exists
only for the length of the test. Tokens then come from Keycloak's direct-access
grant, so no test ever holds a signing key and the claims the proxy reads are
the ones an IdP really emits (`sub` is Keycloak's user uuid, `groups` comes off
a protocol mapper, `aud` is Keycloak's own audience).
The rejection cases stay honest about where the rejection has to come from: the
bad-signature case corrupts a genuine signature, and the expiry case takes its
token from the realm's one-second client and waits for it to lapse rather than
forging a stale `exp`. Those identities get their own freshly created team, so a
rejection can only be blamed on the token, while the unknown-team case names a
group no litellm team was ever created for. An acceptance is proven twice, at
the boundary (200 from a real provider) and in the spend log the proxy
attributes to the claims. The last case keeps a plain `sk-` virtual key working
on the same proxy, guarding against the flag turning JWT on for everyone.
"""
from __future__ import annotations
from dataclasses import dataclass
import time
from typing import Final
import pytest
from e2e_config import CHEAP_OPENAI_MODEL, unique_marker
from e2e_http import UnauthorizedError, UnknownApiError, unwrap
from idp import SHORT_LIVED_CLIENT_ID, SHORT_LIVED_TOKEN_SECONDS, Identity
from lifecycle import ResourceManager
from models import ChatBody, ChatMessage, JwtClaimsBody, TeamNewBody
from models import ChatBody, ChatMessage, TeamNewBody
from other_client import OtherClient
pytestmark = pytest.mark.e2e
@dataclass(frozen=True, slots=True)
class JwtIdentity:
user_id: str
team_id: str
def claims(self, *, exp: int | None = None) -> JwtClaimsBody:
return JwtClaimsBody(sub=self.user_id, email=f"{self.user_id}@example.com", groups=(self.team_id,), exp=exp)
def _provision(client: OtherClient, resources: ResourceManager, *, marker: str) -> Identity:
"""A Keycloak group and a user in it, torn down with the test. The group name
is what the token's `groups` claim carries, which is what the proxy resolves
as a litellm team id."""
identity: Final = client.idp.provision(marker=marker, group=f"e2e-jwt-team-{marker}")
resources.defer(lambda: client.idp.delete_user(identity.user_id))
resources.defer(lambda: client.idp.delete_group(identity.group_id))
resources.defer(lambda: client.proxy.delete_user(identity.user_id))
return identity
@pytest.fixture
def identity(client: OtherClient, resources: ResourceManager) -> JwtIdentity:
def identity(client: OtherClient, resources: ResourceManager) -> Identity:
"""An IdP identity whose group is also a real litellm team, so anything the
proxy rejects is about the token and never about an unresolvable team."""
marker: Final = unique_marker()
team_id: Final = client.proxy.create_team(
TeamNewBody(team_alias=f"e2e-jwt-{marker}", team_id=f"e2e-jwt-team-{marker}")
)
provisioned: Final = _provision(client, resources, marker=marker)
team_id: Final = client.proxy.create_team(TeamNewBody(team_alias=f"e2e-jwt-{marker}", team_id=provisioned.group))
resources.defer(lambda: client.proxy.delete_team(team_id))
user_id: Final = f"e2e-jwt-user-{marker}"
resources.defer(lambda: client.proxy.delete_user(user_id))
return JwtIdentity(user_id=user_id, team_id=team_id)
return provisioned
def _ping() -> ChatBody:
@ -68,9 +77,9 @@ def _corrupt_signature(token: str) -> str:
class TestJwtAuth:
@pytest.mark.covers("other.auth.jwt.valid_token_allows", "other.auth.jwt.spend_attributed_to_claims")
def test_valid_token_for_an_existing_team_is_accepted_and_attributed(
self, client: OtherClient, identity: JwtIdentity
self, client: OtherClient, identity: Identity
) -> None:
token: Final = client.mint_jwt(identity.claims())
token: Final = client.idp.access_token(identity)
response: Final = unwrap(client.proxy.chat(token, _ping()))
assert response.id is not None and response.choices, (
@ -80,16 +89,16 @@ class TestJwtAuth:
rows: Final = client.proxy.poll_logs_for_request_id(response.id)
assert rows, f"no spend log row for request {response.id} within the poll deadline"
row: Final = rows[0]
assert row.team_id == identity.team_id, (
f"spend row must carry the team from the JWT groups claim {identity.team_id!r}, got {row.team_id!r}"
assert row.team_id == identity.group, (
f"spend row must carry the team from the JWT groups claim {identity.group!r}, got {row.team_id!r}"
)
assert row.user == identity.user_id, (
f"spend row must carry the user from the JWT sub claim {identity.user_id!r}, got {row.user!r}"
)
@pytest.mark.covers("other.auth.jwt.invalid_signature_denied")
def test_tampered_signature_is_rejected(self, client: OtherClient, identity: JwtIdentity) -> None:
tampered: Final = _corrupt_signature(client.mint_jwt(identity.claims()))
def test_tampered_signature_is_rejected(self, client: OtherClient, identity: Identity) -> None:
tampered: Final = _corrupt_signature(client.idp.access_token(identity))
result: Final = client.proxy.chat(tampered, _ping())
assert isinstance(result, UnauthorizedError), (
@ -100,27 +109,29 @@ class TestJwtAuth:
)
@pytest.mark.covers("other.auth.jwt.expired_denied")
def test_expired_token_is_rejected(self, client: OtherClient, identity: JwtIdentity) -> None:
expired: Final = client.mint_jwt(identity.claims(exp=1))
def test_expired_token_is_rejected(self, client: OtherClient, identity: Identity) -> None:
expiring: Final = client.idp.access_token(identity, client_id=SHORT_LIVED_CLIENT_ID)
time.sleep(SHORT_LIVED_TOKEN_SECONDS + 1)
result: Final = client.proxy.chat(expired, _ping())
result: Final = client.proxy.chat(expiring, _ping())
assert isinstance(result, UnauthorizedError), (
f"an expired JWT must be rejected with 401 even though its signature verifies, got {result}"
)
assert "expired" in result.body.lower(), f"the 401 must say the token expired, got {result.body[:300]}"
@pytest.mark.covers("other.auth.jwt.unknown_team_denied")
def test_token_naming_a_team_that_does_not_exist_is_rejected(self, client: OtherClient) -> None:
marker: Final = unique_marker()
never_created: Final = JwtIdentity(user_id=f"e2e-jwt-user-{marker}", team_id=f"e2e-jwt-missing-team-{marker}")
token: Final = client.mint_jwt(never_created.claims())
def test_token_naming_a_team_that_does_not_exist_is_rejected(
self, client: OtherClient, resources: ResourceManager
) -> None:
stranger: Final = _provision(client, resources, marker=unique_marker())
token: Final = client.idp.access_token(stranger)
result: Final = client.proxy.chat(token, _ping())
assert isinstance(result, UnknownApiError) and result.status_code == 403, (
f"a valid JWT whose groups name no existing team must be rejected with 403, got {result}"
)
assert never_created.team_id in result.body, (
f"the 403 must name the team it could not resolve ({never_created.team_id}), got {result.body[:300]}"
assert stranger.group in result.body, (
f"the 403 must name the team it could not resolve ({stranger.group}), got {result.body[:300]}"
)
@pytest.mark.covers("other.auth.jwt.virtual_key_unaffected")

79
tests/e2e/test_idp.py Normal file
View file

@ -0,0 +1,79 @@
"""Harness coverage for idp.py: the pure parts of the Keycloak client, which are
the ones a wrong value in silently mistargets. No proxy and no IdP needed, so
these carry no `e2e` marker and run everywhere."""
from __future__ import annotations
from typing import Final
import pytest
from e2e_http import ExternalWrite
from idp import (
KEYCLOAK_ADMIN_PASSWORD_ENV,
KEYCLOAK_ADMIN_USER_ENV,
KEYCLOAK_REALM_ENV,
KEYCLOAK_URL_ENV,
Keycloak,
PasswordCredential,
created_id,
UserCreateBody,
keycloak_from_env,
)
_REALM: Final = Keycloak(
base_url="http://keycloak:8080", realm="litellm-e2e", admin_username="admin", admin_password="pw"
)
def test_realm_urls_match_keycloaks_own_layout() -> None:
assert _REALM.issuer == "http://keycloak:8080/realms/litellm-e2e"
assert _REALM.jwks_url == "http://keycloak:8080/realms/litellm-e2e/protocol/openid-connect/certs"
assert _REALM.token_url("master") == "http://keycloak:8080/realms/master/protocol/openid-connect/token"
def test_created_id_is_the_last_segment_of_the_location_header() -> None:
created: Final = ExternalWrite(
status_code=201, location="http://keycloak:8080/admin/realms/litellm-e2e/groups/abc-123"
)
assert created_id(created, "a group") == "abc-123"
def test_a_refused_create_fails_the_test_with_the_idps_own_words() -> None:
with pytest.raises(BaseException, match=r"409.*already exists"):
created_id(ExternalWrite(status_code=409, body="Group already exists"), "a group")
def test_new_users_are_born_fully_set_up() -> None:
"""A user without a profile or with a pending required action authenticates
nowhere: Keycloak answers every grant with "Account is not fully set up"."""
body: Final = UserCreateBody(
username="e2e", email="e2e@example.com", groups=("team",), credentials=(PasswordCredential(value="pw"),)
).model_dump(by_alias=True)
assert body["requiredActions"] == ()
assert body["firstName"] and body["lastName"] and body["emailVerified"] is True
assert body["credentials"][0]["temporary"] is False
def test_connection_details_come_from_the_environment(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv(KEYCLOAK_URL_ENV, "http://keycloak.litellm.svc.cluster.local:8080/")
monkeypatch.setenv(KEYCLOAK_REALM_ENV, "other-realm")
monkeypatch.setenv(KEYCLOAK_ADMIN_USER_ENV, "admin")
monkeypatch.setenv(KEYCLOAK_ADMIN_PASSWORD_ENV, "pw")
resolved: Final = keycloak_from_env()
assert resolved.issuer == "http://keycloak.litellm.svc.cluster.local:8080/realms/other-realm"
assert resolved.admin_username == "admin" and resolved.admin_password == "pw"
@pytest.mark.parametrize("blank", ["", " "])
def test_a_missing_admin_credential_fails_loudly_instead_of_skipping(
monkeypatch: pytest.MonkeyPatch, blank: str
) -> None:
monkeypatch.setenv(KEYCLOAK_ADMIN_USER_ENV, "admin")
monkeypatch.setenv(KEYCLOAK_ADMIN_PASSWORD_ENV, blank)
with pytest.raises(BaseException, match=KEYCLOAK_ADMIN_PASSWORD_ENV):
keycloak_from_env()

View file

@ -1,141 +0,0 @@
"""Harness coverage for the test-only JWT issuer (jwt_issuer.py).
No proxy and no ``e2e`` marker. The issuer is booted in-process on an
OS-assigned port with a fixed clock and driven over HTTP through
``e2e_http.post_external`` / ``get_external``, the same transport the live
suite uses, so what is pinned here is the contract the live JWT tests lean on:
a token minted at ``/token`` verifies against the key served at
``/.well-known/jwks.json`` under the ``kid`` in its header, ``iss``/``iat``/``exp``
are filled in only when the caller left them out, and malformed claim bodies or
unknown paths are refused instead of signed.
"""
from __future__ import annotations
import time
from collections.abc import Iterator
from typing import Final
import jwt
import pytest
from pydantic import BaseModel, RootModel
from e2e_http import UnknownApiError, get_external, post_external, unwrap
from jwt_issuer import (
DEFAULT_TOKEN_LIFETIME_SECONDS,
JWKS_PATH,
JWT_ISSUER_PORT_ENV,
TOKEN_PATH,
JwksDocument,
MintedToken,
RunningIssuer,
jwt_issuer_port,
start_jwt_issuer,
)
FROZEN_NOW: Final = int(time.time())
class _Claims(BaseModel):
sub: str
groups: tuple[str, ...] = ()
exp: int | None = None
class _DecodedClaims(BaseModel):
sub: str
iss: str
iat: int
exp: int
groups: tuple[str, ...] = ()
class _NotAnObject(RootModel[tuple[str, ...]]):
pass
@pytest.fixture(scope="module")
def issuer() -> Iterator[RunningIssuer]:
running: Final = start_jwt_issuer(clock=lambda: FROZEN_NOW)
yield running
running.shutdown()
def _mint(issuer: RunningIssuer, claims: _Claims) -> str:
return unwrap(post_external(f"{issuer.url}{TOKEN_PATH}", json=claims, response_type=MintedToken)).token
def _served_jwks(issuer: RunningIssuer) -> JwksDocument:
return unwrap(get_external(issuer.jwks_url, response_type=JwksDocument))
def _decode(token: str, jwks: JwksDocument, *, verify_exp: bool = True) -> _DecodedClaims:
key: Final = jwt.PyJWK.from_json(jwks.keys[0].model_dump_json())
decoded: Final = jwt.decode(token, key, algorithms=["RS256"], options={"verify_exp": verify_exp})
return _DecodedClaims.model_validate(decoded)
class TestJwtIssuer:
def test_minted_token_verifies_against_the_served_jwks(self, issuer: RunningIssuer) -> None:
token: Final = _mint(issuer, _Claims(sub="alice", groups=("team-a",)))
jwks: Final = _served_jwks(issuer)
assert len(jwks.keys) == 1
assert jwt.get_unverified_header(token)["kid"] == jwks.keys[0].kid
claims: Final = _decode(token, jwks)
assert claims.sub == "alice"
assert claims.groups == ("team-a",)
assert claims.iss == issuer.url
assert claims.iat == FROZEN_NOW
assert claims.exp == FROZEN_NOW + DEFAULT_TOKEN_LIFETIME_SECONDS
def test_a_token_signed_by_another_key_does_not_verify(self, issuer: RunningIssuer) -> None:
other: Final = start_jwt_issuer(clock=lambda: FROZEN_NOW)
try:
foreign_token: Final = _mint(other, _Claims(sub="alice"))
finally:
other.shutdown()
with pytest.raises(jwt.InvalidSignatureError):
_decode(foreign_token, _served_jwks(issuer))
def test_an_explicit_exp_is_signed_as_given(self, issuer: RunningIssuer) -> None:
expired_at: Final = FROZEN_NOW - 60
token: Final = _mint(issuer, _Claims(sub="alice", exp=expired_at))
jwks: Final = _served_jwks(issuer)
assert _decode(token, jwks, verify_exp=False).exp == expired_at
with pytest.raises(jwt.ExpiredSignatureError):
_ = _decode(token, jwks)
def test_non_object_claims_are_refused(self, issuer: RunningIssuer) -> None:
result: Final = post_external(
f"{issuer.url}{TOKEN_PATH}", json=_NotAnObject(("not", "claims")), response_type=MintedToken
)
assert isinstance(result, UnknownApiError)
assert result.status_code == 400
@pytest.mark.parametrize(
("method", "path"),
[("GET", TOKEN_PATH), ("POST", JWKS_PATH), ("GET", "/token/anything")],
ids=["get-token", "post-jwks", "get-other"],
)
def test_unknown_routes_are_404(self, issuer: RunningIssuer, method: str, path: str) -> None:
url: Final = f"{issuer.url}{path}"
result: Final = (
get_external(url, response_type=MintedToken)
if method == "GET"
else post_external(url, json=_Claims(sub="alice"), response_type=MintedToken)
)
assert isinstance(result, UnknownApiError)
assert result.status_code == 404
class TestIssuerPort:
def test_defaults_to_the_documented_port(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv(JWT_ISSUER_PORT_ENV, raising=False)
assert jwt_issuer_port() == 4190, "CONTRIBUTING.md hardcodes 4190 in JWT_PUBLIC_KEY_URL"
def test_env_override_wins(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv(JWT_ISSUER_PORT_ENV, " 4321 ")
assert jwt_issuer_port() == 4321