Skip to content

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.

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.

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:

MapperTypeConfiguration
AudienceAudience (oidc-audience-mapper)Included custom audience = your resource URI, e.g. https://mcp.example.com/mcp. Add to access token.
RoleUser attribute (oidc-usermodel-attribute-mapper)User attribute = app_role, token claim name = role, type String, single-valued. Add to access token.
UsernameUser 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 into aud, and Kozou strictly requires it to match.
  • The role mapper reads a user attribute (app_role here) 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.

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”).

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.

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”.

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).

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 basic scope: Keycloak adds basic to 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 a basic scope (the fixture gives it the sub and auth_time mappers) and put it in the realm’s default default-client-scopes.
  • sub is no longer automatic: since Keycloak 25 the sub claim rides the opt-in basic scope. The fixture’s basic includes an explicit sub mapper so tokens are self-contained.
  • offline_access may not be a trusted DCR scope: a console realm ships offline_access as a default optional client scope, so a dynamically-registered client can request it; a minimal import may omit it. Kozou advertises offline_access (Step 3) and clients request it, but Keycloak grants a DCR client only the scopes its registration policy trusts — so registration silently strips offline_access, and the authorization request then fails with invalid_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. Add offline_access to the realm’s default optional client scopes — the same place as the mcp:* scopes in Step 2.

A realm you created through the admin console has all of this already; only imports hit it.

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.

  1. curl https://mcp.example.com/.well-known/oauth-protected-resource — you should see your realm in authorization_servers and mcp:describe mcp:execute offline_access in scopes_supported.
  2. 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.
  3. Decode the access token (any JWT inspector): aud must be your resource URI, iss your realm URL, and role the PostgreSQL role. If role is missing, the mapper is not on the mcp:* scopes — the one rule.
  4. Negative check: a user without app_role must be rejected, not admitted with some default.