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 側では何もエラーになりません。施行がただ成立しないだけです。この配置ルールの違反が、最も典型的な失敗パターンです。
Step 1: client scope を作る
Section titled “Step 1: client scope を作る”mcp:describe と mcp:execute の 2 つの client scope を作成します (Admin console: Client scopes → Create。または import — fixture の clientScopes 配列参照)。それぞれに 3 つの protocol mapper を追加します。
| Mapper | 種類 | 設定 |
|---|---|---|
| Audience | Audience (oidc-audience-mapper) | Included custom audience = あなたの resource URI (例 https://mcp.example.com/mcp)。access token に追加。 |
| Role | User attribute (oidc-usermodel-attribute-mapper) | User attribute = app_role、token claim name = role、String、単一値。access token に追加。 |
| Username | User 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 が必須なので、ユーザーがそれを読むことになります。
Step 2: scope を要求可能にする
Section titled “Step 2: scope を要求可能にする”mcp:describe と mcp:execute を realm の default optional client scopes に追加します (Client scopes → realm 設定、import なら defaultOptionalClientScopes)。optional で十分です: クライアントは名前で要求します。これがないと、Keycloak の registration policy がそれらを名指しする DCR リクエストを拒否します (「Requested scope not trusted」)。
Step 3: offline_access を広告する
Section titled “Step 3: offline_access を広告する”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 する場合
Section titled “最小の realm を import する場合”realm import は Keycloak の built-in client scope を自動生成しません。結果として罠が 2 つあり、どちらも fixture に対処が入っています。
- DCR は暗黙に
basicscope に依存します: Keycloak は動的登録された全クライアントにbasicを追加します。realm にその名の scope がないと、登録自体が失敗します (「Requested scope ‘basic’ not trusted」) — クライアントが要求してもいない scope で、です。basicscope を定義し (fixture はsubとauth_timeの mapper を与えています)、realm の default default-client-scopes に入れてください。 subはもう自動ではありません: Keycloak 25 以降、subclaim は opt-in のbasicscope に載ります。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 だけが踏む罠です。
Kozou を realm に向ける
Section titled “Kozou を 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 の issuer は realm URL (末尾スラッシュなし) — 上の authorizationServers の値そのままで、Kozou はこれを期待 iss として使います。
curl https://mcp.example.com/.well-known/oauth-protected-resource—authorization_serversにあなたの realm、scopes_supportedにmcp:describe mcp:execute offline_accessが見えるはずです。- 実クライアントで接続します (最速は Claude Code:
claude mcp add --transport http kozou https://mcp.example.com/mcp→/mcp→ 認証)。ブラウザのフローであなたの consent テキストが表示されるはずです。 - access token をデコードします (任意の JWT インスペクタ):
aud= resource URI、iss= realm URL、role= PostgreSQL ロールであること。roleが欠けているなら、mapper がmcp:*scope に載っていません — 1 つのルールへ。 - 失格系の確認:
app_roleのないユーザーは、何かの既定値で通るのではなく、拒否されなければなりません。
- リモート MCP と OAuth — モード自体・厳格さのルール・クライアント別の挙動。
- Auth0 を authorization server にする — SaaS の選択肢。audience mapper は不要ですが、クライアントごと承認の壁があります。
- 実例 realm — E2E スイートが実際に走らせている fixture。