# 用 GitHub Actions 自己簽發 Cognito M2M token，驗證剛部署好的 MCP — 一個工作要兩種身分

> GitHub OIDC 給了 CI 一個 AWS 身分，但經過 JWT 授權的 MCP 端點還需要第二個：從 terraform output 讀出的 Cognito client_credentials token。

- Source: https://oharu121.com/zh-tw/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 驗證。部署是一個問題。部署完之後要呼叫它，卻是完全另一個問題，因為**讓 CI 能建出這個端點的憑證，沒辦法用來跟它對話**。

這不是某個產品的怪癖。這樣的管線做的是兩件不同的事：它變更基礎設施，由雲端供應商授權；它使用應用程式，由應用程式自己授權。權限來源有兩個，所以需要兩份憑證。

本文依序整理這兩套機制：AWS 控制平面用的 GitHub OIDC，以及端點用的 Cognito `client_credentials` token。最後談那個讓測試在什麼都沒檢查的情況下回報成功的錯誤。

## 一份憑證為什麼不夠

代管這個伺服器的 Amazon Bedrock AgentCore Runtime，**對進來的呼叫只接受 OAuth/JWT**。它沒有 SigV4 的路徑，所以 AWS 憑證根本無法呼叫它。沒有帶認證的請求會拿回 `401`，並附上 `WWW-Authenticate: Bearer` 標頭。

也就是說，CI 本來就有的那個 AWS 身分可以建立 runtime、讀出它的 ARN、改它的設定，卻沒辦法對它發出任何一次呼叫。

*Figure — TwoIdentities: 一個工作，兩份憑證。AWS 身分能到的是控制平面；能到端點的只有 OAuth 身分。*

講精確一點：**這不是兩個 AWS 角色**。其中一份憑證是 AWS 的主體。另一份是使用者集區簽發的 OAuth 存取 token，而 AWS IAM 完全不參與它的驗證。

## 機制 1：AWS 控制平面用的 GitHub OIDC

OIDC 要解的問題是，CI 需要 AWS 憑證，卻不該儲存任何一份。做法是由 GitHub 發給正在執行的工作一個描述它自己的短效 JSON Web Token。AWS 再透過 `sts:AssumeRoleWithWebIdentity` 把這個 token 換成臨時憑證。

AWS 帳戶裡要有兩樣東西。一個是對應 `token.actions.githubusercontent.com` 的 IAM OIDC 身分提供者，建一次就好。另一個是信任政策接受來自它的 token 的角色：

```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"
      }
    }
  }]
}
```

這兩個資源怎麼建其實沒那麼重要，而很多文章正是在這裡毫無理由地寫得很細。重要的是最後一行那個 `sub` 條件。

### sub 宣告描述的是工作在哪裡執行

`sub` 是 GitHub 對「正在索取憑證的這個工作負載」所做的陳述，而**它的形式會隨著工作被什麼觸發而不同**：

| 工作的執行方式 | `sub` 的值 |
| --- | --- |
| 參照了 GitHub Environment | `repo:ORG/REPO:environment:production` |
| 由 pull request 事件觸發，沒有 Environment | `repo:ORG/REPO:pull_request` |
| 以上皆非 | `repo:ORG/REPO:ref:refs/heads/main` |

Environment 存在時優先於其他兩種。pull request 的執行，不論是哪一個 PR 都會產生同一個 `pull_request` 值，裡面沒有編號。每次執行的細節放在 `ref` 與 `job_workflow_ref` 這兩個宣告裡。

*Figure — SubClaim: 同一份工作流程會因為觸發方式而產生不同的 subject。釘在其中一種形式上的信任條件，會拒絕另外兩種。*

實務上的後果是：**信任條件與工作流程的觸發方式必須一起設計**。把條件釘在 `environment:production`，工作就得宣告那個 Environment，否則 `sub` 會以 ref 的形式出現，角色會拒絕。失敗會在 assume role 的時候以一般的存取被拒送達，完全不會提到是哪個宣告沒對上。

