コンテンツにスキップ

Keycloak を authorization server にする

このレシピは Keycloak (26.x で検証) を Kozou の OAuth resource-server モードの authorization server として設定します。identity を self-host する場合や社内ディレクトリをブリッジする場合、Keycloak は自然な選択です。

このレシピの核 — scope と mapper — は実行可能な形で存在します。Kozou リポジトリの end-to-end テストスイートは keycloak-realm-e2e.json で実 Keycloak を駆動しています — client scope・mapper・role attribute (Step 1・2・4) について、自分の realm と diff できる実例です。ただしこれはテスト用 realm であり、完全なデプロイ例ではありません: authorization-code フローではなく direct-access grant で認証し、dynamic client registration は無効のまま (Step 5 は含まれません)、コンテナ化されたテストネットワーク用に sslRequired: none で import されます — 実 realm では TLS 必須のままにしてください。

すべてが掛かっている 1 つのルール

Section titled “すべてが掛かっている 1 つのルール”

audience・role・username の mapper は mcp:describe / mcp:execute の client scope 自体に載せてください — 別建ての「roles」scope でも、realm の既定でもなく。

理由は連鎖にあります: Kozou は protected-resource metadata で scope を広告する → MCP クライアントはそのリストを dynamic client registration に echo する → Keycloak は動的登録されたクライアントに登録で名指しされた scope だけ (+内部付与の basic) を与える。realm 既定の scope は DCR クライアントには適用されません。つまりクライアントに確実に届く scope は mcp:* だけであり、そこに map されていない claim はトークンに決して現れません。

role claim を別の scope に載せると、トークンは role なしで Kozou に到達し、Kozou はそれを拒否します (この面に default role はありません)。Keycloak 側では何もエラーになりません。施行がただ成立しないだけです。この配置ルールの違反が、最も典型的な失敗パターンです。

mcp:describemcp:execute の 2 つの client scope を作成します (Admin console: Client scopes → Create。または import — fixture の clientScopes 配列参照)。それぞれに 3 つの protocol mapper を追加します。

