Overview
Tests start their own Keycloak in a container, import a committed realm fixture, check the realm is up, then drive the real OIDC endpoints. A realm holds users, credentials, roles and groups; a client is an application that asks Keycloak to authenticate users (Server Admin Guide (opens in new window)).
When to use
Step 1 - Start Keycloak per run with a pinned tag
# tests/auth/conftest.py
import pytest
import requests
from testcontainers.keycloak import KeycloakContainer
@pytest.fixture(scope="session")
def keycloak():
kc = KeycloakContainer("quay.io/keycloak/keycloak:26.0") \
.with_realm_import_file("tests/auth/fixtures/test-realm.json")
with kc: # the context manager starts and stops the container
yield kcwith_realm_import_file() mounts the file at /opt/keycloak/data/import/realm.json and adds --import-realm to the start-dev command; start() waits for /health/ready. The admin user comes from the container (test / test unless passed), not from environment variables pointing at another server (testcontainers-python Keycloak module (opens in new window)). The default image is latest, so always pass a tag.
Java (testcontainers-keycloak (opens in new window)):
@Container
static KeycloakContainer keycloak =
new KeycloakContainer("quay.io/keycloak/keycloak:26.0")
.withRealmImportFile("/test-realm.json"); // classpath resourceEvery URL comes from the container (keycloak.get_url()); never a literal localhost:8080, which can hit a different server on a developer machine and nothing on CI.
Step 2 - Commit a sanitised realm fixture
Export the realm (Admin Console: Realm settings → Action → Partial export), then sanitise it with one repeatable command and commit the result:
jq '
del(.smtpServer, .identityProviders, .identityProviderMappers)
| del(.components["org.keycloak.keys.KeyProvider"])
| del(.components["org.keycloak.storage.UserStorageProvider"])
| (.clients[]? | select(.publicClient != true and .secret? != null) | .secret) = "test-secret"
' realm-export.json > tests/auth/fixtures/test-realm.jsonThis drops environment-coupled blocks (SMTP relay, external identity providers, LDAP user federation) that an isolated container cannot reach, drops signing keys so the container generates its own, and replaces each confidential client's secret with a fixed test value instead of deleting it (an imported confidential client still needs one). Add local test users with fixed passwords under users if tests log in. Then grep the output for anything that still looks live (secret, password, bindCredential, privateKey) before committing.
Step 3 - Check the realm is really there
Before any flow test, assert the started server serves the imported realm, so a bad import fails here and not as a 404 in every test:
@pytest.fixture(scope="session")
def realm(keycloak):
base = keycloak.get_url()
r = requests.get(f"{base}/realms/test/.well-known/openid-configuration", timeout=5)
assert r.status_code == 200, f"realm 'test' not imported: {r.status_code}"
disco = r.json()
assert disco["token_endpoint"] == f"{base}/realms/test/protocol/openid-connect/token"
assert "introspection_endpoint" in disco
return discoIf it fails: check the container log for the import line, the realm name inside the JSON, and that the file path exists. Fix the fixture, not the paths: Keycloak 17+ serves /realms/... with no /auth prefix (migrating to Quarkus (opens in new window)).
Step 4 - Client-credentials grant
def test_client_credentials(realm):
r = requests.post(realm["token_endpoint"], data={
"grant_type": "client_credentials",
"client_id": "service-account-client",
"client_secret": "test-secret",
}, timeout=5)
assert r.status_code == 200
body = r.json()
assert body["token_type"].lower() == "bearer"
assert body["expires_in"] > 0Pass the token to the app's protected endpoint and assert 200, and pass no token and an expired or tampered one and assert 401.
The password grant must not be used per RFC 9700 (RFC 9700 (opens in new window)). Keep an existing legacy password-grant test only while its client still allows it; write new user-login tests with authorization-code + PKCE (Step 6).
Step 5 - Token introspection in the resource server
The resource server must authenticate the introspection call with its own client credentials, not with the caller's token, and refuse the request on any answer other than "active": true (non-200, timeout, connection error). Recipe: references/endpoint-recipes.md.
Step 6 - Authorization-code + PKCE, token exchange, UMA
Each of these has a recipe in references/flow-recipes.md:
For a custom browser flow (for example password-less), import the realm with the flow bound, drive it with Playwright through the login page, and assert the redirect carries a code.
Step 7 - Admin REST API
Use the admin client the container provides (python-keycloak KeycloakAdmin logged in with the container's admin user), and seed realm state from the fixture rather than admin calls wherever possible. Recipe: references/endpoint-recipes.md.
Step 8 - CI
jobs:
auth-integration:
runs-on: ubuntu-latest # Docker daemon available
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install "testcontainers[keycloak]" pytest requests
- run: pytest tests/auth -vNo services: block and no KC_URL / admin-password secrets: each run starts and owns its server, so two builds cannot see each other's data.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Shared dev / staging Keycloak | Builds interfere; people change data under the test | Per-run container (Step 1) |
latest image | Results change when nobody changed the suite | Pin a tag; upgrade on purpose |
Hard-coded localhost:8080 | Hits whatever runs there | get_url() (Step 1) |
| Gitignoring the realm, fetching it at run time | Clone + Docker no longer enough | Sanitised committed fixture (Step 2) |
| Realm provisioned by admin calls in fixtures | Slow, order-dependent | Import from JSON (Step 2) |
| Middleware falls back to local decode when introspection fails | Admits tokens the server did not vouch for | Refuse on anything but active: true (Step 5) |
Limitations
References
Additional endpoint patterns beyond the core token endpoint: token introspection for resource servers, and provisioning through the Admin REST API.
Token introspection
Introspection is called by the resource server with its own client credentials (HTTP Basic or client_id / client_secret in the form), never with the caller's bearer token:
def test_token_introspection(realm, access_token):
response = requests.post(
realm["introspection_endpoint"],
auth=("test-client", "test-secret"),
data={"token": access_token},
timeout=5,
)
assert response.status_code == 200
body = response.json()
assert body["active"] is True
assert "preferred_username" in bodyIn the middleware under test, anything other than a 200 with "active": true (non-200, connection error, timeout) must refuse the request; test each of those branches, plus an expired token ("active": false).
Admin REST API
keycloak.get_client() returns a python-keycloak KeycloakAdmin logged in to the master realm with the container's own admin user (testcontainers-python Keycloak module (opens in new window)); pass realm_name / user_realm_name to work on the test realm:
def test_create_user_via_admin_api(keycloak):
admin = keycloak.get_client(realm_name="test", user_realm_name="master")
user_id = admin.create_user({
"username": "newuser",
"enabled": True,
"credentials": [{"type": "password", "value": "newpass", "temporary": False}],
})
assert user_id
assert [u["username"] for u in admin.get_users({"username": "newuser", "exact": True})] == ["newuser"]Use admin calls for what the app itself provisions at run time. Seed realms, clients and baseline users from the realm fixture instead.
Deep reference for keycloak-tests Step 6. All snippets use the realm fixture from SKILL.md Step 3 (the realm's discovery document). alice_token / bob_token are defined in the PKCE section; the realm fixture defines users alice / bob with those test passwords, the public mobile-app client with the redirect below, and the confidential test-client.
Authorization code + PKCE (public client, private-scheme redirect)
The PKCE challenge is the unpadded base64url SHA-256 of a random verifier, sent with code_challenge_method=S256 (RFC 7636 (opens in new window)).
import base64, hashlib, re, secrets
from html import unescape
from urllib.parse import urlparse, parse_qs
import pytest
import requests
REDIRECT = "com.corp.app://oauth/callback"
def pkce_pair():
verifier = secrets.token_urlsafe(64)[:96]
digest = hashlib.sha256(verifier.encode("ascii")).digest()
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
return verifier, challenge
def pkce_login(realm, client_id, redirect, username, password):
"""Drive the real login page; return the access token."""
verifier, challenge = pkce_pair()
with requests.Session() as s: # keeps the login cookies
page = s.get(realm["authorization_endpoint"], params={
"client_id": client_id, "response_type": "code",
"redirect_uri": redirect, "scope": "openid",
"code_challenge": challenge, "code_challenge_method": "S256",
}, timeout=5)
action = unescape(re.search(r'action="([^"]+)"', page.text).group(1))
resp = s.post(action, data={"username": username, "password": password},
allow_redirects=False, timeout=5) # never follow the app scheme
assert resp.status_code == 302
location = resp.headers["Location"]
assert location.startswith(redirect)
code = parse_qs(urlparse(location).query)["code"][0]
token = requests.post(realm["token_endpoint"], data={
"grant_type": "authorization_code", "client_id": client_id,
"code": code, "redirect_uri": redirect, "code_verifier": verifier,
}, timeout=5) # public client: no secret
assert token.status_code == 200
return token.json()["access_token"]
def test_mobile_login_pkce(realm):
access = pkce_login(realm, "mobile-app", REDIRECT, "alice", "alicepass")
info = requests.post(realm["introspection_endpoint"],
auth=("test-client", "test-secret"),
data={"token": access}, timeout=5).json()
assert info["active"] is True
assert info["preferred_username"] == "alice" # the token is alice's
@pytest.fixture
def alice_token(realm):
return pkce_login(realm, "mobile-app", REDIRECT, "alice", "alicepass")
@pytest.fixture
def bob_token(realm):
return pkce_login(realm, "mobile-app", REDIRECT, "bob", "bobpass")The login form's action comes from Keycloak's login page HTML; if the theme is customised, select the form by id instead of the first action=.
Standard token exchange
Switch on Standard token exchange for the confidential requester client in the realm fixture. Only access tokens are accepted as subject_token; audience (optional, repeatable) names the target client (token exchange guide (opens in new window)).
def test_token_exchange_narrows_audience(realm, alice_token):
r = requests.post(realm["token_endpoint"], auth=("requester-client", "test-secret"), data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"subject_token": alice_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"audience": "target-client",
}, timeout=5)
assert r.status_code == 200Add the negative case: the same request from a client without the switch must be refused.
UMA / Authorization Services decision
Ask the token endpoint for a decision on RESOURCE_ID#SCOPE_ID; audience is mandatory with permission. With response_mode=decision the body is {"result": true}, and a request that maps to no permission returns 403 (Authorization Services (opens in new window)).
def test_alice_can_read_report(realm, alice_token):
r = requests.post(realm["token_endpoint"],
headers={"Authorization": f"Bearer {alice_token}"},
data={
"grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket",
"audience": "reports-api",
"permission": "Report#read",
"response_mode": "decision",
}, timeout=5)
assert r.status_code == 200 and r.json()["result"] is True
def test_bob_cannot_delete_report(realm, bob_token):
r = requests.post(realm["token_endpoint"],
headers={"Authorization": f"Bearer {bob_token}"},
data={"grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket",
"audience": "reports-api", "permission": "Report#delete",
"response_mode": "decision"}, timeout=5)
assert r.status_code == 403- A two-day-old token still opens /orders, and all five auth tests are green
- AUTH-212: integration coverage for the new mobile sign-in, before the 30th
- Security blocked PR #881 and the release branch cuts on Thursday
- Every token request in the auth suite 404s since we containerised the identity server
- Our auth suite goes red whenever two PRs build at once