釘在 Environment 上是推薦的做法，不只是其中一個選項。GitHub Environment 可以帶必要審查者與分支限制，所以核可的關卡與信任條件描述的是同一件事。

> **小心喔**
>
> **`repo:ORG/REPO:*` 不是讓它能動的正確做法**。用萬用字元當 subject，會接受那個 repo 的任何分支、任何 Environment、任何觸發方式，`pull_request` 的執行也包含在內。fork 就是這樣拿到你的部署角色的。

安全審查會特別點出這個模式，也會點出完全不寫 `sub` 條件的情況。後者會讓這個角色被 GitHub 上任何一份工作流程 assume。如果一個角色真的必須服務好幾種情境，就用 `StringLike` 列出你自己挑的那些值，而不是列 `*`。

2026 年 7 月 15 日之後建立的 repo，還會拿到一個不可變的預設 subject，裡面放的是擁有者與 repo 的 **ID** 而不是名稱，所以改名不會再弄壞信任政策。在寫條件之前，先確認自己的 repo 用的是哪一種格式是值得的。

工作流程那一側很小，兩行就撐起來了：

```yaml title=".github/workflows/deploy.yml"
jobs:
  deploy:
    environment:
      name: production      # 決定 sub 宣告的第三段
    permissions:
      id-token: write       # 沒有這一行就不會簽發任何 OIDC token
    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` 是*索取* token 的權限，不是寫入任何東西的權限。漏掉它是這裡另一個安靜的失敗，而且從工作的角度看，它和一份壞掉的信任政策長得一模一樣。

## 機制 2：端點用的 client_credentials token

接著是第二個權限來源。端點驗證的是 Cognito 使用者集區簽發的 JWT，所以 CI 需要一個來自那個集區的 token。

最直覺的做法是錯的。使用者集區的密碼授權認證的是**一個人**，所以要從 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"
  }
}
```

在動手寫之前有兩個細節值得知道。`openid`、`profile` 與 `email` 對 `client_credentials` 來說都不是有效的範圍，所以那份清單裡只該放資源伺服器的自訂範圍。另外這個流程不會簽發 refresh token，所以也沒有相關設定要做。

### 和 STS 一樣的形狀

讓這兩套機制看起來像同一個想法、而不是兩件雜事的，就是這一段。

**兩者都是拿一個長期的身分，去換一份會過期的憑證。**`AssumeRoleWithWebIdentity` 收下 GitHub 的 token，回傳能撐過這個工作的 AWS 憑證。token 端點收下用戶端 ID 與密鑰，回傳一小時有效的存取 token。兩種情況下出現在線路上的東西都是短效的，而且兩種情況下 **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 的後端有加密也有存取控制時這是可以接受的；如果它是某個人筆電上的一個檔案，那就不可以接受。

端點那一側只有一行設定。AgentCore 會把 token 的 `client_id` 宣告拿去和一份清單比對，這正是機器 token 在這裡能用的原因。`client_credentials` 的 token 帶有 `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]
  }
}
```

## 讓冒煙測試說真話

兩套機制都通了之後，測試的內容是：開一個工作階段、列出工具、呼叫一個很簡單的工具，再呼叫一個會連到外部 API 的工具。

AI 代理把最後那個呼叫接上時，是放在一個 flag 後面、預設關閉的。當時端點沒有設定任何對外連線的路徑，所以那個呼叫本來就會失敗，跳過它就能讓管線保持綠燈。我否決了這個做法，因為一次跳過自己唯一真實依賴的執行不算結果：

> the only thing I care is if it really tell us the mcp works or not

flag 被拿掉了。為了讓這樣做是安全的，我也要求那個步驟改成回報而不是把關，因為一個尚未釐清的網路問題把建置弄紅，只會教人忽略 CI：

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

接著是測試自己的兩個 bug，兩個產生的失敗都看起來像是關於受測系統的發現。

**token 被簽發出來又被丟掉**。重試的迴圈長這樣：

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

`VAR=value cmd` 是把一個賦值前置到命令上，再一起傳進去。`VAR=value && cmd` 是一個賦值*陳述式*，後面接著另一個獨立的命令，所以變數只設在 shell 裡，從來沒有被 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'
```

