Capstone C.1 — Keycloak Reference Lab: Multi-Client, SSO, and JWKS Rotation
Goal: Deploy a fully functional, production-modeled Keycloak environment locally via Docker Compose, configure three distinct client types (SPA, API, and CLI), exercise Single Sign-On, and execute an automated zero-downtime JWKS key rotation drill.
Prerequisites: Stages 0 through 6 complete.
Table of Contents
- Lab Architecture Overview
- Docker Compose Stack: Keycloak + PostgreSQL
- Automated Realm Provisioning (
realm-export.json) - Client Configuration Breakdown
- The Three Exercises
- JWKS Rotation Drill Execution
- Automated Test & Verification Suite
- Tear Down & Cleanup
- Next Step
1. Lab Architecture Overview
This reference lab exercises the entire authentication stack across four distinct components:
┌───────────────────────────┐
│ Keycloak Identity Hub │
│ (Port 8080 / PostgreSQL)│
└─────────────┬─────────────┘
│
┌───────────────────────────────┼───────────────────────────────┐
│ │ │
┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
│ Client 1 │ │ Client 2 │ │ Client 3 │
│ Web SPA │ │ Resource API│ │ CLI Daemon │
│ (OIDC+PKCE) │ │ (Bearer RS) │ │ (ClientCred)│
└─────────────┘ └─────────────┘ └─────────────┘
2. Docker Compose Stack: Keycloak + PostgreSQL
Create a file named docker-compose.yml:
version: "3.8"
services:
postgres:
image: postgres:16-alpine
container_name: keycloak-db
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: keycloak_secret_password
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U keycloak"]
interval: 5s
timeout: 5s
retries: 5
networks:
- auth-net
keycloak:
image: quay.io/keycloak/keycloak:24.0.5
container_name: keycloak-server
command: start-dev --import-realm
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: keycloak_secret_password
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin_secure_password
KC_HEALTH_ENABLED: "true"
KC_METRICS_ENABLED: "true"
ports:
- "8080:8080"
volumes:
- ./realm-export.json:/opt/keycloak/data/import/realm-export.json:ro
depends_on:
postgres:
condition: service_healthy
networks:
- auth-net
volumes:
postgres_data:
networks:
auth-net:
driver: bridge3. Automated Realm Provisioning (realm-export.json)
To auto-import our configured realm upon container startup, create realm-export.json:
{
"realm": "enterprise-lab",
"enabled": true,
"displayName": "Enterprise Identity Lab",
"accessTokenLifespan": 300,
"ssoSessionIdleTimeout": 1800,
"ssoSessionMaxLifespan": 36000,
"roles": {
"realm": [
{ "name": "admin", "description": "Administrator Role" },
{ "name": "engineer", "description": "Engineering Staff" },
{ "name": "viewer", "description": "Read-only Access" }
]
},
"users": [
{
"username": "darshan",
"enabled": true,
"email": "darshan@example.com",
"firstName": "Darshan",
"lastName": "K",
"credentials": [
{
"type": "password",
"value": "LabUserPassword2026!",
"temporary": false
}
],
"realmRoles": ["admin", "engineer"]
}
],
"clients": [
{
"clientId": "portal-spa",
"name": "Enterprise Web Portal SPA",
"enabled": true,
"publicClient": true,
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": true,
"redirectUris": ["http://localhost:3000/*", "http://127.0.0.1:3000/*"],
"webOrigins": ["http://localhost:3000", "+"],
"attributes": {
"pkce.code.challenge.method": "S256"
}
},
{
"clientId": "inventory-api",
"name": "Backend Inventory Resource Server",
"enabled": true,
"bearerOnly": true
},
{
"clientId": "cli-automation",
"name": "CI/CD Automation CLI",
"enabled": true,
"publicClient": false,
"serviceAccountsEnabled": true,
"secret": "cli_client_secret_778899",
"standardFlowEnabled": false,
"directAccessGrantsEnabled": false
}
]
}4. Client Configuration Breakdown
portal-spa(Public Client):- Uses
publicClient: true(no client secret stored in the browser). - Enforces PKCE with
pkce.code.challenge.method: S256. - Restricts callbacks strictly to
http://localhost:3000/*.
- Uses
inventory-api(Resource Server):- Configured as
bearerOnly: true. It never initiates user login; it only validates incoming bearer tokens against the Keycloak realm JWKS.
- Configured as
cli-automation(Machine-to-Machine):- Configured with
serviceAccountsEnabled: true. Exercises OAuth 2.0client_credentialsgrant with a confidential secret.
- Configured with
5. The Three Exercises
Exercise 1: M2M Token Issuance (Client Credentials)
curl -s -X POST "http://localhost:8080/realms/enterprise-lab/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=cli-automation" \
-d "client_secret=cli_client_secret_778899" | jq .Verify that Keycloak issues an access token with typ: Bearer and sub: service-account-cli-automation.
Exercise 2: Authorization Code Flow with PKCE
- Generate a high-entropy string
code_verifier. - Calculate
code_challenge = base64url(sha256(code_verifier)). - Open the authorization URL in your browser:
http://localhost:8080/realms/enterprise-lab/protocol/openid-connect/auth? response_type=code &client_id=portal-spa &redirect_uri=http://localhost:3000/callback &scope=openid profile email &code_challenge=<YOUR_CODE_CHALLENGE> &code_challenge_method=S256 - Authenticate as
darshan/LabUserPassword2026!. - Capture the authorization code from the redirect URL and exchange it via POST:
curl -s -X POST "http://localhost:8080/realms/enterprise-lab/protocol/openid-connect/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=authorization_code" \ -d "client_id=portal-spa" \ -d "redirect_uri=http://localhost:3000/callback" \ -d "code=<AUTH_CODE>" \ -d "code_verifier=<YOUR_CODE_VERIFIER>" | jq .
6. JWKS Rotation Drill Execution
Run a zero-downtime key rotation drill inside Keycloak:
# 1. Fetch current active keys
curl -s http://localhost:8080/realms/enterprise-lab/protocol/openid-connect/certs | jq '.keys[].kid'
# 2. Add new RSA-OAEP/RS256 active key via Keycloak Admin CLI
docker exec -it keycloak-server /opt/keycloak/bin/kcadm.sh config credentials \
--server http://localhost:8080 --realm master --user admin --password admin_secure_password
# Add a higher priority key provider (priority 100 > default 10)
docker exec -it keycloak-server /opt/keycloak/bin/kcadm.sh create components -r enterprise-lab \
-s name=rsa-new-active -s providerId=rsa-generated -s providerType=org.keycloak.keys.KeyProvider \
-s 'config.priority=["150"]' -s 'config.keySize=["2048"]' -s 'config.algorithm=["RS256"]'
# 3. Verify JWKS now advertises BOTH old and new public keys
curl -s http://localhost:8080/realms/enterprise-lab/protocol/openid-connect/certs | jq '.keys[].kid'Downstream services continue to verify tokens signed by the old key while new tokens are signed using the newly promoted key.
7. Automated Test & Verification Suite
Create verify_lab.py:
import httpx
import jwt
from jwt import PyJWKClient
REALM_URL = "http://localhost:8080/realms/enterprise-lab"
JWKS_URL = f"{REALM_URL}/protocol/openid-connect/certs"
def verify_lab():
# 1. Verify Discovery
resp = httpx.get(f"{REALM_URL}/.well-known/openid-configuration")
assert resp.status_code == 200, "Discovery failed"
disc = resp.json()
assert disc["issuer"] == REALM_URL, "Issuer mismatch"
# 2. Obtain Token via Client Credentials
token_resp = httpx.post(
disc["token_endpoint"],
data={
"grant_type": "client_credentials",
"client_id": "cli-automation",
"client_secret": "cli_client_secret_778899"
}
)
assert token_resp.status_code == 200, "Token exchange failed"
access_token = token_resp.json()["access_token"]
# 3. Cryptographically Verify Token
jwks_client = PyJWKClient(JWKS_URL)
signing_key = jwks_client.get_signing_key_from_jwt(access_token)
claims = jwt.decode(
access_token,
signing_key.key,
algorithms=["RS256"],
audience="account",
issuer=REALM_URL
)
print("Verification Successful! Token Claims:", claims["sub"])
if __name__ == "__main__":
verify_lab()8. Tear Down & Cleanup
docker-compose down -v9. Next Step
With your working reference lab verified, you are ready for the final capstone exercise: simulating production incidents, threat containment, and disaster response.
→ Capstone C.2 — Identity Incident Tabletop: 3 Realistic Scenarios