GitHub ActionsでCognitoのM2Mトークンを発行しデプロイ直後のMCPを検証する — 1つのジョブに2つのアイデンティティ

GitHub OIDCはCIにAWSのアイデンティティを与えるが、JWT認可のMCPエンドポイントにはもう1つ必要になる。terraform outputから読むCognitoのclient_credentialsトークンである。

白いカードに黒のModel Context ProtocolのM字マークとロゴタイプ
目次

はじめに

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 つもできませんでした。

1 つの CI ジョブに 2 つのアイデンティティ左側の GitHub Actions ジョブのカードが、2 つの独立した認証情報を持つ。上側は OIDC トークンを交換して得た AWS 認証情報で、AWS コントロールプレーンのカードへつながる。下側は Cognito の M2M アクセストークンで、AgentCore MCP エンドポイントのカードへつながる。エンドポイントのカードには、SigV4 の経路が無くインバウンド認証は OAuth/JWT のみであることを示す帯があり、AWS 認証情報では到達できない。GitHub Actions ジョブOIDC トークンをAWS 認証情報に交換Cognito M2MアクセストークンAssumeRoleWithWebIdentity でSigV4 署名の認証情報を取得AWS コントロールプレーンterraform apply と output 取得Authorization: Bearer ヘッダーをallowedClients で検証AgentCore MCP エンドポイント呼び出しごとに Bearer トークンSigV4 の経路は存在しない。インバウンド認証は OAuth/JWT のみ。
1 つのジョブ、2 つの認証情報。AWS のアイデンティティが届くのはコントロールプレーンまでで、エンドポイントに届くのは OAuth のアイデンティティだけです。

正確に言うと、これは 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 つは、そこからのトークンを受け入れる信頼ポリシーを持つロールです。

trust-policy.json
{
"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 のクレームに入っているからです。

OIDC subject クレームの 3 つの形契機ごとに 1 行ずつ、計 3 行を示す。GitHub Environment を参照するジョブは :environment:production で終わる subject を生成し、受理される。Environment のないプルリクエスト契機では :pull_request で終わる subject になり、拒否される。それ以外は :ref:refs/heads/main で終わる subject になり、これも拒否される。注記では、ワイルドカードで条件を広げると 3 つすべてが受理され、フォークからの実行も通ることを説明している。同じワークフローで 3 通りの subjectGitHub Environment を参照しているrepo:ORG/REPO:environment:production受理プルリクエストが契機、Environment なしrepo:ORG/REPO:pull_request拒否上記のいずれでもないrepo:ORG/REPO:ref:refs/heads/main拒否1 つ目の形に固定した信頼条件は、その文字列だけを受理し、他は受理しない。ワイルドカードで広げると 3 つすべてを受理し、フォークからの実行も通ってしまう。
同じワークフローが、契機によって異なる subject を生みます。1 つの形に固定した信頼条件は、残る 2 つを拒否します。

実務上の帰結は、信頼条件とワークフローの契機は一緒に設計しなければならないということです。条件を environment:production に固定するなら、ジョブ側でその Environment を宣言する必要があります。そうしないと sub は ref の形で出てきて、ロールは受け付けません。失敗は AssumeRole の時点で一般的なアクセス拒否として届き、どのクレームが一致しなかったかは何も語りません。

Environment に固定するのは、単なる選択肢の 1 つではなく推奨できる選択です。GitHub Environment には必須レビュアーやブランチの制限を持たせられるので、承認のゲートと信頼条件が同じものを指すようになります。

セキュリティレビューはこのパターンを名指しで指摘します。sub の条件をそもそも書かない場合も同じで、そのときロールは GitHub 上のどのワークフローからでも Assume できてしまいます。1 つのロールで複数の文脈を本当に扱う必要があるなら、* ではなく自分で選んだ値を StringLike で列挙してください。

2026 年 7 月 15 日より後に作られたリポジトリには、所有者とリポジトリの名前ではなく ID を含む変更不可の既定 subject も付きます。リネームで信頼ポリシーが壊れることがなくなったわけです。条件を書く前に、自分のリポジトリがどちらの形式なのかは知っておく価値があります。

ワークフロー側は小さく、2 行が本体です。

.github/workflows/deploy.yml
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-1

id-token: write はトークンを要求するための権限で、何かを書き込む権限ではありません。これを書き忘れるのがもう 1 つの静かな失敗で、ジョブ側から見ると信頼ポリシーの誤りと見分けがつきません。

機構 2: エンドポイントのための client_credentials トークン

次は 2 つ目の権限主体です。エンドポイントは Cognito ユーザープールが発行した JWT を検証するので、CI はそのプールからトークンを取る必要があります。

真っ先に思いつくやり方が間違いです。ユーザープールのパスワードグラントが認証するのは人なので、CI から使うにはユーザーを作り、静的なパスワードを与え、そのパスワードをパイプラインが読める場所に置くことになります。ユーザーをコードで作れば、パスワードがインフラの state にも入ります。これらのコストはどれも永続的です。

client_credentials はまさにこの用途のためにあります。人ではなくアプリケーションを認証するので、そもそもユーザーが登場しません。このグラントには、要求されるスコープを所有するリソースサーバーと、そのスコープを要求してよいクライアントが必要です。

cognito.tf
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 がマシントークンを取得して提示するまで5 つの番号付きの手順が上から下へ並ぶ。1 つ目は CI が Terraform の state からクライアント ID とシークレットを読む。2 つ目は client_credentials グラントとリソースサーバーのスコープを指定して Cognito のトークンエンドポイントへ POST する。3 つ目は client_id クレームを持ち aud クレームを持たないアクセストークンを受け取る。4 つ目は Authorization の Bearer ヘッダーで Runtime を呼ぶ。5 つ目は AgentCore が client_id を allowedClients と照合する。末尾の注記に、これは STS と同じ取引であり、長命な認証情報は暗号化されたバックエンドに 1 つだけ置き、通信に乗るのは短命なトークンのみで、CI 自身は認証情報を保持しないと書かれている。1state からクライアント認証情報を読むterraform output -raw m2m_client_id / m2m_client_secret2Cognito のトークンエンドポイントへ POSTgrant_type=client_credentials, scope=mcp-hub/invoke3短命なアクセストークンを受け取るclient_id クレームを持ち aud は持たない。有効期限は 1 時間4Bearer ヘッダーで Runtime を呼ぶAuthorization: Bearer、通常の HTTPS 経由5Authorizer がトークンを検証するclient_id を許可リストと照合STS と同じ取引。長命な認証情報は暗号化されたバックエンドに 1 つだけ置き、通信に乗るのは短命なトークンのみ。CI 自身は認証情報を保持しない。
交換の流れ。認証情報を読んだ後は、この経路に AWS はまったく登場しません。

クライアントシークレットはこの設計で唯一の長命な認証情報ですが、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.py

VAR=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 listed
ping: ok
call_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 listedJWT の 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 つの合格を報告していたはずです。

参考リンク

この記事をシェア