Keycloak as the authorization server
This recipe configures Keycloak (tested against 26.x) as the authorization server for Kozou’s OAuth resource-server mode. Keycloak is the natural choice when you self-host identity or bridge a corporate directory.
The scope-and-mapper core of this recipe exists in runnable form: the Kozou repository’s end-to-end test suite drives a real Keycloak against keycloak-realm-e2e.json — a worked example of the client scopes, mappers, and role attributes (Steps 1, 2, and 4) you can diff your realm against. It is a test realm, not a full deployment: it authenticates with direct-access grants rather than the authorization-code flow, leaves dynamic client registration off (Step 5 is not represented in it), and imports with sslRequired: none for a containerized test network — leave TLS required in any real realm.
The one rule everything hinges on
Section titled “The one rule everything hinges on”Put the audience, role, and username mappers on the mcp:describe / mcp:execute client scopes themselves — not on a separate “roles” scope, not on realm defaults.
The reason is a chain: Kozou advertises its scopes in the protected-resource metadata → MCP clients echo that list into their dynamic client registration → Keycloak grants a dynamically-registered client only the scopes named in its registration (plus an internally-added basic). Realm-default scopes are not applied to DCR clients. So the mcp:* scopes are the only scopes guaranteed to reach the client — and any claim that is not mapped on them never reaches the token.
If the role claim rides a separate scope, tokens arrive at Kozou without a role, and Kozou rejects them (there is no default role on this surface). Nothing errors on the Keycloak side; enforcement just never works. This placement rule is the single most common failure mode.
Step 1: create the client scopes
Section titled “Step 1: create the client scopes”Create two client scopes, mcp:describe and mcp:execute (Admin console: Client scopes → Create; or import — see the fixture’s clientScopes array). On each of them, add three protocol mappers:
| Mapper | Type | Configuration |
|---|---|---|
| Audience | Audience (oidc-audience-mapper) | Included custom audience = your resource URI, e.g. https://mcp.example.com/mcp. Add to access token. |
| Role | User attribute (oidc-usermodel-attribute-mapper) | User attribute = app_role, token claim name = role, type String, single-valued. Add to access token. |
| Username | User property (oidc-usermodel-property-mapper) | Property username → claim preferred_username (optional, display only — Kozou’s enforcement reads the role claim). |
Two of these deserve a word:
- The audience mapper is not optional. Keycloak does not implement RFC 8707 resource indicators — a client sending
resource=gets it silently ignored. The audience mapper is the supported way to stamp the resource URI intoaud, and Kozou strictly requires it to match. - The role mapper reads a user attribute (
app_rolehere) whose value is the PostgreSQL role name. This is the same scalar-role-claim model as enterprise SSO, relocated to where DCR clients can actually receive it.
Mark both scopes as included in the token scope, and give them consent-screen text — dynamically-registered clients require consent, so your users will read it.
Step 2: make the scopes requestable
Section titled “Step 2: make the scopes requestable”Add mcp:describe and mcp:execute to the realm’s default optional client scopes (Client scopes → realm settings, or defaultOptionalClientScopes in an import). Optional is enough: clients request them by name. Without this, Keycloak’s registration policy refuses DCR requests that name them (“Requested scope not trusted”).
Step 3: advertise offline_access
Section titled “Step 3: advertise offline_access”In kozou.config.yaml, set:
extraScopesSupported: [offline_access]Clients that echo Kozou’s advertised scope list into their registration (Claude Code does) will then request offline_access, which Keycloak requires for refresh tokens. Without it, the registered client never holds the scope, and the authorization request — which asks for offline_access — fails wholesale with invalid_scope. The advertised list is “what clients should request”, not “what Kozou checks”; this is the canonical example.
Step 4: assign roles to users
Section titled “Step 4: assign roles to users”Set each user’s app_role attribute to the PostgreSQL role they should assume (Users → Attributes, or the users[].attributes block in the fixture):
"attributes": { "app_role": ["app_viewer"] }This is an explicit admin action, by design. A user without the attribute authenticates fine but is rejected by Kozou — including every first-time user arriving through a federated IdP (Google, LDAP). Make role assignment part of your onboarding flow rather than working around it with a default.
Also confirm users hold the offline_access realm role — members of the realm’s default-roles-<realm> composite have it. Users created through the admin console do; users created by a realm import may not (imports don’t add the composite automatically — set realmRoles explicitly on imported users), and Keycloak then refuses to issue offline tokens with “Offline tokens not allowed for the user or client”.
Step 5: allow dynamic client registration
Section titled “Step 5: allow dynamic client registration”Hosted MCP clients register themselves via anonymous DCR, which Keycloak’s Trusted Hosts policy refuses by default (403). Two ways to open it:
- Match on redirect URIs (recommended): enable client-uris-must-match and list the client callback hosts (for hosted clients, the claude.ai / ChatGPT callback hosts; for Claude Code, developers’
localhost) as trusted hosts. - Match on the requesting host: verifies the connecting IP’s reverse DNS — behind Docker or a tunnel, the connection arrives from the gateway or tunnel daemon, so this mode usually cannot work there.
One of the two checks must pass. If you would rather not open anonymous registration at all, pre-register a confidential client per tenant instead and skip DCR — see the deployment guide for why that is often the better hosted posture anyway.
Note that dynamically-registered clients are created with consent required — the first authorization shows a consent screen listing your scopes’ consent text (persistent, so once per user).
If you import a minimal realm
Section titled “If you import a minimal realm”Realm imports don’t auto-create Keycloak’s built-in client scopes. Two resulting traps, both visible in the fixture:
- DCR silently depends on a
basicscope: Keycloak addsbasicto every dynamically-registered client, and if the realm has no scope by that name, registration itself fails (“Requested scope ‘basic’ not trusted”) — confusingly, for a scope the client never asked for. Define abasicscope (the fixture gives it thesubandauth_timemappers) and put it in the realm’s default default-client-scopes. subis no longer automatic: since Keycloak 25 thesubclaim rides the opt-inbasicscope. The fixture’sbasicincludes an explicit sub mapper so tokens are self-contained.offline_accessmay not be a trusted DCR scope: a console realm shipsoffline_accessas a default optional client scope, so a dynamically-registered client can request it; a minimal import may omit it. Kozou advertisesoffline_access(Step 3) and clients request it, but Keycloak grants a DCR client only the scopes its registration policy trusts — so registration silently stripsoffline_access, and the authorization request then fails withinvalid_scope. Keycloak’s error compounds the confusion by listing every requested scope (mcp:describe mcp:execute offline_access) as invalid, not just the missing one. Addoffline_accessto the realm’s default optional client scopes — the same place as themcp:*scopes in Step 2.
A realm you created through the admin console has all of this already; only imports hit it.
Point Kozou at the realm
Section titled “Point Kozou at the realm”server: mcp: http: auth: resource: https://mcp.example.com/mcp authorizationServers: - https://keycloak.example.com/realms/myrealm jwt: jwksUri: https://keycloak.example.com/realms/myrealm/protocol/openid-connect/certs algorithms: [RS256] roleClaim: role allowedRoles: [app_viewer, app_editor] extraScopesSupported: [offline_access]Keycloak’s issuer is the realm URL without a trailing slash — exactly the authorizationServers entry above, which Kozou uses as the expected iss.
Verify it
Section titled “Verify it”curl https://mcp.example.com/.well-known/oauth-protected-resource— you should see your realm inauthorization_serversandmcp:describe mcp:execute offline_accessinscopes_supported.- Connect a real client (Claude Code is the quickest:
claude mcp add --transport http kozou https://mcp.example.com/mcp, then/mcp→ authenticate). The browser flow should show your consent text. - Decode the access token (any JWT inspector):
audmust be your resource URI,issyour realm URL, androlethe PostgreSQL role. Ifroleis missing, the mapper is not on themcp:*scopes — the one rule. - Negative check: a user without
app_rolemust be rejected, not admitted with some default.
Where to next
Section titled “Where to next”- Remote MCP with OAuth — the mode itself, the strictness rules, and client-by-client behavior.
- Auth0 as the authorization server — the SaaS alternative; no audience mapper needed, but a per-client approval wall.
- The worked example realm — the fixture the E2E suite runs against.