同一個 Observability MCP 有兩種呼叫者。值班工程師從 CLI 登入後查資料,Scheduler 則在沒有人操作時定期執行。兩枚 Access Token 都由同一個 AWS Cognito User Pool 簽發,我起初也很自然地把它們放進同一套 JWT 驗證規則。
真正攤開 Claims 後,兩條路徑的差異剛好落在 Gateway 不能含糊的地方。Human Token 有登入者的 sub,也能透過 Resource Binding 帶入目標 API 的 aud。Client Credentials 取得的 M2M Token 沒有 Human,AWS Cognito 也不支援同一套 Resource Binding。若 Gateway 要求所有 Token 都有指定的 sub 與 aud,合法的 Scheduler 會被擋掉。若乾脆取消 Audience 檢查,Human 路徑又失去原本的 Resource Boundary。
這次要拆開的是 App Client、Token 條件、Secret 輪替與 Gateway Policy。Issuer 和 JWKS 可以共用。值班工程師與 Scheduler 在下游留下的身分,卻不能共用同一套規則。
![]()
選定 AWS Cognito 之後,我們把 Human CLI 與 Scheduler 分別註冊成兩個 App Client。只用「Agent Client」當名稱,過幾個月後通常已看不出它代表登入者、Scheduler,還是某個 Runtime。兩條路的 Grant 和 Secret Lifecycle 也會跟著混在一起。
| 設定 | Human CLI | Scheduler M2M |
|---|---|---|
| App Client | Public | Confidential |
| Grant | Authorization Code | Client Credentials |
| Durable Secret | 不應存在 | 必須保存與輪替 |
| PKCE | S256 | 不適用 |
| Callback | Exact Allowlist | 不適用 |
| Token Principal | 登入的 Human | App Client/Workload |
AWS Cognito App Client 文件 將沒有 Client Secret 的應用視為 Public Client,有 Secret 的應用視為 Confidential Client。Client Credentials 只能使用 Confidential Client,而且不能和 Authorization Code 或 Implicit Grant 放在同一個 App Client。這不是偏好的命名方式,而是 AWS Cognito 的 App Client 規則從一開始就要求兩份設定。
Human CLI 使用沒有 Durable Secret 的 Public App Client,走 Authorization Code + PKCE。Callback 必須精確列入 Allowlist,Scope 也要同時出現在 Resource Server 與 App Client 設定中:
app client: sre-console
secret: none
grant: authorization_code
callback: http://127.0.0.1:8765/callback
scope: openid platform/observability.query
resource: https://observability.lab.example/mcp
Day 11 已經談過 localhost 與 127.0.0.1 的 Callback 坑。來到 AWS Cognito 後,新的重點是 resource。AWS Resource Binding 文件 說明 Managed Login 的 Authorization Code User Flow 可以把 Resource URL 寫入 Access Token 的 aud。下游除了驗證 Token 來自哪個 User Pool,也能確認它是不是簽給自己的。
Lab 中的 Human Token 保留以下 Claims:
{
"iss": "https://cognito-idp.ap-northeast-1.amazonaws.com/ap-northeast-1_LabPool",
"aud": "https://observability.lab.example/mcp",
"sub": "user/sre-oncaller",
"client_id": "sre-console",
"scope": "platform/observability.query",
"token_use": "access"
}
sub 指向登入者,client_id 保留入口,aud 限制目標服務。AWS Cognito Access Token 不保證 Header 一定有 typ=at+jwt,因此驗證時不能為了套用通用 JWT Profile 而自行補造 Provider 沒有承諾的欄位。這條路徑改用 AWS Cognito 的 token_use=access 區分 Access Token 與 ID Token,再檢查 Audience、Client 與 Scope。
Scheduler 不經過 Browser、Callback 或 Human Consent。它使用另一個 Confidential App Client,以 Client Credentials 取得 App-only Token:
app client: sre-scheduler
secret: required
grant: client_credentials
scope: platform/observability.query
AWS Cognito M2M 文件 將這條路徑限制在 Resource Server 定義的 Custom Scope。Token Endpoint 只回 Access Token,不回 ID Token 或 Refresh Token。這份 Lab 使用的 Claims 也刻意維持最小集合:
{
"iss": "https://cognito-idp.ap-northeast-1.amazonaws.com/ap-northeast-1_LabPool",
"client_id": "sre-scheduler",
"scope": "platform/observability.query",
"token_use": "access"
}
這裡沒有登入者,Audit 的 Human 欄位應記成 NOT_APPLICABLE,Machine Actor 則由驗證過的 client_id 映射成 client/sre-scheduler。即使未來 Token 多出 sub,也不能只憑欄位名稱把 Machine Subject 當成人類身分。
M2M 路徑同時多了一份長期責任:Client Secret 必須保存、輪替與停用。AWS Cognito App Client 可以重疊保留新舊 Secret,讓 Workload 先切換到新值,再撤掉舊值。不過把 Secret 放進 Kubernetes Secret 並不代表問題已經解決。如果它曾進入 Image Layer、Git、Terminal History 或權限過寬的 Terraform State,外面再包一層 Secret Object 也補不回已經外洩的 Credential。
AWS Cognito 對 Resource Binding 的限制讓兩份 Policy 無法再假裝相同。Human User Flow 可以要求 API-specific aud,Client Credentials M2M 不支援這個參數。因此共同驗證層只處理兩邊都成立的條件,再依已驗證的 client_id 進入不同的 Authorization Rule:
共同條件:signature + iss + exp + token_use=access
Human:client_id + sub + aud + scope
M2M: client_id + scope,Human 不適用

