Keycloak as the external IdP¶
In the delivery, Keycloak is the external identity provider for people: the sign-in form,
passwords, and browser sessions. It decides nothing else: a person's authority lives in IAM
(principal, PAT) and in Control Plane (binding and permissions). This article describes the
compose profile idp, the realm platform and its clients, the Admin API utility scripts,
registering Keycloak in IAM, and the procedure for onboarding a person. It is for
installation administrators.
Keycloak's role in the identity chain¶
Keycloak tokens are consumed by the console server (client
runtime-console) and by the personal harness launcher (harness-launcher, profile
harness, client human-harness). Both take the person through Keycloak sign-in and
immediately exchange the resulting token in IAM. For example, the launcher:
sequenceDiagram
participant U as Browser
participant L as harness-launcher
participant K as Keycloak (realm platform)
participant I as iam-service
participant H as person's container
participant CP as control-plane-api
U->>L: /harness/
L->>K: Authorization Code + PKCE (client human-harness)
K-->>L: access token (iss realm, aud iam-service)
L->>I: POST /api/v1/tenants/{t}/federation:exchange
I-->>L: the person's IAM principal
L->>H: request to this principal's container
H->>CP: Bearer (exchange of the person's PAT)
Keycloak answers only the question "who is this person". Which principal corresponds to them is decided by IAM through the external identity link; what the person can do in Control Plane is decided by their principal's binding (see Authorization and permissions). Agents, services, and the operator MCP plugin do not use Keycloak: their identity is a Platform Access Token or IAM client credentials.
Deployment: the idp profile¶
The idp profile of deploy/local/compose.yml starts three services:
| Service | What it does |
|---|---|
keycloak-db |
PostgreSQL 16 for Keycloak only: database and role keycloak, password KEYCLOAK_DB_PASSWORD, volume keycloak_db |
realm-render |
A one-off container: substitutes the public address into the realm template and puts the result into the realm_import volume |
keycloak |
Keycloak 26 (start --import-realm), listens on 8080 in the compose network, on the host — 127.0.0.1:${KEYCLOAK_HOST_PORT} (default 18081) |
The console and people's workplaces sign in through Keycloak: the console profile also
starts the idp services, and the harness profile requires idp — without Keycloak the
launcher cannot let a person in.
make up PROFILES="core edge console" # console and Keycloak
make up PROFILES="core edge console harness" # plus the assistant
keycloak container parameters¶
| Variable | Value | Meaning |
|---|---|---|
KC_HOSTNAME |
${TAIMEN_PUBLIC_URL}/auth |
public address with a prefix; realm issuer = ${TAIMEN_PUBLIC_URL}/auth/realms/platform |
KC_HTTP_RELATIVE_PATH |
/auth |
Keycloak serves everything under /auth |
KC_HOSTNAME_STRICT |
${KEYCLOAK_HOSTNAME_STRICT:-true} |
turned off (false) locally over http; true in a production installation |
KC_PROXY_HEADERS |
xforwarded |
trust X-Forwarded-* from Caddy |
KC_HTTP_ENABLED |
true |
Caddy terminates TLS |
KC_HEALTH_ENABLED |
true |
healthcheck GET /auth/health/ready on management port 9000 |
KC_DB_URL_HOST, KC_DB_URL_DATABASE |
keycloak-db, keycloak |
its own database in keycloak-db |
KC_BOOTSTRAP_ADMIN_USERNAME / _PASSWORD |
KEYCLOAK_ADMIN / KEYCLOAK_ADMIN_PASSWORD |
administrator of the master realm (applied only on first start) |
The memory limit is KEYCLOAK_MEM_LIMIT (default 768m); keycloak-db uses the shared
PG_MEM_LIMIT. The secrets KEYCLOAK_DB_PASSWORD and KEYCLOAK_ADMIN_PASSWORD are filled
in by make secrets.
Realm template¶
The realm is described in git by the template deploy/keycloak/platform-realm.json.
Keycloak does not expand environment variables on import, so realm-render replaces the
placeholder __WEB_BASE_URL__ with TAIMEN_PUBLIC_URL (redirect URIs and web origins of
the console and assistant clients). The console client (runtime-console) and the
assistant client (human-harness) are part of the template: a new installation gets them
on the first import.
The realm is imported only on first start
--import-realm creates the realm if it does not exist yet. Editing the template
and restarting Keycloak has no effect on an already imported realm: the live realm is
stored in keycloak-db. Changes to an existing realm are made through the Admin API or
the admin console; keep the template consistent with them so that a new installation
gets the same on import.
Realm platform¶
Main settings¶
| Parameter | Value |
|---|---|
sslRequired |
external |
loginWithEmailAllowed |
true (sign-in by e-mail) |
duplicateEmailsAllowed |
false |
registrationAllowed |
false — no self-registration |
resetPasswordAllowed |
true |
rememberMe |
true |
bruteForceProtected |
true — password-guessing protection on the IdP side |
accessTokenLifespan |
300 s |
ssoSessionIdleTimeout |
1800 s |
ssoSessionMaxLifespan |
36000 s |
defaultSignatureAlgorithm |
RS256 |
Clients¶
| Client | Purpose |
|---|---|
runtime-console |
sign-in to the console: confidential, Authorization Code + PKCE S256, redirect ${TAIMEN_PUBLIC_URL}/console/_auth/callback |
human-harness |
sign-in for the personal harness launcher: Authorization Code + PKCE S256, redirect ${TAIMEN_PUBLIC_URL}/harness/* |
iam-service |
bearer-only, audience only: IAM accepts upstream tokens addressed to it |
The console and assistant clients have the mapper iam-service-audience
(oidc-audience-mapper): it adds iam-service to the aud of tokens. This is exactly the
audience IAM checks for the keycloak provider.
The realm has no realm roles, group mappers, or organization attributes: neither IAM nor Control Plane needs them.
User profile¶
The realm declares a declarative user profile. What matters for onboarding people: email
is required, and the keycloak-users.py script always fills in firstName and lastName —
without them Keycloak may ask the person to complete the profile at sign-in (a required
action). Undeclared attributes can be changed only by an administrator
(unmanagedAttributePolicy: ADMIN_EDIT).
Admin API utility scripts¶
The scripts live in deploy/keycloak/, use only the Python standard library, and reach
Keycloak at the internal address http://keycloak:8080/auth (overridden by
KC_INTERNAL_URL). They are run in a one-off container in the compose network; the launch
command is in each script's docstring.
| Script | What it does | Environment |
|---|---|---|
keycloak-users.py |
Creates or updates people in the realm: profile (email, firstName, lastName, emailVerified: true) and password. Idempotent. Prints {"username", "id"} — the id is the user's sub |
KC_ADMIN_PASSWORD, KC_USERS (a JSON array: username, email, password, optionally first_name, last_name, temporary), KC_ADMIN_USERNAME, KC_REALM |
keycloak-runtime-console-client.py |
Creates the confidential console client runtime-console (Code + PKCE S256, redirect <address>/console/_auth/callback, audience iam-service in the id and access tokens) or brings an existing one in line with the template. Takes the secret from a file, or generates it and writes it there (0600); does not print it |
KC_ADMIN_PASSWORD, WEB_BASE_URL, SECRET_FILE (default /secrets/runtime-console-oidc-secret — mount secrets/), LOCAL_PORTS |
docker run --rm --network taimen_default -v "$PWD/deploy/keycloak:/s:ro" \
-e KC_ADMIN_PASSWORD="$KEYCLOAK_ADMIN_PASSWORD" \
-e KC_USERS='[{"username":"alice","email":"alice@example.com","first_name":"Alice","last_name":"Example","password":"<password>"}]' \
python:3.12-alpine python /s/keycloak-users.py
# {"username": "alice", "id": "<sub>"}
The network is TAIMEN_NETWORK from .env (default taimen_default). Passwords are passed
only through the environment and are never printed.
Permanent or temporary password
By default the script sets the password with temporary: false. With
"temporary": true, Keycloak asks the person to change the password on its own page at
first sign-in — acceptable for harness sign-in, which goes through the Keycloak page.
Registering Keycloak in IAM¶
For IAM to accept Keycloak tokens in federation:exchange, the realm is registered in the
IAM tenant as an identity provider. make bootstrap does not do this — the step is
performed once with the IAM bootstrap token at the host address (administrative IAM paths
are closed at the edge):
IAM=http://127.0.0.1:18010
curl -s -X POST "$IAM/api/v1/tenants/$IAM_TENANT_ID/identity-providers" \
-H "X-IAM-Bootstrap-Token: $IAM_BOOTSTRAP_TOKEN" -H "Content-Type: application/json" \
-d '{
"key": "keycloak",
"issuer": "https://platform.example.com/auth/realms/platform",
"audience": "iam-service",
"lifecycleProfile": "managed"
}'
| Field | Value | Why |
|---|---|---|
key |
keycloak |
the provider name the launcher passes |
issuer |
the realm issuer | exactly the iss in Keycloak tokens |
audience |
iam-service |
tokens carry it thanks to the iam-service-audience mapper |
subjectClaim, externalIdClaim |
sub by default |
the stable Keycloak user id |
lifecycleProfile |
managed |
allows linking an external identity to an existing principal in advance; with read_only, manual linking returns 409 identity_provider_managed |
jwksUri can be omitted: IAM takes it from discovery at the issuer's public address (in the
compose network this address leads to Caddy thanks to the ${TAIMEN_PUBLIC_HOST} alias).
Details of token verification and linking are in
Identity federation.
Onboarding a person¶
The main path is the console, the "People and roles"
section: the people administrator enters the name, e-mail, Keycloak user id (sub, the ID
in the Users section), workspace, roles, and permission profile, and the console runs steps
1, 3, and 4 below in one idempotent flow and issues a first sign-in link. The console does
not create the user in Keycloak itself (step 2) or the personal workplace (step 5).
There are no invitations. If there is no console or a manual path is needed, a person is onboarded in five steps; the order matters — the binding in Control Plane must exist before the person's first request.
flowchart LR
A["1. IAM principal<br/>kind human"] --> B["2. Keycloak<br/>user"]
B --> C["3. external identity<br/>issuer + sub"]
C --> D["4. principal and binding<br/>in Control Plane"]
D --> E["5. harness registry<br/>and PAT"]
-
IAM principal — the IAM bootstrap endpoint:
curl -s -X POST "$IAM/api/v1/tenants/$IAM_TENANT_ID/principals" \ -H "X-IAM-Bootstrap-Token: $IAM_BOOTSTRAP_TOKEN" -H 'Content-Type: application/json' \ -d '{"kind": "human", "displayName": "Alice Example"}'The
idin the response is<iam-principal-id>. -
Keycloak user —
deploy/keycloak/keycloak-users.py(see Utility scripts); theidin the output is the user'ssub. -
Link the external identity to the principal:
curl -s -X POST \ "$IAM/api/v1/tenants/$IAM_TENANT_ID/principals/<iam-principal-id>/external-identities" \ -H "X-IAM-Bootstrap-Token: $IAM_BOOTSTRAP_TOKEN" -H 'Content-Type: application/json' \ -d '{"issuer": "https://platform.example.com/auth/realms/platform", "subject": "<sub>"}'Without this step, the first sign-in creates a new principal (JIT) instead of using the one created in step 1.
-
Principal and binding in Control Plane —
POST /api/v1/principalsandPOST /api/v1/principals/{id}/iam-bindingswith a core administrator token (example requests are in Agent identity; for a person,kind: human). To work in the harness, the binding needstasks.claimandskills.invokeon top of the basic read and write permissions. -
Personal harness — the entry
{"iamPrincipalId": "<iam-principal-id>", "name": …, "email": …}in the people registry (deploy/harness-people.json) andmake bootstrap ARGS="--harness-people deploy/harness-people.json": step 8 issues the person's PAT and updates the launcher registry (see Personal workspace).
After that, the person signs in to the console at ${TAIMEN_PUBLIC_URL}/console/ with the
e-mail and password from step 2; the assistant opens in it as a panel
(${TAIMEN_PUBLIC_URL}/harness/ leads to the same place). They can work with the core from
Claude Code through the MCP plugin with their own PAT (see
MCP plugin).
Operation¶
Changes to the live realm¶
All changes after the first import go through the Admin API (/auth/admin/realms/platform/…),
the deploy/keycloak/ scripts, or the admin console https://platform.example.com/auth/admin/.
The admin console is reachable from outside
The Caddy layout proxies all of /auth/*, including /auth/admin/. In a production
installation, restrict access to /auth/admin/* at the external perimeter (an address
allow-list or a separate internal entry point) and use a strong
KEYCLOAK_ADMIN_PASSWORD.
Disabling a person¶
Disabling the account in Keycloak only blocks new sign-ins to the harness. To close access
completely, disable the principal in IAM (POST …/principals/{id}:disable — this also
revokes its PATs) and revoke the binding in Control Plane (see
Emergency procedures).
Backup¶
Keycloak state (users, passwords, the live realm) lives in the keycloak database of the
keycloak-db service, volume keycloak_db. Back it up with pg_dump of that database (see
Backup).
Common problems¶
| Symptom | Cause | What to do |
|---|---|---|
| A realm template edit did not take effect | the realm is imported only on first start | the Admin API or the deploy/keycloak/ scripts |
The console gets invalid_client |
the live realm has no runtime-console client |
keycloak-runtime-console-client.py |
Keycloak stays in starting for a long time, then OOM |
the JVM does not fit into KEYCLOAK_MEM_LIMIT |
raise the limit, check free memory on the host |
| A hostname error or redirects to an internal address | KC_HOSTNAME is built from TAIMEN_PUBLIC_URL; with KEYCLOAK_HOSTNAME_STRICT=true, requests with a different name are rejected |
check TAIMEN_PUBLIC_URL |
| No access to the admin console | the administrator password was changed in Keycloak but .env is outdated, or vice versa |
KEYCLOAK_ADMIN_PASSWORD applies only on first start; change the password in Keycloak itself |
| The launcher responds "Sign-in denied" | IAM refused federation:exchange: the provider is not registered, the issuer or audience does not match, or the principal is disabled |
Federation errors |
| After sign-in the person works under a new, extra principal | the external identity was not linked in advance — JIT creation kicked in | link the identity to the right principal (step 3), disable the extra principal |