What you'll get
- A Keycloak StatefulSet in namespace
kagent, reachable in-cluster athttp://keycloak.kagent.svc.cluster.local:8080. - One realm —
solo— with three groups:field-fte,field-trial,field-admin. - One OIDC client —
kagent— with the OAuth2 password grant enabled, an audience mapper that pinsaud="kagent"on every access token, and a group-membership mapper that emits"groups": [...]. - Three preconfigured users so demos can mint tokens with a single
curlcall.
alice
- Group
field-fte- Password
alice- Use for
- full-toolset, full-access demo paths.
bob
- Group
field-trial- Password
bob- Use for
- reduced-toolset / deny-by-default demo paths.
carol
- Group
field-admin- Password
carol- Use for
- cluster-wide / privileged demo paths.
Install
1. Apply the realm-import ConfigMap
The realm export below pre-seeds the realm, client, groups and users.
We mount it as a ConfigMap and tell Keycloak to --import-realm
on first boot.
json yaml/realm-solo.json
{
"realm": "solo",
"enabled": true,
"groups": [
{ "name": "field-fte" },
{ "name": "field-trial" },
{ "name": "field-admin" }
],
"users": [
{ "username": "alice", "enabled": true,
"credentials": [ { "type": "password", "value": "alice", "temporary": false } ],
"groups": ["field-fte"] },
{ "username": "bob", "enabled": true,
"credentials": [ { "type": "password", "value": "bob", "temporary": false } ],
"groups": ["field-trial"] },
{ "username": "carol", "enabled": true,
"credentials": [ { "type": "password", "value": "carol", "temporary": false } ],
"groups": ["field-admin"] }
],
"clients": [
{
"clientId": "kagent",
"publicClient": true,
"directAccessGrantsEnabled": true,
"protocolMappers": [
{ "name": "audience-kagent",
"protocolMapper": "oidc-audience-mapper",
"config": { "included.client.audience": "kagent",
"access.token.claim": "true" } },
{ "name": "groups",
"protocolMapper": "oidc-group-membership-mapper",
"config": { "claim.name": "groups", "full.path": "false",
"access.token.claim": "true",
"id.token.claim": "true",
"userinfo.token.claim": "true" } }
]
}
]
}
kubectl create namespace kagent --dry-run=client -o yaml | kubectl apply -f -
kubectl -n kagent create configmap keycloak-realm-import \
--from-file=realm-solo.json=yaml/realm-solo.json \
--dry-run=client -o yaml | kubectl apply -f -
2. Install the bitnami chart
yaml yaml/values.yaml
auth:
adminUser: admin
adminPassword: admin
production: false
proxy: edge
resources:
requests: { cpu: 200m, memory: 512Mi }
limits: { cpu: 1, memory: 1Gi }
extraStartupArgs: "--import-realm"
extraVolumes:
- name: realm-import
configMap:
name: keycloak-realm-import
extraVolumeMounts:
- name: realm-import
mountPath: /opt/bitnami/keycloak/data/import
readOnly: true
replicaCount: 1
helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo update
helm upgrade --install keycloak bitnami/keycloak \
--namespace kagent \
-f yaml/values.yaml
kubectl -n kagent rollout status statefulset/keycloak --timeout=180s
3. Reach Keycloak from your laptop
Local port 18080 is used instead of 8080 because tools like OrbStack,
Docker Desktop, and many Java app servers default to 8080 and quietly
win the bind race against kubectl port-forward. Pick any
free local port if 18080 is also taken on your laptop.
kubectl -n kagent port-forward svc/keycloak 18080:80 &
export KC_HOST="http://127.0.0.1:18080"
export KC_REALM="solo"
export KC_CLIENT="kagent"
Mint a token
The kagent client has the password grant enabled, so
minting an access token is one curl call. Swap
username/password for whichever user you want to act as.
# alice — groups=["field-fte"]
ALICE_JWT=$(curl -s -X POST \
"${KC_HOST}/realms/${KC_REALM}/protocol/openid-connect/token" \
-d "grant_type=password" \
-d "client_id=${KC_CLIENT}" \
-d "username=alice" \
-d "password=alice" \
-d "scope=openid" \
| jq -r .access_token)
# bob — groups=["field-trial"]
BOB_JWT=$(curl -s -X POST \
"${KC_HOST}/realms/${KC_REALM}/protocol/openid-connect/token" \
-d "grant_type=password" \
-d "client_id=${KC_CLIENT}" \
-d "username=bob" \
-d "password=bob" \
-d "scope=openid" \
| jq -r .access_token)
# carol — groups=["field-admin"]
CAROL_JWT=$(curl -s -X POST \
"${KC_HOST}/realms/${KC_REALM}/protocol/openid-connect/token" \
-d "grant_type=password" \
-d "client_id=${KC_CLIENT}" \
-d "username=carol" \
-d "password=carol" \
-d "scope=openid" \
| jq -r .access_token)
Decode and verify the token
Decode the JWT payload to confirm the issuer, audience and groups claims arrived on the token. Every lab that consumes this token validates these three fields.
echo "${ALICE_JWT}" | cut -d. -f2 | base64 -d 2>/dev/null | jq '.iss, .aud, .groups, .preferred_username'
You should see:
"http://keycloak.kagent.svc.cluster.local:8080/realms/solo"
"kagent"
[ "field-fte" ]
"alice"
Note that the issuer claim uses the in-cluster service DNS — that's
because Keycloak's frontend URL is whatever the realm was first
imported with. If a downstream verifier needs a different issuer
(e.g. https://keycloak.example.com/realms/solo from
outside the cluster), set the realm's frontendUrl
attribute in the import JSON or via the admin console.
Fetch the JWKS
Some policies need the realm's public keys pasted inline — for
example, kagent's AccessPolicy.jwksKey.inline only
supports inline JWKS, not a remote URL. Fetch it:
curl -sS "${KC_HOST}/realms/${KC_REALM}/protocol/openid-connect/certs" \
| tee /tmp/keycloak-jwks.json | jq
Teardown
helm -n kagent uninstall keycloak
kubectl -n kagent delete configmap keycloak-realm-import
kubectl -n kagent delete pvc -l app.kubernetes.io/instance=keycloak
# Optionally also drop the namespace if nothing else lives there:
# kubectl delete namespace kagent
Labs that use this
- One Agent, Two Runtimes — kagent + AgentCore — kagent
AccessPolicykeyed on thegroupsclaim. - JWT claims into headers — agentgateway projects
groupsinto a downstream HTTP header. - OIDC On-Behalf-Of — kagent waypoint exchanges this token for a workload identity.
- RFC 8693 token exchange — same token, different audience.