Day 15 收尾時留了一句話等著兌現:X-Tenant-Id 這些 trusted headers 是「信任 gateway 的 impersonation 輸入」,不是 authentication——任何能直連這支 API 的人都能自稱任何人。這天把呼叫端身份換成 Microsoft Entra ID 的 bearer token:身份不再是宣稱,是驗出來的。讀完你能說出這條驗證邊界的兩層分工,與 401、403 的契約線劃在哪。
還有一題貫穿全篇:授權規則為什麼長成「delegated scope 或 app role 擇一」——兩種 token 的授權 claim 天生不對稱,後面會用兩份真 token 的 claim 清單與官方文件對照解釋。
動工前我以為這天的主角是密碼學。實際寫完回頭看,驗簽那層是整天最便宜的部分,時間都花在簽章證明不了的事情上。這篇要講的就是那幾個裁決。
完整程式碼在 day-19 tag 上:
api/principal.py——兩個 resolver、401/403 的分界、授權規則services/entra_jwt.py——OIDC discovery、JWKS 快取、驗簽infra/scripts/create-entra-app.sh——一鍵建出兩個 app registration,含 teardownDay 15 建立的形狀這天沒有動:所有受保護的 endpoint 依賴同一個 require_principal,它產出 Principal(tenant、user、groups),下游的 ACL filter 與 conversation 授權全吃這個物件。這天換掉的是 Principal 從哪裡來——AUTH_MODE 在啟動時選一次 adapter,不是每個 request 看它帶了什麼來猜:
def build_initial_resolver(settings: Settings) -> PrincipalResolver:
if settings.auth_mode == "headers":
return HeaderPrincipalResolver()
return UninitializedResolver()
headers 模式維持 Day 15 行為,是開發預設;entra 模式在 lifespan 啟動時對租戶做 OIDC discovery、抓 JWKS,之後每個 request 驗簽並從 claim 組出 Principal:tid → tenant、oid → user、groups → Day 15 的 document ACL。同一個 Principal、同一套下游;下游程式碼甚至不知道身份的來源換了。
一個值得說的細節:entra 模式在 lifespan 跑完前裝的是 UninitializedResolver,被打到就直接 raise 500。這裡絕不能 fall back 到 headers——那等於在部署失誤的瞬間,把可偽造的 header 靜默升級成可接受的身份。
EntraTokenVerifier 的核心就是 PyJWT 一個呼叫:
claims = jwt.decode(
token,
key.key,
algorithms=["RS256"],
audience=self._audience,
issuer=self._issuer,
leeway=60,
# require 把三個 claim 釘成契約必在場。它獨力多擋的只有 exp:
# 缺這行,完全不帶 exp 的 token 會被當成永不過期而通過;
# iss/aud 少了它照樣被拒——上面的 issuer=/audience= 參數本來就
# 要求它們。對那兩個,這行是把契約講明,不是唯一防線。
options={"require": ["exp", "iss", "aud"]},
)
三個不在套件文件首頁、但決定這層可不可信的裁決:
issuer 的期望值是自己組的,不是從 discovery 文件讀的。 https://login.microsoftonline.com/{tenant_id}/v2.0——比對用的值必須來自你自己的設定。discovery 文件不是只被動提供 jwks_uri:它回報的 issuer 必須與這個期望值完全一致,過了這關它給的 jwks_uri 才被接受;而且抓 key 的 host 釘死在 login.microsoftonline.com,一份被汙染的 metadata 也不能把 key 下載引到別處。
aud 是 GUID,因為註冊時設了 requestedAccessTokenVersion: 2。 v2.0 access token 的 aud 一律是 API 的 client id(查核 2026-08,claims reference);v1.0 則可能是 client id 或 resource URI。要比對一個值,就先讓它只有一種形狀。
verifier 的契約止於簽章與註冊 claim。 require 管的是「必須在場」:iss、aud、exp 缺一即拒。驗證範圍比這三個大——在場的註冊時間 claim(nbf、iat)PyJWT 預設照驗,一個還沒生效的 token 一樣 401。它回傳 claims,不對 claims 的意義表態:哪個 claim 是 tenant、哪個是權限、缺了怎麼辦,全都不是它的問題。
verifier 不比 tid。這是整天最刻意的一個決定。
比 tid 的地方全 codebase 恰好一處——resolver 把 claims 組成 Principal 的那行。理由有兩層。第一,resolver 是唯一需要 tid 的層:Principal.tenant_id 從它來,順手比對是自然的。第二,也是更重要的:兩層都比只是冗餘,但「兩層各自以為對方比了」是 tenant bypass。與其讓一個檢查散在兩處、誰都不確定自己是不是最後防線,不如讓它有唯一的、寫在註解裡的 owner。這行消失,就沒有別的地方在比租戶,audit 時一目瞭然。
(iss 的 exact match 其實也釘了租戶,因為 issuer URL 內嵌 tenant GUID。所以 resolver 這行是第二層,不是唯一一層——但它是唯一一層「名字叫做 tid 檢查」的。)
錯誤契約沿用系列一貫的 envelope,兩個 code 各管一邊:
# 缺 header、格式壞、驗不過 —— 你是誰,我不知道
HTTPException(status_code=401,
detail={"code": "unauthorized", "message": "Missing or invalid credentials."},
headers={"WWW-Authenticate": "Bearer"})
# 驗過了,但缺這支 API 要求的權限 —— 我知道你是誰,就是不行
HTTPException(status_code=403,
detail={"code": "insufficient_scope",
"message": "The credential lacks the required API permission."},
headers={"WWW-Authenticate": 'Bearer error="insufficient_scope"'})
分界線可以壓成一句話:可信的身份建立了沒有。401 是沒建立——token 缺了、格式壞、簽章驗不過、過期、audience 錯,API 對「你是誰」拿不出可信的答案。403 是建立了但權限不夠:API 知道你是誰,也正因如此,client 該做的不是修憑證,是去換一個帶對權限的 token——這正是 RFC 6750 insufficient_scope challenge 想說的話。把兩者混成一個 401,client 就分不出「我該修憑證」還是「我該找管理員要權限」。
兩個容易做錯的邊角:
Authorization 拆不出兩段、X-Group-Ids 超長,這些是「你是誰」的失敗,不是 request body 驗證失敗。回 422 等於把身份層的錯誤混進資料層的詞彙。unauthorized(),訊息不提是 header 模式還是 JWT、不提是簽章壞還是 audience 錯——告訴一個未驗證的 caller 哪個檢查擋下它,是免費的偵察情報。這天的 live 驗證(2026-08-04,系列租戶)從兩個真 token 讀出各自的 claim key 清單,只記名字、不記值:
scp、有 groups,沒有 roles
roles,沒有 scp、沒有 groups
(取 token 也有規矩:client credentials 的 scope 只能是 {resource}/.default——本次=api://{appId}/.default——Entra 回的是已授與的 application permissions,app role 不能逐一點名;查核 2026-08,flow 文件。)
官方文件背書的是這半邊:scp「only included for user tokens」——app-only token 永遠不帶 scp(查核 2026-08,claims reference)。這個不對稱直接決定了授權層的三個形狀:
第一,規則只能是「scp 或 roles 擇一」。 app-only token 不帶 scp,要求兩者同時在場等於把所有 app-only caller 鎖在門外。所以 ENTRA_REQUIRED_SCOPE 與 ENTRA_REQUIRED_APP_ROLE 各自獨立設定,設了哪個就接受哪種憑證;沒設的那個永遠不匹配——「沒設定」的意思是「不接受這種憑證」,不是「不檢查」。
第二,scp 在場就走 scope gate,不准 fall through。 不對稱只有一半是鐵的:app-only 永遠不帶 scp;Entra 卻可以把 app role 指派給使用者,委派 token 可能同時帶 scp 與 roles——上面那份沒帶,只因這個使用者沒被指派 app role,單次觀測不是通則。所以才需要這條規則:scp 比對失敗若還能落到 app role gate,使用者 token 就能憑它從未被授與為 delegated scope 的權限進門。是委派就裁決到底,錯了就是 403。
第三,Day 15 的 group ACL 只在委派路徑上存在。 groups 只出現在使用者 token 裡——app-only caller 在 ACL 眼裡沒有群組身份,能看到的只有 tenant-wide 文件。這是 claim 形狀的直接後果,不是本專案選出來的:服務自己本來就不是任何群組的成員。
還有一組 fail-closed 裁決藏在 groups 的讀法裡,而且上限有兩層。第一層是本 API 自己的:groups 陣列超過 100 個就 401——這是防禦性的 wire cap,101 到 200 個群組的使用者在 Entra 還沒動用 overage 表示法之前,就先被這支 API 拒絕了。
第二層才是 Entra 的:使用者超過 200 個群組時,JWT 裡不發 groups,改發 _claim_names 指向 Graph(查核 2026-08,同上文件)。把這讀成「沒有群組」是最順手的寫法,也是最糟的——Day 15 的 filter 會把使用者有權看的文件藏起來,而且沒有任何錯誤告訴任何人。本 API 對兩層都選擇拒絕:cap 超限 401、看到 overage 訊號也 401。誠實的「我做不到」好過安靜的降權。
四關檢查加兩個 gate,攤開來是這個形狀:

左右兩區各只有一個拒絕出口,這不是排版巧合:分界線就是那句「可信的身份建立了沒有」。而右區那條打叉的虛線,是圖上唯一一條刻意不存在的邊。
忍喵:200 個群組很多嗎?去問問那種用 AD 群組管門禁、印表機和午餐訂購的企業——這條 401 在那裡不是邊角,是日常。
live 驗證裡最反直覺的一步不在程式碼,在租戶設定。
要測「403 是這支 API 拒絕了一個它已驗證的憑證」,你需要一個驗得過、但沒權限的 token。問題是:Entra 的 service principal 上有個屬性叫 appRoleAssignmentRequired(查核 2026-08,Graph servicePrincipal)——設成 true 時,沒有 app role assignment 的 app 拿不到 token。
這時你的測試會死在 token endpoint,收到的是 Entra 的拒發錯誤:這只證明了 Entra 會擋,完全沒證明你的 API 有授權層。哪怕你的 _require_permission 整個是空的,這個測試照樣「過」。
所以 provisioning 腳本明寫 appRoleAssignmentRequired: false(也是 Graph 的預設值):token endpoint 照發一個 audience 正確、但不帶 roles claim 的 token,403 於是證明了該證明的事。實測的 no-role 階段 token claim 清單裡確實沒有 roles——token 驗過了,API 還是說 403。
這是「測試要測到自己的程式碼」在 IdP 場景的具體形狀:上游擋得越早,你的防線就越測不到。 想測自己的 403,得先說服上游放行。
這天的 live 驗證全程 fake LLM/search/embeddings——沒有任何付費模型呼叫,也沒建立任何獨立計費的 Azure 資源——17 項斷言全過。
其中一項只有 live 測得到:拿一個 claim 裡確實帶 groups 的委派 token 打過成功的 /chat 之後(run 裡另一次成功的 /chat 用的是不帶 groups 的 app-only token),整份 server log 連 group 這個字串都沒出現——0 次。說清楚它證明了什麼:這是字面掃描,log 裡沒有任何帶 group 字樣的欄位或訊息;個別 group GUID 值沒有逐一比對,Day 15 的「group id 永不進 log」在這次 run 驗到的是這個字面事實。
但第一次跑 provisioning 就死了,而且死因值得寫下來。
腳本用 az ad app create --query "[appId,id]" -o tsv 一次拿兩個 id,再用 cut -f1/cut -f2 拆——假設雙元素陣列投影會輸出 tab 分隔的一行。實測 az 2.88.0(用唯讀的 az account show 配 od -c 量的):它是一值一行。
cut 逐行運作,兩行輸入的第 1 欄就是兩行都拿——兩個 id 變數於是裝著同一個「換行相連」的字串,下一個 Graph PATCH 的 URI 夾著換行送出去,回來一頁 HTML:Bad Request - Invalid URL。
這個錯誤最麻煩的地方:它發生在原因的下游,隔著一次已成功的 mutation。 錯誤頁隻字不提 parse,而 app registration 那時已經建好了。修法(day-19 tag 上)三件事一起做:
API_APP_PAIR="$(az ad app create ... --query "join(' ', [appId, id])" -o tsv)"
read -r PARSED_APP_ID PARSED_OBJECT_ID <<<"$API_APP_PAIR"
require_guid "API app id" "${PARSED_APP_ID:-}" "$ENTRA_API_APP_NAME"
join() 把兩個值收成一行;read -r 拆開;require_guid 在用任何 id 建任何東西之前驗形狀。未來 az 的輸出慣例再變,腳本會死在 parse 這行,錯誤訊息直接給 display name 和找回孤兒註冊的指令,而不是在三步之後留下一個誰也認不出的 HTML 錯誤加一個孤兒。
忍喵:-o tsv的輸出形狀從來不在你以為的地方壞掉。把 GUID 檢查放在 mutation 之前這一步學起來——孤兒資源的清理成本永遠比一行 regex 貴。
scp 在場=委派」是 proxy,不是證明。 一個沒有公開任何 delegated scope 的 app,其使用者 token 會帶 roles 而無 scp,將走進 app-only gate。實際擋住這件事的是租戶設定——allowedMemberTypes: ["Application"] 的角色不能 assign 給使用者;token 內能裁決的 claim 是 idtyp(optional claim),本日未採。login.microsoftonline.com——這是刻意的信任邊界選擇,代價是 authority host 不同的 sovereign cloud(如 Azure Government、由世紀互聯營運的 Azure 中國)會直接 startup 失敗。要支援得把 authority 變成設定,本日不做。AUTH_MODE=headers 仍是預設。 開發模式保留 Day 15 行為;OpenAPI 只文件化 bearerAuth,trusted headers 刻意不以 parameter 形式出現——不想讓任何工具鏈把開發用的信任輸入當成公開契約的一部分。呼叫端的身份收掉了:進來的每個 request 都對應一個驗過的 tid/oid。但往外看——這支 API 自己呼叫 Azure OpenAI 時,用的還是 .env 裡的一條 API key。呼叫端不再匿名了,服務自己還是。Day 20:Managed Identity 與 Key Vault——不要把 API key 寫進程式碼,以及 DefaultAzureCredential 的便利與它掩蓋的風險。
完整程式碼在 day-19 tag,CI 綠。
用到的 Azure 服務:Microsoft Entra ID(app registration ×2、token 簽發與 JWKS,2026-08-04 live 驗證後已 teardown)。本次 run 的增量支出為零:LLM/search/embeddings 全程 fake adapter、未建立任何獨立計費的 Azure 資源。
Entra 本身的授權(Free/P1/P2 等)屬租戶既有條件,不在本篇的增量計算內(查核 2026-08,Microsoft Entra pricing)。
本文由作者規劃與撰寫,AI(Claude)協助草稿整理與程式碼驗證;技術內容與觀點由作者確認並負責。