GitHub ActionsでCognitoのM2Mトークンを発行しデプロイ直後のMCPを検証する — 1つのジョブに2つのアイデンティティ
GitHub OIDCはCIにAWSのアイデンティティを与えるが、JWT認可のMCPエンドポイントにはもう1つ必要になる。terraform outputから読むCognitoのclient_credentialsトークンである。

目次
はじめに
Model Context Protocol のサーバーをデプロイし、CI から検証できる状態にしてほしいと依頼されました。デプロイは 1 つの問題でしたが、その後で呼び出すのはまったく別の問題でした。CI がそのエンドポイントを作るのに使えた認証情報では、そのエンドポイントと会話できなかったからです。
これは特定の製品の癖ではありません。この種のパイプラインは 2 つの異なる行為をします。インフラを変更する行為はクラウドプロバイダが認可し、アプリケーションを利用する行為はアプリケーション自身が認可します。権限主体が 2 つあるので、認証情報も 2 つ必要になります。
本記事では、その 2 つの機構を順に整理します。AWS のコントロールプレーンに対する GitHub OIDC、そしてエンドポイントに対する Cognito の client_credentials トークンです。最後に、出来上がったテストが何も検証しないまま成功を報告していた原因にも触れます。
1 つの認証情報では足りない理由
サーバーをホストする Amazon Bedrock AgentCore Runtime は、インバウンドの呼び出しに OAuth/JWT しか受け付けません。SigV4 の経路が存在しないので、AWS の認証情報ではそもそも呼び出せません。認証のないリクエストは WWW-Authenticate: Bearer ヘッダー付きの 401 で返ってきます。
つまり CI がすでに持っていた AWS のアイデンティティは、Runtime を作り、その ARN を読み、設定を変更できる一方で、そこへの呼び出しは 1 つもできませんでした。
正確に言うと、これは 2 つの AWS ロールではありません。一方の認証情報は AWS のプリンシパルです。もう一方はユーザープールが発行した OAuth のアクセストークンで、その検証に AWS IAM は一切関与しません。
機構 1: AWS コントロールプレーンのための GitHub OIDC
OIDC が解く問題は、CI が AWS の認証情報を必要とする一方で、それを何も保存すべきではないという点です。代わりに GitHub が、実行中のジョブへそのジョブ自身を記述した短命な JSON Web Token を発行します。AWS はそのトークンを sts:AssumeRoleWithWebIdentity で一時的な認証情報に交換します。
AWS アカウント側に必要なものは 2 つです。1 つは token.actions.githubusercontent.com に対する IAM の OIDC ID プロバイダで、これは一度作れば済みます。もう 1 つは、そこからのトークンを受け入れる信頼ポリシーを持つロールです。
{ "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Principal": { "Federated": "arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com" }, "Action": "sts:AssumeRoleWithWebIdentity", "Condition": { "StringEquals": { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", "token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:environment:production" } } }]}この 2 つのリソースをどう作るかは大した問題ではなく、多くの記事が理由もなく細かくなるのはこの部分です。重要なのは最後の行にある sub の条件です。
sub クレームはジョブがどこで実行されたかを述べる
sub は認証情報を要求しているワークロードについての GitHub の言明であり、ジョブが何を契機に実行されたかによって形が変わります。
| ジョブの実行のしかた | sub の値 |
|---|---|
| GitHub Environment を参照している | repo:ORG/REPO:environment:production |
| プルリクエストのイベントが契機で、Environment はなし | repo:ORG/REPO:pull_request |
| 上記のいずれでもない | repo:ORG/REPO:ref:refs/heads/main |
Environment が存在する場合は他の 2 つより優先されます。プルリクエストでの実行は、どの PR でも同じ pull_request という値になり、番号は入りません。実行ごとの詳細は代わりに ref と job_workflow_ref のクレームに入っているからです。
実務上の帰結は、信頼条件とワークフローの契機は一緒に設計しなければならないということです。条件を environment:production に固定するなら、ジョブ側でその Environment を宣言する必要があります。そうしないと sub は ref の形で出てきて、ロールは受け付けません。失敗は AssumeRole の時点で一般的なアクセス拒否として届き、どのクレームが一致しなかったかは何も語りません。
Environment に固定するのは、単なる選択肢の 1 つではなく推奨できる選択です。GitHub Environment には必須レビュアーやブランチの制限を持たせられるので、承認のゲートと信頼条件が同じものを指すようになります。
セキュリティレビューはこのパターンを名指しで指摘します。sub の条件をそもそも書かない場合も同じで、そのときロールは GitHub 上のどのワークフローからでも Assume できてしまいます。1 つのロールで複数の文脈を本当に扱う必要があるなら、* ではなく自分で選んだ値を StringLike で列挙してください。
2026 年 7 月 15 日より後に作られたリポジトリには、所有者とリポジトリの名前ではなく ID を含む変更不可の既定 subject も付きます。リネームで信頼ポリシーが壊れることがなくなったわけです。条件を書く前に、自分のリポジトリがどちらの形式なのかは知っておく価値があります。
ワークフロー側は小さく、2 行が本体です。
jobs: deploy: environment: name: production # sub クレームの 3 番目のセグメントを決める permissions: id-token: write # これがないと OIDC トークンは一切発行されない steps: - uses: aws-actions/configure-aws-credentials@<pinned-sha> with: role-to-assume: arn:aws:iam::111122223333:role/ci-deploy aws-region: ap-northeast-1id-token: write はトークンを要求するための権限で、何かを書き込む権限ではありません。これを書き忘れるのがもう 1 つの静かな失敗で、ジョブ側から見ると信頼ポリシーの誤りと見分けがつきません。
機構 2: エンドポイントのための client_credentials トークン
次は 2 つ目の権限主体です。エンドポイントは Cognito ユーザープールが発行した JWT を検証するので、CI はそのプールからトークンを取る必要があります。
真っ先に思いつくやり方が間違いです。ユーザープールのパスワードグラントが認証するのは人なので、CI から使うにはユーザーを作り、静的なパスワードを与え、そのパスワードをパイプラインが読める場所に置くことになります。ユーザーをコードで作れば、パスワードがインフラの state にも入ります。これらのコストはどれも永続的です。
client_credentials はまさにこの用途のためにあります。人ではなくアプリケーションを認証するので、そもそもユーザーが登場しません。このグラントには、要求されるスコープを所有するリソースサーバーと、そのスコープを要求してよいクライアントが必要です。
resource "aws_cognito_resource_server" "api" { identifier = "mcp-hub" user_pool_id = aws_cognito_user_pool.this.id
scope { scope_name = "invoke" scope_description = "Invoke the runtime" }}
resource "aws_cognito_user_pool_client" "machine" { name = "ci" user_pool_id = aws_cognito_user_pool.this.id
generate_secret = true allowed_oauth_flows = ["client_credentials"] allowed_oauth_flows_user_pool_client = true allowed_oauth_scopes = ["${aws_cognito_resource_server.api.identifier}/invoke"]
access_token_validity = 1 token_validity_units { access_token = "hours" }}書く前に知っておくとよい点が 2 つあります。openid、profile、email は client_credentials では有効なスコープではないので、あのリストに入れてよいのはリソースサーバーの独自スコープだけです。そしてこのフローはリフレッシュトークンを発行しないので、その設定は何もありません。
STS と同じ形
2 つの機構が別々の雑用ではなく 1 つの考え方に見えてくるのは、ここです。
どちらも永続的なアイデンティティを、期限の切れる認証情報に交換します。AssumeRoleWithWebIdentity は GitHub のトークンを受け取り、ジョブの間だけ有効な AWS の認証情報を返します。トークンエンドポイントはクライアント ID とシークレットを受け取り、1 時間有効なアクセストークンを返します。どちらの場合も通信に乗るものは短命で、どちらの場合も CI は自前の長命な認証情報を保持しません。
curl -sS -X POST "https://<domain>.auth.<region>.amazoncognito.com/oauth2/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -u "${CLIENT_ID}:${CLIENT_SECRET}" \ -d "grant_type=client_credentials" \ --data-urlencode "scope=mcp-hub/invoke"クライアントシークレットはこの設計で唯一の長命な認証情報ですが、CI に置く必要はありません。Terraform が生成して state に保持するので、すでに state へアクセスできるジョブがそれを読み、次のステップへ渡せます。
secret=$(terraform output -raw m2m_client_secret)echo "::add-mask::${secret}" # 書かないとログに出るecho "CLIENT_SECRET=${secret}" >> "$GITHUB_ENV"echo "CLIENT_ID=$(terraform output -raw m2m_client_id)" >> "$GITHUB_ENV"これは勝ちではなく取引として述べる価値があります。シークレットは state の中で保存時の機微データになりました。state のバックエンドが暗号化されアクセス制御されているなら許容できますが、誰かのノート PC 上のファイルであれば許容できません。
エンドポイント側の設定は 1 行です。AgentCore はトークンの client_id クレームをリストと照合し、それがマシントークンをここで使える理由になっています。client_credentials のトークンは client_id を持ち、aud は持ちません。
authorizer_configuration { custom_jwt_authorizer { discovery_url = "https://cognito-idp.<region>.amazonaws.com/<pool-id>/.well-known/openid-configuration" allowed_clients = [aws_cognito_user_pool_client.machine.id] }}スモークテストに真実を語らせる
両方の機構が動いた状態でのテストは、セッションを開き、ツールを列挙し、些細なものを 1 つ呼び、外部 API に到達するものを 1 つ呼ぶという内容でした。
エージェントはその最後の呼び出しを、フラグの裏でオフにした状態で組み込みました。当時エンドポイントには外向きの経路が設定されておらず、呼び出しは失敗する前提で、スキップしておけばパイプラインは緑のままでした。私はそれを却下しました。唯一の実依存をスキップする実行は、結果ではないからです。
the only thing I care is if it really tell us the mcp works or not
フラグは外しました。それを安全にするために、このステップはゲートではなく報告にしてほしいとも私は頼みました。未解決のネットワークの問題でビルドが赤くなると、人は CI を無視することを覚えるからです。
- name: Invoke the endpoint continue-on-error: trueそのうえでテスト自身に 2 つのバグがあり、どちらもテスト対象についての発見のように見える失敗を生みました。
トークンは発行されて捨てられていました。リトライのループはこうなっていました。
BEARER_TOKEN="$(./token.sh)" && python3 client.pyVAR=value cmd は代入をコマンドの前に置いて、そのコマンドへ渡します。VAR=value && cmd は代入文とその後に続く別のコマンドなので、変数はシェルに設定されるだけで export されません。構文としては妥当なので、bash -n では捕まえられません。
入力BEARER="$(echo tok-123)" && python3 -c 'import os; print(repr(os.getenv("BEARER")))'出力None入力if t="$(echo tok-123)"; then export BEARER="$t"; python3 -c 'import os; print(repr(os.getenv("BEARER")))'; fi出力'tok-123'エラーはパースされて消えていました。トークンを export した状態で呼び出しは走り、json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0) で死にました。MCP サーバーの中でツールが例外を投げると、その例外メッセージがツールの content として返ってきます。その content を JSON としてパースすると、エラーは破壊され、その 1 文字目についての苦情に置き換わります。
payload = result.content[0].text if result.content else ""
if result.isError or not payload.lstrip().startswith("{"): print(f"tool returned an error: {payload!r}") sys.exit(1)
data = json.loads(payload)最後の 1 行で落ちた実行
2 つを直した後の実行は、真実を報告しました。
token obtained (858 chars)tools: 5 listedping: okcall_external_api: tool returned an error: 'Error executing tool ...: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1032)'各行は 1 つ上の行より多くのスタックに依存しています。それが、最初の失敗を単に赤いだけのものではなく有益なものにします。
| 行 | 通ったことで除外できるもの |
|---|---|
token obtained | マシンクライアント、リソースサーバー、スコープ、シークレットの受け渡しがすべて正しい |
tools: 5 listed | JWT の Authorizer が client_id クレームを受理し、プロトコルのハンドシェイクも完了する |
ping: ok | イメージが置き場所から取得され、コンテナが起動し、動いているのは私たちのコードである |
| 外部呼び出し | 何も。失敗したのはこの層 |
つまり失敗は自分で場所を絞り込みました。外向きの呼び出しは TLS のハンドシェイクまで到達して証明書を拒否しており、これはタイムアウトでも DNS の失敗でもありません。外向き通信は通っていて、経路上の何かが証明書を差し替えているということです。4 層のうち 3 層はもう疑わなくてよくなり、4 層目は検索できるだけ具体的なエラー文字列を伴っていました。
この並べ方は、偶然そうなるのではなく設計する価値があります。各ステップが依存を 1 つずつ増やすテストは、どこで壊れたかを教えます。1 回の複合的な呼び出しは、壊れたことしか教えません。代償は、ステップが本当に入れ子になっていなければならない点です。1 つ前と依存を共有しないステップは、何も買いません。
まとめ
権限主体が 2 つあるので、機構も 2 つです。クラウドプロバイダは誰がインフラを変更してよいかを決め、アプリケーションは誰が呼び出してよいかを決めます。1 つ目を担うのが GitHub OIDC で、ジョブを記述したトークンを一時的な AWS 認証情報に交換します。そのトークンの sub クレームは自分が書いた信頼条件と一致しなければならないので、条件とワークフローの契機は 2 つではなく 1 つの設計判断になります。
2 つ目を担うのが client_credentials で、その価値は何を持たないかにあります。人ではなくアプリケーションを認証することで、ユーザーも、静的なパスワードも、それを置いておく場所も不要になります。残るのは STS と同じ取引です。長命な認証情報は保護された場所に 1 つだけ持ち、通信に乗せるのは短命なトークンだけにします。
最後の部分は他より広く一般化できます。各チェックが 1 つ前より多くのスタックに依存するように並べれば、失敗はそれが属する層を名指しします。私たちの場合は 3 層を通過して 4 層目で止まり、そこが唯一、誰も答えを知らなかった層でした。スキップのフラグが残っていれば、3 層しか動かしていないのに 4 つの合格を報告していたはずです。