**錯誤被解析掉了**。token 有 export 之後，呼叫跑起來了，然後死在 `json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)`。當一個工具在 MCP 伺服器裡拋出例外，**例外訊息會以那個工具的 content 回來**。把那份 content 當成 JSON 解析，會摧毀錯誤，換成一句在抱怨它第一個字元的話：

```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)
```

## 在最後一行掉下來的那次執行

兩個都修好之後的那次執行，回報了一件真實的事：

```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)'
```

**每一行依賴的堆疊都嚴格多於上面那一行**。這就是讓第一個失敗有資訊量、而不只是變紅的原因：

| 行 | 通過它排除了什麼 |
| --- | --- |
| `token obtained` | 機器用戶端、資源伺服器、範圍，以及密鑰的傳遞全都正確 |
| `tools: 5 listed` | JWT authorizer 接受了 `client_id` 宣告，協定交握也完成了 |
| `ping: ok` | 映像從它所在的位置被拉下來、容器啟動了，而且跑的是我們的程式碼 |
| 外部呼叫 | 什麼都沒排除：失敗的就是這一層 |

所以這個失敗自己把位置定了出來。對外的呼叫走到了 TLS 交握才拒絕 TLS 憑證，那既不是逾時也不是 DNS 失敗：**對外連線是通的，而路徑上有東西在替換憑證**。四層裡有三層不再有疑問，第四層還附帶一段具體到可以拿去搜尋的錯誤字串。

這樣的排序值得刻意設計，而不是碰巧變成這樣。**每個步驟各加一層依賴的測試，會告訴你它在哪裡壞掉；一次複合式的呼叫，只會告訴你它壞了**。代價是這些步驟必須真的層層相依，所以一個和前一步沒有共同依賴的步驟什麼也買不到。

> **讚啦**
>
> **那最後一行，正是那個跳過 flag 會讓你損失的東西**。把外部呼叫關掉，同一次執行會回報一條綠色的管線和五個列出來的工具。它做過的每一項檢查都還是會通過，而唯一沒人知道的那個事實不會出現。

## 總結

**兩套機制，因為有兩個權限來源**。雲端供應商決定誰可以變更基礎設施，應用程式決定誰可以呼叫它。第一個由 GitHub OIDC 負責，做的是把一個描述工作的 token 換成臨時的 AWS 憑證。那個 token 裡的 `sub` 宣告必須對上你寫的信任條件，所以條件與工作流程的觸發方式是一個設計決定，不是兩個。

**第二個由 `client_credentials` 負責，而它的價值在於它省掉了什麼**。認證應用程式而不是人，等於移除了那個使用者、那組固定密碼，以及你本來得找地方保管它的那個位置。剩下的就是 STS 做的同一個取捨：把一份長期憑證放在受保護的地方，線路上只出現短效的 token。

最後這部分的一般性比其他段落更廣。**把檢查排成每一項都比前一項依賴更多堆疊，失敗就會指名它所在的那一層**。我們的例子通過了三層，停在第四層，而那正是唯一沒人知道答案的一層。如果那個跳過 flag 還在，它會在只運作三層的情況下回報四項通過。

## 參考連結
- [OpenID Connect 參考文件：sub 宣告的各種形式，以及哪種觸發方式產生哪一種](https://docs.github.com/actions/reference/openid-connect-reference)
- [在 Amazon Web Services 設定 OpenID Connect，含信任政策的樣子](https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-aws)
- [避免在 AWS OIDC 整合條件上犯錯，談萬用字元 subject 與 fork](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 的 token 端點與 client_credentials 授權類型](https://docs.aws.amazon.com/cognito/latest/developerguide/token-endpoint.html)
