OIDC Authentication with Keycloak¶
This guide configures the AIP gateway to validate JWT tokens issued by Keycloak running inside a Kind cluster. No external accounts or services required — everything runs in the cluster.
This is the recommended setup for local development and the reference starting point before moving to a managed provider (Auth0, Okta, Google).
How it works¶
Agent pod (client_id + client_secret)
│
│ POST /token (client_credentials grant, in-cluster)
▼
Keycloak (keycloak.keycloak.svc.cluster.local:8080)
│
│ access_token JWT (azp = client_id, aud includes "aip-gateway")
▼
AIP Gateway (--oidc-issuer-url=http://keycloak.keycloak.svc.cluster.local:8080/realms/aip)
│
│ fetches /.well-known/openid-configuration at startup
│ verifies JWT signature + aud + identity claim on every request
▼
AgentRequest created
The gateway discovers Keycloak's JWKS endpoint automatically via OIDC
discovery. It extracts the caller identity from the azp (authorized party)
claim — Keycloak sets this to the client_id automatically in every
client_credentials token, with no custom mapper required. The identity is
then matched against --agent-subjects or --reviewer-subjects.
The identity claim is configurable via --oidc-identity-claim (default azp)
so the same gateway works with any OIDC-compliant provider. See the
Provider compatibility section.
Why Keycloak, not Dex? Dex is an identity broker — it federates upstream providers (LDAP, GitHub) but does not issue tokens for M2M flows itself.
client_credentialsgrant requires a first-class IdP that owns identities. Keycloak is that IdP.
Prerequisites¶
- Kind cluster running (
make setup-test-e2eorkind create cluster) kubectlconfigured against it- AIP deployed (
helm install aip-k8s charts/aip-k8s)
Step 1 — Deploy Keycloak inside Kind¶
A minimal manifest for Keycloak in dev mode (H2 in-memory database, no PostgreSQL required) is included in the repository:
Wait for Keycloak to be ready (first image pull takes ~60s):
Verify OIDC discovery from inside the cluster:
kubectl run -it --rm kc-check \
--image=curlimages/curl:latest \
--restart=Never \
--namespace=keycloak \
-- curl -s http://keycloak.keycloak.svc.cluster.local:8080/realms/master/.well-known/openid-configuration
You should see "issuer":"http://keycloak.keycloak.svc.cluster.local:8080/realms/master".
Step 2 — Configure Keycloak¶
Port-forward the admin console to your local machine:
Get an admin token¶
ADMIN_TOKEN=$(curl -s -X POST http://localhost:8090/realms/master/protocol/openid-connect/token \
-d "client_id=admin-cli" \
-d "username=admin" \
-d "password=admin" \
-d "grant_type=password" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
Create the aip realm¶
curl -s -X POST http://localhost:8090/admin/realms \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"realm":"aip","enabled":true}' \
-w "\nHTTP %{http_code}"
# Expected: HTTP 201
Create clients¶
Each M2M identity is a Keycloak client with serviceAccountsEnabled: true.
# Agent identity
curl -s -X POST http://localhost:8090/admin/realms/aip/clients \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"clientId": "aip-agent-1",
"enabled": true,
"publicClient": false,
"serviceAccountsEnabled": true,
"standardFlowEnabled": false,
"directAccessGrantsEnabled": false,
"clientAuthenticatorType": "client-secret",
"secret": "agent-1-secret"
}' -w "\nHTTP %{http_code}"
# Reviewer identity
curl -s -X POST http://localhost:8090/admin/realms/aip/clients \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"clientId": "aip-reviewer-1",
"enabled": true,
"publicClient": false,
"serviceAccountsEnabled": true,
"standardFlowEnabled": false,
"directAccessGrantsEnabled": false,
"clientAuthenticatorType": "client-secret",
"secret": "reviewer-1-secret"
}' -w "\nHTTP %{http_code}"
Add the audience mapper¶
The gateway uses the azp (authorized party) claim for identity — Keycloak
sets this to the client_id automatically in every client_credentials token,
no custom mapper needed. The only mapper required is the audience mapper to
add aud = aip-gateway.
Fetch each client's internal ID:
AGENT_ID=$(curl -s "http://localhost:8090/admin/realms/aip/clients?clientId=aip-agent-1" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
| python3 -c "import sys,json; print(json.load(sys.stdin)[0]['id'])")
REVIEWER_ID=$(curl -s "http://localhost:8090/admin/realms/aip/clients?clientId=aip-reviewer-1" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
| python3 -c "import sys,json; print(json.load(sys.stdin)[0]['id'])")
Add the audience mapper to each client:
for CLIENT_ID in $AGENT_ID $REVIEWER_ID; do
curl -s -X POST "http://localhost:8090/admin/realms/aip/clients/$CLIENT_ID/protocol-mappers/models" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "audience-aip-gateway",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"config": {
"included.custom.audience": "aip-gateway",
"id.token.claim": "true",
"access.token.claim": "true"
}
}' -w "\nHTTP %{http_code}"
done
Step 3 — Configure the AIP gateway¶
| Flag | Value | Purpose |
|---|---|---|
--oidc-issuer-url |
http://keycloak.keycloak.svc.cluster.local:8080/realms/aip |
OIDC discovery and JWKS |
--oidc-audience |
aip-gateway |
Expected aud claim in the token |
--oidc-identity-claim |
azp |
Which claim holds the caller identity (default: azp) |
--agent-subjects |
aip-agent-1 |
Identity values allowed to act as agents |
--reviewer-subjects |
aip-reviewer-1 |
Identity values allowed to approve/deny |
helm upgrade aip-k8s charts/aip-k8s \
--reuse-values \
--set gateway.auth.oidcIssuerURL="http://keycloak.keycloak.svc.cluster.local:8080/realms/aip" \
--set gateway.auth.oidcAudience="aip-gateway" \
--set gateway.auth.agentSubjects="aip-agent-1" \
--set gateway.auth.reviewerSubjects="aip-reviewer-1"
Confirm the gateway completed OIDC discovery:
kubectl logs deployment/aip-k8s-gateway -n aip-k8s-system
# Success: "Starting AIP Demo Gateway on :8080"
# Failure: "OIDC setup failed: oidc provider: ..."
Step 4 — Fetch a token (agent)¶
Tokens must be fetched from inside the cluster so the iss claim matches
the in-cluster URL the gateway was configured with.
kubectl run -it --rm agent-token \
--image=curlimages/curl:latest \
--restart=Never \
-- curl -s -X POST http://keycloak.keycloak.svc.cluster.local:8080/realms/aip/protocol/openid-connect/token \
-d "grant_type=client_credentials" \
-d "client_id=aip-agent-1" \
-d "client_secret=agent-1-secret" \
-d "scope=openid"
Response:
Use the access_token — it is the correct token for API access. Verify its
claims:
TOKEN=eyJhbGciOiJSUzI1NiIs...
echo $TOKEN | cut -d. -f2 | python3 -c "import sys,base64,json; p=sys.stdin.read().strip(); p+='='*(-len(p)%4); print(json.dumps(json.loads(base64.urlsafe_b64decode(p)), indent=2))" | grep -E '"iss"|"azp"|"aud"'
Expected:
"iss": "http://keycloak.keycloak.svc.cluster.local:8080/realms/aip",
"azp": "aip-agent-1",
"aud": ["aip-gateway", "account"],
Step 5 — Send an AgentRequest through the gateway¶
kubectl port-forward svc/aip-k8s-gateway 8080:8080 -n aip-k8s-system &
AGENT_TOKEN=eyJhbGciOiJSUzI1NiIs... # use access_token
curl -s -X POST http://localhost:8080/agent-requests \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"agentIdentity": "aip-agent-1",
"action": "scale-down",
"targetURI": "k8s://prod/default/deployment/payment-api",
"reason": "scheduled maintenance window"
}'
Expected response (HTTP 201):
| Response | Meaning |
|---|---|
| 201 | Request created and resolved |
| 401 | Missing or invalid token |
| 403 | Token valid but identity not in --agent-subjects |
Step 6 — Reviewer approval flow¶
kubectl run -it --rm reviewer-token \
--image=curlimages/curl:latest \
--restart=Never \
-- curl -s -X POST http://keycloak.keycloak.svc.cluster.local:8080/realms/aip/protocol/openid-connect/token \
-d "grant_type=client_credentials" \
-d "client_id=aip-reviewer-1" \
-d "client_secret=reviewer-1-secret" \
-d "scope=openid"
REVIEWER_TOKEN=eyJhbGciOiJSUzI1NiIs...
REQUEST_NAME=aip-agent-1-x7k2m
curl -s -X POST http://localhost:8080/agent-requests/$REQUEST_NAME/approve \
-H "Authorization: Bearer $REVIEWER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"reason": "approved for maintenance window"}'
Reviewers cannot approve their own requests — the gateway enforces self-approval prevention regardless of role.
How an agent fetches tokens in code¶
import (
"encoding/json"
"net/http"
"net/url"
)
func fetchToken(keycloakURL, realm, clientID, clientSecret string) (string, error) {
resp, err := http.PostForm(
keycloakURL+"/realms/"+realm+"/protocol/openid-connect/token",
url.Values{
"grant_type": {"client_credentials"},
"client_id": {clientID},
"client_secret": {clientSecret},
"scope": {"openid"},
})
if err != nil {
return "", err
}
defer resp.Body.Close()
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
return result["access_token"].(string), nil
}
Store client_secret in a Kubernetes Secret — never hardcode it in agent source.
Adding more agents¶
Add the client via the admin API (same steps as Step 2), then extend
gateway.auth.agentSubjects:
helm upgrade aip-k8s charts/aip-k8s \
--reuse-values \
--set gateway.auth.agentSubjects="aip-agent-1,aip-agent-2"
kubectl rollout restart deployment/aip-k8s-gateway -n aip-k8s-system
Troubleshooting¶
OIDC setup failed: oidc provider: ... on gateway startup
Keycloak is not reachable or the realm does not exist:
kubectl get pods -n keycloak
kubectl logs deployment/keycloak -n keycloak | tail -20
kubectl run -it --rm dns-check --image=curlimages/curl:latest --restart=Never -- \
curl -s http://keycloak.keycloak.svc.cluster.local:8080/realms/aip/.well-known/openid-configuration
Gateway returns 401 invalid token
The token's iss does not match --oidc-issuer-url. Tokens fetched via
port-forward carry the localhost URL. Fetch from inside the cluster instead.
echo $AGENT_TOKEN | cut -d. -f2 | python3 -c "import sys,base64,json; p=sys.stdin.read().strip(); p+='='*(-len(p)%4); print(json.dumps(json.loads(base64.urlsafe_b64decode(p)), indent=2))" | grep '"iss"'
# Must equal: "http://keycloak.keycloak.svc.cluster.local:8080/realms/aip"
Gateway returns 403 agent role required
The azp claim does not match any value in --agent-subjects. Decode the token:
echo $AGENT_TOKEN | cut -d. -f2 | python3 -c "import sys,base64,json; p=sys.stdin.read().strip(); p+='='*(-len(p)%4); print(json.dumps(json.loads(base64.urlsafe_b64decode(p)), indent=2))" | grep '"azp"'
# Must equal the value in --agent-subjects, e.g. "aip-agent-1"
Gateway returns 401 aud claim does not match
The audience-aip-gateway mapper was not applied. The aud array must
include aip-gateway.
Production considerations¶
- Secrets management — replace static
secret:values with Kubernetes Secrets or a vault integration. - TLS — enable TLS on Keycloak and use
https://in--oidc-issuer-url. Plain HTTP is acceptable only inside a cluster with NetworkPolicy isolating pod-to-pod traffic. - Token expiry — Keycloak defaults to 5 minutes. Agents must refresh tokens before expiry.
- Keycloak storage —
start-devuses H2 in-memory storage, lost on restart. Use production mode with an external database for persistence. - Realm isolation — use a dedicated realm (not
master) for AIP clients.
Provider compatibility¶
The gateway works with any OIDC-compliant provider. The only moving parts are
which claim carries the identity (--oidc-identity-claim) and what value
appears in the token's aud claim (--oidc-audience). These vary by provider.
| Provider | --oidc-identity-claim |
aud in token |
Notes |
|---|---|---|---|
| Keycloak | azp (default) |
Custom audience via mapper | Validated ✓ |
| Okta | sub |
Authorization server URL by default; configure custom audience in Okta Authorization Server | Expected to work |
| Auth0 | sub |
The API identifier registered in Auth0 (set this as --oidc-audience) |
Expected to work |
| Azure AD | appid |
Application ID URI (e.g. api://your-app-id) |
Expected to work; appid is non-standard but always present |
| Ping Identity | sub or client_id |
Configurable per-application | Check your Ping policy |
| Not applicable | Google's M2M mechanism differs | See note below |
Google service accounts: Google does not support the standard
client_credentialsgrant. M2M tokens are obtained by signing a JWT with a service account key and exchanging it athttps://oauth2.googleapis.com/token. The resulting token is opaque (not a JWT) for Google APIs. To protect a custom API with Google identity, use Google Identity Platform which issues OIDC-compliant JWTs — set--oidc-identity-claim=emailto use the service account email as the identity.
Okta example¶
helm upgrade aip-k8s charts/aip-k8s \
--reuse-values \
--set gateway.auth.oidcIssuerURL="https://your-org.okta.com/oauth2/default" \
--set gateway.auth.oidcAudience="api://aip-gateway" \
--set gateway.auth.oidcIdentityClaim="sub" \
--set gateway.auth.agentSubjects="0oa1b2c3d4e5f6g7h8i9" \
--set gateway.auth.reviewerSubjects="0oa9i8h7g6f5e4d3c2b1"
The sub for Okta service apps is the application's client ID. Find it in the
Okta Admin Console under Applications → your app → General → Client ID.
Auth0 example¶
helm upgrade aip-k8s charts/aip-k8s \
--reuse-values \
--set gateway.auth.oidcIssuerURL="https://your-tenant.auth0.com/" \
--set gateway.auth.oidcAudience="https://aip-gateway.your-org.com" \
--set gateway.auth.oidcIdentityClaim="sub" \
--set gateway.auth.agentSubjects="YOUR_CLIENT_ID@clients" \
--set gateway.auth.reviewerSubjects="YOUR_REVIEWER_CLIENT_ID@clients"
Auth0 sets sub to <client_id>@clients for machine-to-machine applications.
The aud must match the API identifier you registered in Auth0.
Azure AD example¶
helm upgrade aip-k8s charts/aip-k8s \
--reuse-values \
--set gateway.auth.oidcIssuerURL="https://login.microsoftonline.com/YOUR_TENANT_ID/v2.0" \
--set gateway.auth.oidcAudience="api://YOUR_APP_ID" \
--set gateway.auth.oidcIdentityClaim="appid" \
--set gateway.auth.agentSubjects="YOUR_AGENT_APP_ID" \
--set gateway.auth.reviewerSubjects="YOUR_REVIEWER_APP_ID"
Azure AD uses appid (the application's client ID) rather than azp or sub
for service principals. The aud must match your application's ID URI.
Diagnosing token claims for an unfamiliar provider¶
If you are unsure which claim your IdP uses for client identity, decode a token and inspect the claims before configuring the gateway:
TOKEN=<your access_token>
echo $TOKEN | cut -d. -f2 | python3 -c "import sys,base64,json; p=sys.stdin.read().strip(); p+='='*(-len(p)%4); print(json.dumps(json.loads(base64.urlsafe_b64decode(p)), indent=2))"
Look for a claim that consistently equals the client application's registered
name or ID. Set --oidc-identity-claim to that claim name and
--agent-subjects to its value.
Next steps¶
- Auth0 OIDC setup — managed provider, same gateway flags (guide coming soon).
- SafetyPolicy reference — restrict which agents can request which actions.