Identity & OIDC¶
Every tatara service-to-service call is an OIDC bearer token, validated independently by the receiver against a per-service expected aud claim. There is no shared session state and no per-user identity in the machine-to-machine plane. The load-bearing detail is not the JWT mechanics (standard OIDC discovery + RS256, covered in one line below) but the client topology: which client mints which audience, and why per-task authorization cannot key on OIDC identity.
Keycloak realm¶
The platform uses Keycloak as the identity provider. The homelab deployment runs the clients in the master realm (OIDC_ISSUER is operator-injected, so the realm is a deployment config, not a hard-coded value). Putting application clients in master is a homelab shortcut; a production deployment should use a dedicated realm. Nothing in the code assumes master.
Issuer: https://<keycloak-host>/realms/<realm>
Clients and audiences¶
Five Keycloak clients carry the service plane. What matters is the direction each token flows and the audience it must carry.
| Client ID | Type | Grant | Mints tokens for (aud) | Used by |
|---|---|---|---|---|
tatara-operator | confidential | client_credentials | tatara-operator (inbound validation) | operator: validates inbound calls from agent pods; outbound SCM/API |
tatara-memory | confidential (resource server) | - | tatara-memory | memory REST API: validates inbound tokens |
tatara-cli | confidential | client_credentials | tatara-memory (+ operator/chat via mappers) | the in-pod MCP server, outbound to memory/operator/chat |
tatara-claude-code-wrapper | confidential | client_credentials | tatara-claude-code-wrapper | the operator, calling the wrapper REST API inbound |
tatara-chat | confidential | client_credentials | tatara-chat | chat service: validates inbound tokens |
Two subtleties the topology hides if you read it as a flat list:
- The in-pod identity is a confidential
tatara-cliclient, not the wrapper client. The in-pod MCP server (tatara-cli) calls memory, operator, and chat using a confidentialclient_credentialsgrant: it sends a client id + secret via HTTP Basic auth (Keycloak'sclient_secret_basic;client_secret_post401s). Those credentials areCLI_OIDC_CLIENT_ID/CLI_OIDC_CLIENT_SECRET, injected from thetatara-cli-oidcSecret. This is not a public client - a public client cannot mint aclient_credentialstoken. Do not confuse this with the public device-flow client a human developer uses fortatara loginon a workstation; that is a separate, public concern and never the identity the pods use outbound. - The
tatara-claude-code-wrapperclient is an inbound audience, not an outbound identity. The operator holds atatara-claude-code-wrapper-audience token and uses it to call the wrapper's REST API. The pod'sOIDC_AUDIENCE=tatara-claude-code-wrapperenv var is only the audience the wrapper validates on those inbound operator calls. Agent pods never present the wrapper client outbound to memory.
Audience mappers are mandatory¶
A client_credentials token from Keycloak carries no aud claim by default. Every resource server here rejects a token whose aud does not contain its own audience, so each client that calls a resource server MUST have an audience (protocol) mapper adding the target audience to aud. In particular the tatara-cli client (or its tatara scope) needs a mapper adding tatara-memory - without it, memory returns 401 for every agent call. This is the single most common reproduction failure when standing up identity from scratch.
Token validation¶
Each service validates every request the standard way: discover JWKS via /.well-known/openid-configuration, verify the RS256 signature, iss, exp, and that aud contains the service's own audience. Verification uses coreos/go-oidc, which fetches and caches JWKS automatically, so Keycloak key rotation needs no restart or manual key distribution.
Agent pod token flow¶
sequenceDiagram
participant OP as tatara-operator
participant POD as agent pod (tatara-cli MCP)
participant KC as Keycloak
participant MEM as tatara-memory
participant WRP as wrapper REST API
OP->>POD: Inject CLI_OIDC_CLIENT_ID + CLI_OIDC_CLIENT_SECRET (from tatara-cli-oidc)
POD->>KC: POST /token (client_credentials, HTTP Basic)<br/>aud=tatara-memory via mapper
KC-->>POD: access_token
POD->>MEM: GET /query<br/>Authorization: Bearer token
MEM->>MEM: Verify token (aud=tatara-memory, iss, exp)
MEM-->>POD: 200 OK
OP->>WRP: POST /v1/messages (turn submission)<br/>aud=tatara-claude-code-wrapper
WRP->>WRP: Verify token (aud=tatara-claude-code-wrapper)
WRP-->>OP: 202 {turnId} Note the two directions: the pod is the client for memory/operator/chat (confidential tatara-cli creds), and the operator is the client for the wrapper. These are different credentials for different audiences.
Agent pod identity and why authz cannot key on it¶
All agent pods authenticate to memory/operator/chat with the same confidential tatara-cli client credentials (from tatara-cli-oidc). The sub claim is that one client's service-account UUID and is identical across every pod, regardless of Task, Project, or kind. There is therefore no OIDC-identity distinction between a brainstorm pod and an incident pod.
Per-task authorization consequently cannot rely on the token. It relies instead on:
- Tool-surface gating in
tatara-cli, keyed on the operator-setTATARA_TOOL_PROFILE(see Data & Control Flow). This is the real authz boundary. An unknown profile fails closed to a 4-toolalwaysOnset. - Task context in the pod env (
TATARA_TASK,TATARA_PROJECT) plus the operator REST API validating that a request's task scope matches the pod it came from.
Keycloak Terraform configuration¶
The homelab's five clients live in infra/terraform/keycloak/tatara_clients.tf in the maintainer's private infra repo - that path is not portable and is referenced only as the authoritative source, not something to copy verbatim. The example below is the shape you actually need, including the audience mapper that the toy two-client snippets omit (and without which memory rejects every agent token):
resource "keycloak_openid_client" "tatara_cli" {
realm_id = var.realm_id
client_id = "tatara-cli"
access_type = "CONFIDENTIAL" # pods use client_credentials + secret
service_accounts_enabled = true # enables client_credentials
}
# MANDATORY: without this, tatara-cli tokens have no `aud` and memory returns 401.
resource "keycloak_openid_audience_protocol_mapper" "cli_to_memory" {
realm_id = var.realm_id
client_id = keycloak_openid_client.tatara_cli.id
name = "aud-tatara-memory"
included_client_audience = "tatara-memory"
}
resource "keycloak_openid_client" "tatara_memory" {
realm_id = var.realm_id
client_id = "tatara-memory"
access_type = "CONFIDENTIAL" # resource server
}
Repeat the client-plus-audience-mapper pair for tatara-operator, tatara-chat, and the tatara-claude-code-wrapper inbound audience. Every resource server the CLI client calls needs its audience added to the CLI client's aud.
Security notes¶
- Tokens are short-lived (Keycloak default: 5 minutes). The operator and agent pods use the client-credentials grant, which mints a fresh token as needed.
- No long-lived tokens are stored in the cluster outside the client-secret Kubernetes Secrets (
tatara-cli-oidcand the per-service client secrets). - All client secrets are stored SOPS-encrypted in
tatara-helmfilevalues files, never in plaintext. - The in-pod
tatara-cliclient is confidential (it holds a secret intatara-cli-oidc). Only the developertatara logindevice-flow client is public. - The bot PAT (GitHub/GitLab) is a separate credential in the
scmSecretRefSecret, distinct from every OIDC client secret, and is what agents use to act on the SCM - not an OIDC token.