在 agentgateway 設定裡,aud 不能放進兩條路徑共同必填的 Claims。兩條 CEL Rule 先確認 token_use == "access",再各自處理 Human 與 M2M:
mcpAuthorization:
rules:
- >-
jwt.token_use == "access" &&
jwt.client_id == "sre-console" &&
has(jwt.sub) &&
jwt.aud == "https://observability.lab.example/mcp" &&
jwt.scope.split(" ").exists(s, s == "platform/observability.query")
- >-
jwt.token_use == "access" &&
jwt.client_id == "sre-scheduler" &&
!has(jwt.aud) &&
jwt.scope.split(" ").exists(s, s == "platform/observability.query")
Scope 在 AWS Cognito Access Token 裡是以空白分隔的字串,Policy 要檢查其中是否包含指定值,不能把整串 Scope 做 Equality。requiredClaims 與 Authorization Policy 也負責不同事情。前者確認標準 Claim 是否存在,Provider-specific Claim 的值與路徑分流應留在 CEL Rule。
這份範例明確拒絕帶有意外 aud 的 M2M Token,因為本篇沒有使用 Pre-token Trigger 改寫它。若平台未來決定替 M2M 加入 Audience,Token Contract、Gateway Policy 與 Regression Test 都要一起更新,不能只改 IdP 後就期待 Gateway 自行理解新語意。
完整 Gateway 設定放在 agentgateway-cognito.yaml。公開 Lab 使用合成 Token 重跑分流。真正的 Managed Login 與 JWKS 輪替仍需連到可拋棄的 AWS 環境。
Terraform 範例將 Human 與 M2M 建成兩個獨立 aws_cognito_user_pool_client。Human 設定 generate_secret=false 與 Authorization Code,M2M 則設定 generate_secret=true 與 Client Credentials。完整 HCL 放在 cognito-terraform,正文不再逐段複製。
本輪只執行 terraform validate,沒有對 AWS Apply。正式套用前還要處理 AWS 權限、Domain 與 Federation 設定。generate_secret=true 也會讓 M2M Secret 進入 Terraform State,因此 Remote State 的加密與存取權限不能省略。
最容易出錯的不是兩條成功路徑,而是把 Human 規則套到 Scheduler。Human 的 sub、aud 與 Scope 可以把登入者和目標 API 對起來。M2M 應由已驗證的 client_id 表示。若把 aud 放進兩者共用的必填欄位,合法排程會被拒絕。若為了排程移除所有 Audience 條件,Human Token 的目標邊界也會跟著鬆開。
Day 12 的合成 Token 對照 跑過這兩條路,也刻意讓 Public Client 嘗試 M2M、讓 M2M 要求不支援的 Resource Binding。結果把前面設定差異變成看得到的拒絕位置。完整指令、九組案例與原始紀錄留在 Lab README 和結果檔,日後盤點新 App Client 可直接使用 Human/M2M 雙路徑盤點表。

這組公開結果只涵蓋離線分流與設定檢查。真正的 AWS User Pool 登入、JWKS 輪替和 Terraform Apply 仍須在可拋棄的 AWS 環境另行驗證。
做到這裡,Human 與 M2M 已能共用 AWS Cognito Issuer、JWKS 與同一個 Gateway,同時保留不同的 App Client、Audience Rule、Credential Lifecycle 與 Audit Principal。這解決的是 Token 進入 Gateway 後的驗證與分流。
MCP Client 在送出 Token 前,仍要找到 Authorization Server,也要取得可用的 Client Registration。Resource Server Only Mode 不會自動替平台處理 Pre-registration、Client Metadata、Discovery 與 Provider Adapter。這些工作由誰維護,已經不是單一 JWT Rule 能回答的問題。
下一篇會把視角從 Token 拉回平台選型。我們當初從 LiteLLM 轉向 agentgateway,真正改變決定的不是又多了一個 Routing 功能,而是 Kubernetes 交付、Declarative API、Policy、Audit、Discovery 與升級責任開始一起進入 Operating Model。