Mapper種類設定
AudienceAudience (oidc-audience-mapper)Included custom audience = あなたの resource URI (例 https://mcp.example.com/mcp)。access token に追加。
RoleUser attribute (oidc-usermodel-attribute-mapper)User attribute = app_roletoken claim name = role、String、単一値。access token に追加。
UsernameUser property (oidc-usermodel-property-mapper)property username → claim preferred_username (任意・表示用のみ — Kozou の施行は role claim を読みます)。

うち 2 つは補足しておきます。

  • audience mapper は省略できません。 Keycloak は RFC 8707 resource indicators を実装していません — クライアントが送る resource=黙って無視されます。resource URI を aud に刻む正規の手段が audience mapper であり、Kozou は aud の一致を厳密に要求します。
  • role mapper は user attribute (ここでは app_role) を読みます。値は PostgreSQL ロール名です。enterprise SSO と同じスカラー role claim モデルを、DCR クライアントが実際に受け取れる場所に移したものです。

両 scope とも token scope に含める設定にし、consent 画面のテキストを与えてください — 動的登録されたクライアントは consent が必須なので、ユーザーがそれを読むことになります。

mcp:describemcp:execute を realm の default optional client scopes に追加します (Client scopes → realm 設定、import なら defaultOptionalClientScopes)。optional で十分です: クライアントは名前で要求します。これがないと、Keycloak の registration policy がそれらを名指しする DCR リクエストを拒否します (「Requested scope not trusted」)。

kozou.config.yaml に設定します:

extraScopesSupported: [offline_access]

Kozou の広告 scope リストを登録に echo するクライアント (Claude Code がそうです) は offline_access を要求するようになります。Keycloak は refresh トークンにこれを要求します。これがないと登録済みクライアントはこの scope を一切持たず、offline_access 込みの認可リクエストが invalid_scope で丸ごと失敗します。広告リストは「クライアントが要求すべきもの」であって「Kozou が検証するもの」ではない — その典型例です。

Step 4: ユーザーにロールを割り当てる

Section titled “Step 4: ユーザーにロールを割り当てる”

各ユーザーの app_role attribute に、引き受けるべき PostgreSQL ロールを設定します (Users → Attributes、fixture では users[].attributes ブロック):

"attributes": { "app_role": ["app_viewer"] }

これは設計上、明示的な管理者アクションです。attribute のないユーザーは認証は通りますが Kozou に拒否されます — federated IdP (Google・LDAP) 経由で到達する初回ユーザー全員も含めて。既定値で回避するのではなく、ロール割り当てをオンボーディングのフローに組み込んでください。

あわせて、ユーザーが realm role offline_access を実効保有していることを確認してください — realm の default-roles-<realm> composite のメンバーは持っています。admin console で作ったユーザーは持ちますが、realm import で作ったユーザーは持たないことがあります (import は composite を自動付与しません — import するユーザーには realmRoles を明示してください)。その場合 Keycloak は「Offline tokens not allowed for the user or client」で offline トークンの発行を拒みます。

Step 5: dynamic client registration を許可する

Section titled “Step 5: dynamic client registration を許可する”

hosted な MCP クライアントは匿名 DCR で自己登録しますが、Keycloak の Trusted Hosts policy は既定でこれを拒否します (403)。開放は 2 通り:

  • redirect URI で照合 (推奨): client-uris-must-match を有効にし、クライアントの callback ホスト (hosted クライアントなら claude.ai / ChatGPT の callback ホスト、Claude Code なら開発者の localhost) を trusted hosts に列挙します。
  • 接続元ホストで照合: 接続元 IP の逆引きを検証します — Docker やトンネルの背後では接続は gateway やトンネルデーモンから来るため、通常この方式は成立しません。

2 つの検査のどちらかは必須です。匿名登録自体を開けたくない場合は、DCR を使わずテナントごとに confidential client を事前登録してください — それが hosted ではしばしば良い姿勢である理由はデプロイガイドを参照。

なお動的登録されたクライアントは consent 必須で作られます — 初回の認可では、scope に設定した consent テキストが並ぶ画面が表示されます (persistent なのでユーザーごとに 1 回)。

realm import は Keycloak の built-in client scope を自動生成しません。結果として罠が 2 つあり、どちらも fixture に対処が入っています。

  • DCR は暗黙に basic scope に依存します: Keycloak は動的登録された全クライアントに basic を追加します。realm にその名の scope がないと、登録自体が失敗します (「Requested scope ‘basic’ not trusted」) — クライアントが要求してもいない scope で、です。basic scope を定義し (fixture は subauth_time の mapper を与えています)、realm の default default-client-scopes に入れてください。
  • sub はもう自動ではありません: Keycloak 25 以降、sub claim は opt-in の basic scope に載ります。fixture の basic は明示の sub mapper を含み、トークンを自己完結にしています。
  • offline_access が DCR の信頼 scope に入っていないことがあります: admin console で作った realm は offline_access を default optional client scope として持つため動的登録クライアントも要求できますが、最小 import では欠けることがあります。Kozou は offline_access を広告し (Step 3)、クライアントも要求しますが、Keycloak は DCR クライアントに登録ポリシーが信頼する scope しか与えません — よって登録時に offline_access が黙って剥がれ、認可リクエストが invalid_scope で失敗します。しかも Keycloak のエラーは要求 scope をすべて (mcp:describe mcp:execute offline_access)「Invalid」と列挙するため、mcp:* 側の問題に見えて紛らわしいです。mcp:* scope と同じ場所 (Step 2) で、realm の default optional client scopes に offline_access を足してください。

admin console で作成した realm にはこれらは最初から揃っています。import だけが踏む罠です。

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 の issuer は realm URL (末尾スラッシュなし) — 上の authorizationServers の値そのままで、Kozou はこれを期待 iss として使います。

  1. curl https://mcp.example.com/.well-known/oauth-protected-resourceauthorization_servers にあなたの realm、scopes_supportedmcp:describe mcp:execute offline_access が見えるはずです。
  2. 実クライアントで接続します (最速は Claude Code: claude mcp add --transport http kozou https://mcp.example.com/mcp/mcp → 認証)。ブラウザのフローであなたの consent テキストが表示されるはずです。
  3. access token をデコードします (任意の JWT インスペクタ): aud = resource URI、iss = realm URL、role = PostgreSQL ロールであること。role が欠けているなら、mapper が mcp:* scope に載っていません — 1 つのルールへ。
  4. 失格系の確認: app_role のないユーザーは、何かの既定値で通るのではなく、拒否されなければなりません。