用 GitHub Actions 自己簽發 Cognito M2M token,驗證剛部署好的 MCP — 一個工作要兩種身分
GitHub OIDC 給了 CI 一個 AWS 身分,但經過 JWT 授權的 MCP 端點還需要第二個:從 terraform output 讀出的 Cognito client_credentials token。

本頁目錄
引言
有人要我把一個 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、改它的設定,卻沒辦法對它發出任何一次呼叫。
講精確一點:這不是兩個 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 的角色:
{ "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 這兩個宣告裡。
實務上的後果是:信任條件與工作流程的觸發方式必須一起設計。把條件釘在 environment:production,工作就得宣告那個 Environment,否則 sub 會以 ref 的形式出現,角色會拒絕。失敗會在 assume role 的時候以一般的存取被拒送達,完全不會提到是哪個宣告沒對上。
釘在 Environment 上是推薦的做法,不只是其中一個選項。GitHub Environment 可以帶必要審查者與分支限制,所以核可的關卡與信任條件描述的是同一件事。
安全審查會特別點出這個模式,也會點出完全不寫 sub 條件的情況。後者會讓這個角色被 GitHub 上任何一份工作流程 assume。如果一個角色真的必須服務好幾種情境,就用 StringLike 列出你自己挑的那些值,而不是列 *。
2026 年 7 月 15 日之後建立的 repo,還會拿到一個不可變的預設 subject,裡面放的是擁有者與 repo 的 ID 而不是名稱,所以改名不會再弄壞信任政策。在寫條件之前,先確認自己的 repo 用的是哪一種格式是值得的。
工作流程那一側很小,兩行就撐起來了:
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-1id-token: write 是索取 token 的權限,不是寫入任何東西的權限。漏掉它是這裡另一個安靜的失敗,而且從工作的角度看,它和一份壞掉的信任政策長得一模一樣。
機制 2:端點用的 client_credentials token
接著是第二個權限來源。端點驗證的是 Cognito 使用者集區簽發的 JWT,所以 CI 需要一個來自那個集區的 token。
最直覺的做法是錯的。使用者集區的密碼授權認證的是一個人,所以要從 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" }}在動手寫之前有兩個細節值得知道。openid、profile 與 email 對 client_credentials 來說都不是有效的範圍,所以那份清單裡只該放資源伺服器的自訂範圍。另外這個流程不會簽發 refresh token,所以也沒有相關設定要做。
和 STS 一樣的形狀
讓這兩套機制看起來像同一個想法、而不是兩件雜事的,就是這一段。
兩者都是拿一個長期的身分,去換一份會過期的憑證。AssumeRoleWithWebIdentity 收下 GitHub 的 token,回傳能撐過這個工作的 AWS 憑證。token 端點收下用戶端 ID 與密鑰,回傳一小時有效的存取 token。兩種情況下出現在線路上的東西都是短效的,而且兩種情況下 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 的後端有加密也有存取控制時這是可以接受的;如果它是某個人筆電上的一個檔案,那就不可以接受。
端點那一側只有一行設定。AgentCore 會把 token 的 client_id 宣告拿去和一份清單比對,這正是機器 token 在這裡能用的原因。client_credentials 的 token 帶有 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] }}讓冒煙測試說真話
兩套機制都通了之後,測試的內容是:開一個工作階段、列出工具、呼叫一個很簡單的工具,再呼叫一個會連到外部 API 的工具。
AI 代理把最後那個呼叫接上時,是放在一個 flag 後面、預設關閉的。當時端點沒有設定任何對外連線的路徑,所以那個呼叫本來就會失敗,跳過它就能讓管線保持綠燈。我否決了這個做法,因為一次跳過自己唯一真實依賴的執行不算結果:
the only thing I care is if it really tell us the mcp works or not
flag 被拿掉了。為了讓這樣做是安全的,我也要求那個步驟改成回報而不是把關,因為一個尚未釐清的網路問題把建置弄紅,只會教人忽略 CI:
- name: Invoke the endpoint continue-on-error: true接著是測試自己的兩個 bug,兩個產生的失敗都看起來像是關於受測系統的發現。
token 被簽發出來又被丟掉。重試的迴圈長這樣:
BEARER_TOKEN="$(./token.sh)" && python3 client.pyVAR=value cmd 是把一個賦值前置到命令上,再一起傳進去。VAR=value && cmd 是一個賦值陳述式,後面接著另一個獨立的命令,所以變數只設在 shell 裡,從來沒有被 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'錯誤被解析掉了。token 有 export 之後,呼叫跑起來了,然後死在 json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)。當一個工具在 MCP 伺服器裡拋出例外,例外訊息會以那個工具的 content 回來。把那份 content 當成 JSON 解析,會摧毀錯誤,換成一句在抱怨它第一個字元的話:
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)在最後一行掉下來的那次執行
兩個都修好之後的那次執行,回報了一件真實的事:
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)'每一行依賴的堆疊都嚴格多於上面那一行。這就是讓第一個失敗有資訊量、而不只是變紅的原因:
| 行 | 通過它排除了什麼 |
|---|---|
token obtained | 機器用戶端、資源伺服器、範圍,以及密鑰的傳遞全都正確 |
tools: 5 listed | JWT authorizer 接受了 client_id 宣告,協定交握也完成了 |
ping: ok | 映像從它所在的位置被拉下來、容器啟動了,而且跑的是我們的程式碼 |
| 外部呼叫 | 什麼都沒排除:失敗的就是這一層 |
所以這個失敗自己把位置定了出來。對外的呼叫走到了 TLS 交握才拒絕 TLS 憑證,那既不是逾時也不是 DNS 失敗:對外連線是通的,而路徑上有東西在替換憑證。四層裡有三層不再有疑問,第四層還附帶一段具體到可以拿去搜尋的錯誤字串。
這樣的排序值得刻意設計,而不是碰巧變成這樣。每個步驟各加一層依賴的測試,會告訴你它在哪裡壞掉;一次複合式的呼叫,只會告訴你它壞了。代價是這些步驟必須真的層層相依,所以一個和前一步沒有共同依賴的步驟什麼也買不到。
總結
兩套機制,因為有兩個權限來源。雲端供應商決定誰可以變更基礎設施,應用程式決定誰可以呼叫它。第一個由 GitHub OIDC 負責,做的是把一個描述工作的 token 換成臨時的 AWS 憑證。那個 token 裡的 sub 宣告必須對上你寫的信任條件,所以條件與工作流程的觸發方式是一個設計決定,不是兩個。
第二個由 client_credentials 負責,而它的價值在於它省掉了什麼。認證應用程式而不是人,等於移除了那個使用者、那組固定密碼,以及你本來得找地方保管它的那個位置。剩下的就是 STS 做的同一個取捨:把一份長期憑證放在受保護的地方,線路上只出現短效的 token。
最後這部分的一般性比其他段落更廣。把檢查排成每一項都比前一項依賴更多堆疊,失敗就會指名它所在的那一層。我們的例子通過了三層,停在第四層,而那正是唯一沒人知道答案的一層。如果那個跳過 flag 還在,它會在只運作三層的情況下回報四項通過。





