前回はKong GatewayのMCP OAuth2 PluginとEntra IDを連携させる手順を検証した。
今回はもう少し踏み込んでEntra IDを使ってConsumer Mappingの設定を確認する。
別のIdPでも出来る機能なので、汎用的な話と思って読んで問題ないと思う。
Consumer Mapping
Consumer Mappingとは、Kong GatewayのConsumerとOAuth2のクレームをどのようにマッピングするかを定義する設定である。
MCP OAuth2 Pluginは基本的には「JWTが有効かどうか」を検証するだけであり、それ自体はKong Consumerを意識しない。しかしConsumer Mappingを設定することで、JWTのクレーム値(例えばsubクレームのユーザID)とKong Consumerのusernameやcustom_idを紐付けることができる。
MCP OAuth2 PluginのConsumer Mappingがあれば以下のようなものが実現できる。
- JWTの指定クレーム値でKong Consumer/Consumer Groupを検索し、後続のKong Pluginから参照可能にする
- Consumer不一致時は
credential_claimで指定したクレーム値をKong内部の認証オブジェクト(credential)としてセットする(Consumerエンティティがなくても呼び出し元を識別できる) -
consumer_optional: trueの場合、一致するConsumerがなくてもリクエストを通過させる(falseの場合は401を返す)
またConsumer MappingによってKongがConsumerを識別した状態になることで、Kong全体のConsumer対応機能が活用できるようになる。
例えば以下のようなことが実現できる(MCP OAuth2 Plugin固有の機能ではなく、Kong Consumerの一般的な仕組みである)。
Consumer単位のRate Limiting
Rate Limiting PluginをConsumer単位で動作させられる。たとえば、あるサービスアカウントには1分10リクエスト、別のアカウントには100リクエストといった個別制限が可能になる。
Consumer GroupによるACL制御
ACL PluginとConsumer Groupを組み合わせることで、特定グループだけが特定Routeにアクセスできるような制御が実現できる。
Consumer単位の可観測性
KongのAnalyticsやログにConsumer情報が付与されるため、「どのConsumerがどのMCPツールを何回呼んだか」といった粒度で把握できるようになる。
MCP OAuth2 PluginのConsumer関連の設定用パラメータ
MCP OAuth2 Pluginでは以下のConsumer Mapping用パラメータが利用可能である。
| パラメータ | 役割 | デフォルト値 |
|---|---|---|
consumer_claim |
ConsumerとマッピングするJWTクレームのパス | null |
consumer_by |
Consumerの照合に使うフィールド(username、custom_id、id) |
["username", "custom_id"] |
consumer_groups_claim |
Consumer GroupにマッピングするJWTクレームのパス | なし |
credential_claim |
Consumer不一致時のフォールバック識別子となるクレーム | ["sub"] |
consumer_optional |
trueにするとConsumer不一致でもリクエストを通過させる |
false |
consumer_groups_optional |
trueにするとGroup不一致でもリクエストを通過させる |
false |
Entra IDとの組み合わせで使えるクレーム
Consumer Mappingに使えそうなクレームは以下の2つがある。
-
sub: サービスプリンシパルの一意識別子。Kong ConsumerのCustom IDと対応させる(Consumer Mapping用) -
roles: アプリロール名の配列。Kong Consumer Groupと対応させる(Consumer Group Mapping用)
subのユースケースとしては、複数のクライアントアプリをそれぞれEntra IDに個別登録し、アプリごとに異なるKong Consumerと対応させる構成が考えられる。
各アプリは固有のsub値を持つため、それをKong ConsumerのCustom IDに設定することでアプリ単位の識別が可能になる。
アプリA → sub: aaa-111 → Kong Consumer: app-a(Rate Limit: 10 req/min)
アプリB → sub: bbb-222 → Kong Consumer: app-b(Rate Limit: 100 req/min)
アプリC → sub: ccc-333 → Kong Consumer: app-c(ACLで特定Routeのみ許可)
なお、本検証はアプリ登録が1つのみのためsubも1つしか存在せずConsumer Mappingの恩恵は実感しにくいが、「Consumerが正しく検索・識別される」動作の確認という位置づけで検証を進める。
また、上記に挙げたクレーム以外にも独自にクレームを作成してマッピングする方法も考えられるが、今回は上記のクレームを使って検証を進める。
事前準備
前回と同じで以下は既にあるものとする。
- Kong Konnect (CPとDP)
- Entra IDテナントとアプリ登録
- クライアントID / クライアントシークレットを控えておく
-
accessTokenAcceptedVersionは2に設定する(マニフェスト->AAD Graphアプリマニフェストで設定可能)
- MCPサーバー(またはAPIサーバーを用意し、MCP Proxy PluginでMCPサーバー化する)
- MCPクライアント
今回の検証でもDPとhttpbinをローカル環境で起動して検証した。
httpbinに対してはMCP Proxy Pluginを使ってMCPサーバー化する。
MCPクライアントはリクエスト・レスポンスの中身を可視化するためにcurlを利用した。
検証
検証では前回使った以下のYAMLをベースとする。
deck.yaml(クリックすると表示)
_format_version: "3.0"
services:
- name: mcp-oauth-entraid-service
protocol: http
host: httpbin
path: /anything
port: 80
routes:
- name: mcp-oauth-entraid-route
paths:
- /mcp
- /.well-known/oauth-protected-resource
protocols:
- http
plugins:
- name: ai-mcp-oauth2
config:
resource: ${{ env "DECK_MCP_RESOURCE_URL" }}
authorization_servers:
- ${{ env "DECK_AUTHORIZATION_SERVER_URL" }}
jwks_endpoint: ${{ env "DECK_JWKS_ENDPOINT_URL" }}
metadata_endpoint: /.well-known/oauth-protected-resource
insecure_relaxed_audience_validation: true
- name: ai-mcp-proxy
config:
mode: conversion-listener
tools:
- name: httpbin-anything
description: Send a GET request to httpbin /anything endpoint via MCP tools/call
method: GET
また前回同様以下の環境変数を事前に設定しておく。
# deck
export DECK_KONNECT_ADDR=https://us.api.konghq.com
export DECK_KONNECT_TOKEN=<your-konnect-token>
export DECK_KONNECT_CONTROL_PLANE_NAME=<your-control-plane-name>
# Entra ID
export ENTRA_TENANT_ID=<your-tenant-id>
export ENTRA_CLIENT_ID=<your-client-id>
export ENTRA_CLIENT_SECRET=<your-client-secret>
export ENTRA_APP_ID_URI=${ENTRA_CLIENT_ID}
export ENTRA_SCOPE=api://${ENTRA_APP_ID_URI}/.default
# MCP OAuth2 Plugin
export DECK_MCP_RESOURCE_URL=api://${ENTRA_APP_ID_URI}
export DECK_AUTHORIZATION_SERVER_URL=https://login.microsoftonline.com/${ENTRA_TENANT_ID}/v2.0
export DECK_JWKS_ENDPOINT_URL=https://login.microsoftonline.com/${ENTRA_TENANT_ID}/discovery/v2.0/keys
追加で、Entra IDのsubクレームの値を環境変数に設定する。
export DECK_ENTRA_SUB=$(curl -s -X POST \
"https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=${ENTRA_CLIENT_ID}" \
-d "client_secret=${ENTRA_CLIENT_SECRET}" \
-d "scope=${ENTRA_SCOPE}" \
| jq -r '.access_token' \
| cut -d'.' -f2 | base64 -d | jq -r '.sub')
echo $DECK_ENTRA_SUB
ロールの検証を行う際に使うロールの値も設定しておく。
export DECK_ENTRA_ROLE=mcp-role
これはEntra IDの対象アプリのアプリロールで設定しているもので、値の部分を環境変数に設定する。
またロールの検証は認可コードフローで行うが、対象ユーザに対してロールが紐付けられているものとする。
Consumer Mappingの基本動作確認
ここではConsumer Mappingが正しく機能していることを確認する。consumer_claimをsub、consumer_byをcustom_idに設定し、Entra IDのsubクレームとKong Consumerのcustom_idをマッピングする。
まず、ベースのYAMLに対して以下のConsumer設定を追加してGatewayに反映する。
consumers:
- username: mcp-oauth-consumer
custom_id: ${{ env "DECK_ENTRA_SUB" }}
次にai-mcp-oauth2の設定にConsumer Mapping用のパラメータを追加する。
consumer_claim:
- sub
consumer_by:
- custom_id
上記の設定により、トークン内のsubと一致するcustom_idを持つConsumerがKong内で検索されるようになる。
リクエストヘッダにConsumerの情報が含まれるようになるので、実際に確認してみる。
まずEntra IDのトークンを取得する。
TOKEN=$(curl -s -X POST \
"https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=${ENTRA_CLIENT_ID}" \
-d "client_secret=${ENTRA_CLIENT_SECRET}" \
-d "scope=${ENTRA_SCOPE}" \
| jq -r '.access_token')
echo $TOKEN
トークンをMCP OAuth2 Pluginに送る。
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8000/mcp/aaa | jq .
実行した結果、httpbinのレスポンス内のheadersにX-Consumer-を含むConsumerに関するヘッダが確認できる。
"X-Consumer-Custom-Id": "<custom-id>",
"X-Consumer-Id": "<consumer-id>",
"X-Consumer-Username": "mcp-oauth-consumer",
この結果から、Consumer Mappingが正しく機能しており、トークンのsubクレームと一致するcustom_idを持つConsumerがKong内で識別されていることがわかる。
なお、consumer_optionalの値がデフォルトでfalseであるため、custom_idを変更するなどしてConsumer Mappingに失敗した場合は401 Unauthorizedとなり以下のエラーメッセージが表示される。
{
"message": "consumer was not found"
}
ついでにconsumer_optionalの動作確認も行ってみる。
先程の設定にconsumer_optional: trueを追加し、consumer_byをcustom_idからusernameに変更する。
consumer_claim:
- sub
consumer_by:
- username
consumer_optional: true
この状態で先程と同様にリクエストを送ると、Consumer Mappingに失敗してもリクエストが通るようになる。
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8000/mcp/aaa | jq .
以下は実際に受け取った結果だが、Consumer Mappingに失敗しているためX-Consumer-ヘッダが存在しないことがわかる。
{
"args": {},
"data": "",
"files": {},
"form": {},
"headers": {
"Accept": "*/*",
"Connection": "keep-alive",
"Host": "httpbin",
"User-Agent": "curl/8.7.1",
"X-Forwarded-Host": "localhost",
"X-Forwarded-Path": "/mcp/aaa",
"X-Forwarded-Prefix": "/mcp",
"X-Kong-Request-Id": "dcb74a225fd09d291427361ab932291a"
},
"json": null,
"method": "GET",
"origin": "172.22.0.1",
"url": "http://httpbin/anything/aaa"
}
anonymous的な挙動を求める場合はconsumer_optional: trueを活用すると良さそうだ。
Consumer GroupとACL Pluginによるアクセス制御
ここまではClient Credentialsフローで取得したトークンのsubクレームをConsumer MappingしてKong Consumerを識別するところまで確認した。
次にrolesクレームを使ってConsumer Groupとマッピングし、ACL Pluginでアクセス制御するところまで確認してみる。
これについては認可コードフローがやりやすいので、トークンの取得方法を変えて検証する。
先程利用したYAMLのConsumer定義にConsumer Groupを追加する。
consumer_groups:
- name: ${{ env "DECK_ENTRA_ROLE" }}
またMCP OAuth2 Pluginの設定にConsumer Group Mapping用のパラメータを追加する。
#consumer_claim:
# - sub
#consumer_by:
# - custom_id
consumer_groups_claim:
- roles
Consumer Mapping用の設定は残しておいてもいいが、結果確認をしやすくするためにコメントアウトしている。
最後にACL PluginをRouteに追加して、Consumer Groupに基づくアクセス制御を設定する。
- name: acl
config:
allow:
- ${{ env "DECK_ENTRA_ROLE" }}
include_consumer_groups: true
通常、ACL PluginはConsumerに紐づいてアクセス制御を行うが、include_consumer_groups: trueを設定することでConsumer Groupの情報も参照するようになる。
最終形は以下となる。
deck.yaml(クリックして表示)
_format_version: "3.0"
consumer_groups:
- name: ${{ env "DECK_ENTRA_ROLE" }}
consumers:
- username: mcp-oauth-consumer
custom_id: ${{ env "DECK_ENTRA_SUB" }}
services:
- name: mcp-oauth-entraid-service
protocol: http
host: httpbin
path: /anything
port: 80
routes:
- name: mcp-oauth-entraid-route
paths:
- /mcp
- /.well-known/oauth-protected-resource
protocols:
- http
plugins:
- name: ai-mcp-oauth2
config:
resource: ${{ env "DECK_MCP_RESOURCE_URL" }}
authorization_servers:
- ${{ env "DECK_AUTHORIZATION_SERVER_URL" }}
jwks_endpoint: ${{ env "DECK_JWKS_ENDPOINT_URL" }}
metadata_endpoint: /.well-known/oauth-protected-resource
insecure_relaxed_audience_validation: true
#consumer_claim:
# - sub
#consumer_by:
# - custom_id
consumer_groups_claim:
- roles
- name: acl
config:
allow:
- ${{ env "DECK_ENTRA_ROLE" }}
include_consumer_groups: true
- name: ai-mcp-proxy
config:
mode: conversion-listener
tools:
- name: httpbin-anything
description: Send a GET request to httpbin /anything endpoint via MCP tools/call
method: GET
設定を反映する。
deck gateway sync ./deck.yaml
これによりACL Pluginのallowリストに指定した値と一致するConsumer Groupのみが通過できるようになる。
上記の設定を反映した状態で認可コードフローを使ってトークンを取得し、リクエストを送ってhttpbinのレスポンスが返ってくることを確認する。
まず認可コードフローでトークンを取得する。
Client Credentialsフローと異なりブラウザでのログインが必要なため、以下の3ステップで取得する。
なお、事前にEntra IDのアプリの登録でリダイレクトURIとしてhttp://localhost(プラットフォームの種類はWeb)を追加しておく。
Step 1: 認可URLを生成してブラウザで開く
アクセストークンを取得するには、まずEntra IDから認可コードを発行してもらう必要がある。
認可コードとはアクセストークンと交換するための一時的なコードで、ユーザーがブラウザでログイン・同意することで発行される。
以下のコマンドで認可URLを生成し、出力されたURLをブラウザで開いてログインする。ログインが成功するとEntra IDはredirect_uri(ここではhttp://localhost)にリダイレクトし、URLのクエリパラメータに認可コードが含まれる。
echo "https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/authorize?\
client_id=${ENTRA_CLIENT_ID}\
&response_type=code\
&redirect_uri=http%3A%2F%2Flocalhost\
&scope=openid%20${ENTRA_CLIENT_ID}%2F.default\
&response_mode=query"
各パラメータの意味は以下の通り。
| パラメータ | 値 | 説明 |
|---|---|---|
client_id |
ENTRA_CLIENT_ID |
認可を要求するアプリのクライアントID |
response_type |
code |
認可サーバーが返すレスポンスの種類を指定する。token(アクセストークン直接返却)やid_tokenなどがある。今回は認可コードフローなのでcodeを指定する |
redirect_uri |
http://localhost(URLエンコード) |
認可コードを受け取るリダイレクト先。アプリ登録に登録済みである必要がある |
scope |
openid ${ENTRA_CLIENT_ID}/.default |
要求するスコープ。openidに加えてアプリ固有スコープ(<client-id>/.default)を指定することで、アプリ固有のアクセストークンが発行され、rolesクレームが含まれるようになる |
response_mode |
query |
認可レスポンスをどの形式でリダイレクトURIに付加するかを指定する。query(クエリパラメータ)、fragment(URLフラグメント)、form_postなどがある。今回はブラウザのアドレスバーからコードを取り出すためqueryを指定する |
Step 2: リダイレクトURLから認可コードを取得する
ログイン後、ブラウザのアドレスバーにリダイレクトURLが表示される。
例えば以下のようなURLになる。
http://localhost/?code=1.AS...DE&session_state=009a...
code=の後ろにある値が認可コードである。
これを抽出するためにブラウザのアドレスバーに表示されたリダイレクトURLを以下の<リダイレクトURL>に貼り付けて実行する。ダブルクォーテーションで括るのを忘れないこと。
REDIRECT_URL="<リダイレクトURL>"
export AUTH_CODE=$(echo "$REDIRECT_URL" | grep -oE 'code=[^&#]+' | cut -d= -f2)
echo "AUTH_CODE: $AUTH_CODE"
認可コードの有効期限は10分なので、少し間が空いたりしたら再度認可コードの取得からやり直す必要がある。
Step 3: 認可コードをアクセストークンに交換する
認可コードを使ってアクセストークンを取得する。
TOKEN=$(curl -s -X POST \
"https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "client_id=${ENTRA_CLIENT_ID}" \
-d "client_secret=${ENTRA_CLIENT_SECRET}" \
-d "code=${AUTH_CODE}" \
-d "redirect_uri=http://localhost" \
-d "scope=openid ${ENTRA_CLIENT_ID}/.default" \
| jq -r '.access_token')
echo $TOKEN
echo $TOKENでトークンが表示されればOKだが、表示されない場合は認可コードの期限が切れた可能性がある。
その場合はcurl部分を抜き出して再実行して取得できなかった原因を特定する。
アクセストークンが取得できた場合、取得したJWTのrolesクレームが含まれていることを確認する。
echo $TOKEN | cut -d'.' -f2 | base64 -d 2>/dev/null | jq .
含まれている場合は以下のようなのがJSON内に確認できるはずだ。
"roles": [
"mcp-role",
"default:super-admin"
],
なお、Consumer Group名には制約があり、上記のdefault:super-adminのようにコロンが含まれるとConsumer Groupの作成に失敗するので、ここではロールの値はmcp-roleを利用する。
次に認可コードフローで取得したトークンのsubクレームをDECK_ENTRA_SUBに設定する。
Client Credentialsフローとは異なり、ここでのsubはサービスプリンシパルではなくログインユーザーのIDになることに注意。
export DECK_ENTRA_SUB=$(echo $TOKEN | cut -d'.' -f2 | base64 -d 2>/dev/null | jq -r '.sub')
export DECK_ENTRA_ROLE=mcp-role
echo "DECK_ENTRA_SUB: $DECK_ENTRA_SUB"
echo "DECK_ENTRA_ROLE: $DECK_ENTRA_ROLE"
リクエストを送信する。
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8000/mcp/aaa | jq .
問題なければhttpbinのレスポンスのヘッダーにX-Authenticated-Groupsというキーのヘッダが追加され、値にmcp-roleが含まれていることが確認できる。
"X-Authenticated-Groups": "mcp-role",
なお、今回Consumer Mappingの設定をコメントアウトして検証しているが、コメントアウトしなかった場合はヘッダの見え方が少し異なって以下のような感じで確認できる。
"X-Consumer-Custom-Id": "<custom-id>",
"X-Consumer-Groups": "mcp-role",
"X-Consumer-Id": "<consumer-id>",
"X-Consumer-Username": "mcp-oauth-consumer",
次にACL Pluginによるアクセス拒否を確認する。
rolesとマッチしないConsumer Group名を設定してsyncし、再度リクエストを送ってみる。
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8000/mcp/aaa | jq .
以下のようにアクセスが拒否されることが確認できる。
{
"message": "consumer groups were not found"
}
まとめ
通常のOAuthのようなConsumerとのマッピングの仕組みをMCP OAuth2 Pluginでも利用できることが確認できた。
利用者ごとに細かく権限を分けたい場合なんかには使えると思うので、そういった時に活用したいと思う。