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

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

- Source: https://oharu121.com/ja/blog/github-actions-oidc-cognito-m2m-token-mcp-smoke-test/
- Published: 2026-09-04T00:23:01+09:00
- Tags: GitHub Actions, AWS, セキュリティ, MCP, AgentCore

---
## はじめに

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

*Figure — TwoIdentities: 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 つは、そこからのトークンを受け入れる信頼ポリシーを持つロールです。

```json title="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` のクレームに入っているからです。

*Figure — SubClaim: 同じワークフローが、契機によって異なる subject を生みます。1 つの形に固定した信頼条件は、残る 2 つを拒否します。*

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

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

> **気を付けて**
>
> **`repo:ORG/REPO:*` は動かすための正解ではありません**。subject をワイルドカードにすると、そのリポジトリのあらゆるブランチ、あらゆる Environment、あらゆる契機を受け入れます。`pull_request` での実行も含まれます。フォークがデプロイ用のロールを Assume できるようになるのは、この経路です。

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

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

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

```yaml title=".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` はまさにこの用途のためにあります。**人ではなくアプリケーションを認証する**ので、そもそもユーザーが登場しません。このグラントには、要求されるスコープを所有するリソースサーバーと、そのスコープを要求してよいクライアントが必要です。

```hcl title="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 は自前の長命な認証情報を保持しません**。

```bash
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"
```

*Figure — TokenFlow: 交換の流れ。認証情報を読んだ後は、この経路に AWS はまったく登場しません。*

クライアントシークレットはこの設計で唯一の長命な認証情報ですが、CI に置く必要はありません。Terraform が生成して state に保持するので、すでに state へアクセスできるジョブがそれを読み、次のステップへ渡せます。

```bash
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` は持ちません。

```hcl
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 を無視することを覚えるからです。

```yaml
- name: Invoke the endpoint
  continue-on-error: true
```

そのうえでテスト自身に 2 つのバグがあり、どちらもテスト対象についての発見のように見える失敗を生みました。

**トークンは発行されて捨てられていました**。リトライのループはこうなっていました。

```bash
BEARER_TOKEN="$(./token.sh)" && python3 client.py
```

`VAR=value cmd` は代入をコマンドの前に置いて、そのコマンドへ渡します。`VAR=value && cmd` は代入*文*とその後に続く別のコマンドなので、変数はシェルに設定されるだけで export されません。構文としては妥当なので、`bash -n` では捕まえられません。

```bash
BEARER="$(echo tok-123)" && python3 -c 'import os; print(repr(os.getenv("BEARER")))'
# None
```

```bash
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 文字目についての苦情に置き換わります。

```python
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 つを直した後の実行は、真実を報告しました。

```text
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 listed` | JWT の Authorizer が `client_id` クレームを受理し、プロトコルのハンドシェイクも完了する |
| `ping: ok` | イメージが置き場所から取得され、コンテナが起動し、動いているのは私たちのコードである |
| 外部呼び出し | 何も。失敗したのはこの層 |

つまり失敗は自分で場所を絞り込みました。外向きの呼び出しは TLS のハンドシェイクまで到達して証明書を拒否しており、これはタイムアウトでも DNS の失敗でもありません。**外向き通信は通っていて、経路上の何かが証明書を差し替えている**ということです。4 層のうち 3 層はもう疑わなくてよくなり、4 層目は検索できるだけ具体的なエラー文字列を伴っていました。

この並べ方は、偶然そうなるのではなく設計する価値があります。**各ステップが依存を 1 つずつ増やすテストは、どこで壊れたかを教えます。1 回の複合的な呼び出しは、壊れたことしか教えません**。代償は、ステップが本当に入れ子になっていなければならない点です。1 つ前と依存を共有しないステップは、何も買いません。

> **よっしゃ！**
>
> **あの最後の 1 行が、スキップのフラグが失わせていたものです**。外部呼び出しを無効にすれば、同じ実行が緑のパイプラインと 5 つのツールの一覧を報告します。実施した検査はすべて同じように通り、誰も知らなかった 1 つの事実だけが現れなかったことになります。

## まとめ

**権限主体が 2 つあるので、機構も 2 つです**。クラウドプロバイダは誰がインフラを変更してよいかを決め、アプリケーションは誰が呼び出してよいかを決めます。1 つ目を担うのが GitHub OIDC で、ジョブを記述したトークンを一時的な AWS 認証情報に交換します。そのトークンの `sub` クレームは自分が書いた信頼条件と一致しなければならないので、条件とワークフローの契機は 2 つではなく 1 つの設計判断になります。

**2 つ目を担うのが `client_credentials` で、その価値は何を持たないかにあります**。人ではなくアプリケーションを認証することで、ユーザーも、静的なパスワードも、それを置いておく場所も不要になります。残るのは STS と同じ取引です。長命な認証情報は保護された場所に 1 つだけ持ち、通信に乗せるのは短命なトークンだけにします。

最後の部分は他より広く一般化できます。**各チェックが 1 つ前より多くのスタックに依存するように並べれば、失敗はそれが属する層を名指しします**。私たちの場合は 3 層を通過して 4 層目で止まり、そこが唯一、誰も答えを知らなかった層でした。スキップのフラグが残っていれば、3 層しか動かしていないのに 4 つの合格を報告していたはずです。

## 参考リンク
- [OpenID Connect リファレンス。sub クレームの形と、どの契機がどれを生むか](https://docs.github.com/actions/reference/openid-connect-reference)
- [AWS での OpenID Connect の設定。信頼ポリシーの形も含む](https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-aws)
- [AWS OIDC 連携の条件で犯しがちな間違い。ワイルドカードの subject とフォークについて](https://www.wiz.io/blog/avoiding-mistakes-with-aws-oidc-integration-conditions)
- [AgentCore のインバウンド JWT Authorizer の設定。allowedClients と client_id の照合について](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/inbound-jwt-authorizer.html)
- [Amazon Cognito のトークンエンドポイントと client_credentials グラント](https://docs.aws.amazon.com/cognito/latest/developerguide/token-endpoint.html)
