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

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

白色卡片上的黑色 Model Context Protocol M 形標誌與字樣
本頁目錄

引言

有人要我把一個 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、改它的設定,卻沒辦法對它發出任何一次呼叫。

一個 CI 工作,兩種身分左側的 GitHub Actions 工作卡片持有兩份獨立憑證。上方是以 OIDC token 交換而來的 AWS 憑證,連往 AWS 控制平面的卡片。下方是 Cognito 的 M2M 存取 token,連往 AgentCore MCP 端點的卡片。端點卡片上有一條橫帶說明此處沒有 SigV4 路徑、進站驗證只有 OAuth/JWT,因此 AWS 憑證無法到達。GitHub Actions 工作以 OIDC token 交換AWS 憑證Cognito M2M存取 token以 AssumeRoleWithWebIdentity取得 SigV4 簽章的憑證AWS 控制平面terraform apply 與讀取 output帶 Authorization: Bearer 標頭,以 allowedClients 驗證AgentCore MCP 端點每次呼叫都帶 Bearer token這裡沒有 SigV4 路徑。進站驗證只有 OAuth/JWT。
一個工作,兩份憑證。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 的角色:

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 Environmentrepo:ORG/REPO:environment:production
由 pull request 事件觸發,沒有 Environmentrepo:ORG/REPO:pull_request
以上皆非repo:ORG/REPO:ref:refs/heads/main

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

OIDC subject 宣告的三種形式三列,每一列對應一種觸發情境。參照 GitHub Environment 的工作會產生以 :environment:production 結尾的 subject,被接受。沒有 Environment 而由 pull request 觸發時,產生以 :pull_request 結尾的 subject,被拒絕。其餘情況產生以 :ref:refs/heads/main 結尾的 subject,同樣被拒絕。註記說明用萬用字元放寬條件會同時接受這三種,包含從 fork 來的執行。同一份工作流程,三種可能的 subject參照了 GitHubEnvironmentrepo:ORG/REPO:environment:production接受由 pull request 觸發,沒有 Environmentrepo:ORG/REPO:pull_request拒絕以上皆非repo:ORG/REPO:ref:refs/heads/main拒絕固定成第一種形式的信任條件,只接受那一個字串,其他都不接受。用萬用字元放寬會同時接受這三種,連從 fork 來的執行也會通過。
同一份工作流程會因為觸發方式而產生不同的 subject。釘在其中一種形式上的信任條件,會拒絕另外兩種。

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

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

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

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

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

.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 正是為這種情況存在:它認證的是應用程式而不是人,所以整件事裡根本沒有使用者。這個授權類型需要一個擁有被索取範圍的資源伺服器,以及一個被允許索取那個範圍的用戶端:

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 都不保存屬於自己的長期憑證。

終端機視窗
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 如何取得並出示機器 token五個編號步驟由上往下排列。第一步,CI 從 Terraform state 讀出用戶端 ID 與密鑰。第二步,以 client_credentials 授權類型與資源伺服器範圍 POST 到 Cognito 的 token 端點。第三步,取得帶有 client_id 宣告、沒有 aud 宣告的存取 token。第四步,以 Authorization Bearer 標頭呼叫 Runtime。第五步,AgentCore 把 client_id 與 allowedClients 清單比對。底部註記說明這和 STS 一樣的取捨:長效憑證只放一份在加密的後端,線路上只出現短效 token,CI 本身不保存任何憑證。1從 state 讀出用戶端憑證terraform output -raw m2m_client_id / m2m_client_secret2POST 到 Cognito 的 token 端點grant_type=client_credentials, scope=mcp-hub/invoke3取得短效存取 token帶有 client_id 宣告、沒有 aud 宣告,一小時後過期4以 Bearer 標頭呼叫 RuntimeAuthorization: Bearer,走一般 HTTPS5Authorizer 驗證 token把 client_id 與允許清單比對和 STS 一樣的取捨:長效憑證只放一份在加密的後端,線路上只出現短效 token。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 的後端有加密也有存取控制時這是可以接受的;如果它是某個人筆電上的一個檔案,那就不可以接受。

端點那一側只有一行設定。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.py

VAR=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 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 listedJWT authorizer 接受了 client_id 宣告,協定交握也完成了
ping: ok映像從它所在的位置被拉下來、容器啟動了,而且跑的是我們的程式碼
外部呼叫什麼都沒排除:失敗的就是這一層

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

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

總結

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

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

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

參考連結

分享這篇文章