- date
- entry
- 031
- topic
- infrastructure
- rev
- —
2026 年 7 月 15 日後建的 GitHub repo,OIDC 的 sub 多了兩個 ID
同一行 trust policy 在舊 repo 能用、新 repo 不行。差別是 GitHub 在新 repo 的 sub 裡加了不會變的 owner ID 與 repo ID。
用 GitHub Actions 部署到 AWS,我一律用 OIDC:workflow 向 GitHub 要一張短效的身分 token,拿去 STS 換 IAM role 的臨時憑證,repo 裡不放任何長期金鑰。role 的 trust policy 除了限定 GitHub 這個身分提供者與 audience,還會用 sub(subject)限定是哪個 repo 的哪個分支,長這樣:repo:<owner>/<repo>:ref:refs/heads/main。這行我從舊專案抄過好幾次,一直沒出過事。
2026 年 7 月 28 日替一個新 repo 開部署角色,同樣抄過去,連續三次手動觸發部署,都在取得 AWS 憑證那一步失敗:Could not assume role with OIDC: Not authorized to perform sts:AssumeRoleWithWebIdentity。錯誤訊息沒說是哪個條件沒過。這篇要談的是原因:那個 repo 拿到的 sub 長得不一樣了。依序回答四件事:實際的 sub 是什麼、GitHub 改了什麼、寫 policy 之前怎麼先查、為什麼萬用字元救不回來。
實際的 sub
三次失敗的 job 紀錄還在。每次 configure-aws-credentials 那一步都印了 12 行 Assuming role with OIDC,才報錯結束,三次分別花了 71、96、55 秒。當時用的 v4 版本預設最多嘗試 12 次(含第一次;它的 action.yml 對 retry-max-attempts 的說明寫的是「By default it will retry 12 times」),中間是帶隨機性的指數退避。所以 trust policy 寫錯,每次要等將近一到一分半鐘才看到同一句錯誤。
我加了一支臨時 workflow,只做一件事:要一張 audience 是 sts.amazonaws.com 的 token,解開中間那段 payload 印出來。當時那支印了七個 claim,用 base64 -d 解碼;下面是整理過的版本,只印三個欄位,改用 Node 的 base64url 解碼(各家 base64 -d 對沒補 padding 的 base64url 處理不一,我在 macOS 上試,少了 padding 的那段結尾會被截掉)。
on: workflow_dispatch
permissions:
id-token: write
jobs:
claims:
runs-on: ubuntu-latest
steps:
- shell: bash
run: |
set -o pipefail
TOKEN=$(curl -fsS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -er '.value')
printf '%s' "$TOKEN" | node -e '
let s = ""; process.stdin.on("data", c => s += c);
process.stdin.on("end", () => {
const { sub, aud, iss } = JSON.parse(
Buffer.from(s.split(".")[1], "base64url").toString("utf8"));
console.log(JSON.stringify({ sub, aud, iss }, null, 2));
});'
它印出來的 sub:
"sub": "repo:yuxue1213@18254762/<repo>@1314600209:ref:refs/heads/main"
(repo 名稱是客戶專案,我遮掉了,其餘照抄。)owner 和 repo 名稱後面各多了一個 @ 加數字。StringEquals 比對的是整串字,少了這兩段就對不上。(那支 workflow 只印挑出來的 claim,不印 token 本身;用完就刪了。)
GitHub 改了什麼
GitHub 在 2026 年 4 月 23 日的 changelog〈Immutable subject claims for GitHub Actions OIDC tokens〉宣布了這件事。重點有四個:
- 預設的
sub在名稱後面接上不會變的 owner ID 與 repo ID,例如repo:octocat@123456/my-repo@456789:ref:refs/heads/main。 - 2026 年 7 月 15 日之後建立的 repo 自動使用新格式;在那之後改名或轉移的 repo 也會改用新格式。
- 既有的 repo 只要沒有在那之後改名或轉移,就維持舊格式,除非在 repo 或組織的 OIDC 設定裡自己打開。
- 只適用 github.com,不影響 GitHub Enterprise Server。
理由是名稱可以被回收:舊格式下,如果原本的 owner/repo 這個名稱被別人重新取得(例如帳號或組織名稱被回收、repo 刪掉後重建),新的 repo 在同樣的 ref 條件下就會拿到一模一樣的 sub,而雲端那邊的 trust policy 還信任這個名字。加上 ID 之後,同名不同 repo 的 token 對不上。
時間對得上:gh api repos/yuxue1213/<repo> 回的 created_at 是 2026-07-28T06:01:39Z,在 7 月 15 日之後。我拿來抄的那個舊 repo 建於 2025 年 5 月,所以舊格式在那邊一直能用。另外,同一篇 changelog 在 6 月 10 日改過一次:分隔符號原本寫的是 -,後來改成 @。如果某篇教學照 6 月 10 日以前的公告寫成 -,要改成現在的 @。
寫 policy 之前先查
GitHub 的 REST API 有一支端點直接告訴你這個 repo 的 sub 前綴,不用跑 workflow:
$ gh api repos/yuxue1213/<repo>/actions/oidc/customization/sub
{"use_default":true,"use_immutable_subject":true,
"sub_claim_prefix":"repo:yuxue1213@18254762/<repo>@1314600209"}
$ gh api repos/yutuo-tech/homepage/actions/oidc/customization/sub
{"use_default":true,"use_immutable_subject":false,
"sub_claim_prefix":"repo:yutuo-tech/homepage"}
use_immutable_subject 是 true 的,前綴就帶 ID。我另一個 10 月 9 日建的 repo 也是 true,前綴是 repo:yutuo-tech@210807359/taiwanrep@1411624306。兩個數字分別等於 gh api users/<owner>(組織用 orgs/<org>)與 gh api repos/<owner>/<repo> 回的 id。這些 ID 本身不是秘密,能看到那個 repo 的人都查得到(OIDC 設定那支端點對私有 repo 則需要相應的權限)。
trust policy 的條件就照這個前綴寫。我的情境是 push 到 main、沒有用 environment,所以後面接 :ref:refs/heads/main;用了 environment 或是 PR 事件,sub 的後半段不一樣,要另外核對。下面是 trust policy 裡 Condition 那一段:
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub":
"repo:yuxue1213@18254762/<repo>@1314600209:ref:refs/heads/main"
}
}
為什麼在舊前綴後面加萬用字元也對不上
卡住的時候直覺是放寬條件,例如改成 StringLike 加 repo:yuxue1213/<repo>:*。這也對不上。* 只能代換它所在的位置,而新格式的差異在中間:模式要求 yuxue1213 後面緊接著 /,實際的字串在那裡是 @18254762/。用 Python 的 fnmatch 模擬(它和 IAM 的 StringLike 一樣用 * 和 ?,但多支援方括號,這裡用不到;repo 名稱以 REPO 代替):
>>> from fnmatch import fnmatchcase
>>> sub = "repo:yuxue1213@18254762/REPO@1314600209:ref:refs/heads/main"
>>> fnmatchcase(sub, "repo:yuxue1213/REPO:*")
False
>>> fnmatchcase(sub, "repo:yuxue1213@18254762/REPO@1314600209:*")
True
>>> fnmatchcase("repo:yuxue1213x@999/REPO-evil@1:ref:refs/heads/main",
... "repo:yuxue1213*/REPO*:*")
True
要寫出兩種格式都會過的萬用字元,就得在名稱後面也放 *,例如 repo:yuxue1213*/REPO*:*。這等於把加上 ID 想擋的東西又放回來,而且比原本更鬆:上面最後一行,一個叫 yuxue1213x 的帳號底下、叫 REPO-evil 的 repo 也會過。需要過渡期時,可以在 StringEquals 的 sub 值放一個陣列,同時列出新舊兩個完整字串(同一個鍵的多個值是 OR),切換確認之後再拿掉舊的。
打開既有 repo 的新格式之前,先把新的前綴加進 trust policy,再切換;順序反過來,切換之後新簽發的 token 都換不到 STS 憑證,部署從下一次開始失敗。repo 改名或轉移之後,前綴也會變,那時要回來改 policy。
如果只記得一件事
trust policy 的 sub 不要從舊專案抄,先用 gh api repos/<owner>/<repo>/actions/oidc/customization/sub 查這個 repo 實際的前綴。開頭那三次失敗加起來將近四分鐘,等到的都是同一句沒有說原因的錯誤,而前綴在一支不用跑 workflow 的 API 裡就查得到,剩下的只是依觸發情境接上後半段。
修訂紀錄
- 首次發布