iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0
AI Engineering

Backend 工程師的 Azure GenAI 實戰系列 第 19

Day 19:使用 Microsoft Entra ID 做 API 身份驗證——驗簽不是難題,簽章證明不了的才是

  • 分享至 

  • xImage
  •  

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 上:

換的是 resolver,不是整個邊界

Day 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 組出 Principaltid → 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 管的是「必須在場」:issaudexp 缺一即拒。驗證範圍比這三個大——在場的註冊時間 claim(nbfiat)PyJWT 預設照驗,一個還沒生效的 token 一樣 401。它回傳 claims,不對 claims 的意義表態:哪個 claim 是 tenant、哪個是權限、缺了怎麼辦,全都不是它的問題。

tid 誰來比:一個檢查要有唯一的 owner

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 檢查」的。)

401 與 403 的分界線:可信的身份建立了沒有

錯誤契約沿用系列一貫的 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 就分不出「我該修憑證」還是「我該找管理員要權限」。

兩個容易做錯的邊角:

  • header 語法錯誤是 401,不是 422。 Authorization 拆不出兩段、X-Group-Ids 超長,這些是「你是誰」的失敗,不是 request body 驗證失敗。回 422 等於把身份層的錯誤混進資料層的詞彙。
  • 401 的訊息刻意不說哪裡失敗。 兩個 resolver 共用同一個 unauthorized(),訊息不提是 header 模式還是 JWT、不提是簽章壞還是 audience 錯——告訴一個未驗證的 caller 哪個檢查擋下它,是免費的偵察情報。

兩種 token 天生不同形——授權規則是被 claim 形狀決定的

這天的 live 驗證(2026-08-04,系列租戶)從兩個真 token 讀出各自的 claim key 清單,只記名字、不記值:

  • 委派 token(device code flow,代表一個使用者):有 scp、有 groups沒有 roles
  • app-only token(client credentials,服務自己):有 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_SCOPEENTRA_REQUIRED_APP_ROLE 各自獨立設定,設了哪個就接受哪種憑證;沒設的那個永遠不匹配——「沒設定」的意思是「不接受這種憑證」,不是「不檢查」。

第二,scp 在場就走 scope gate,不准 fall through。 不對稱只有一半是鐵的:app-only 永遠不帶 scp;Entra 卻可以把 app role 指派給使用者,委派 token 可能同時帶 scproles——上面那份沒帶,只因這個使用者沒被指派 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,攤開來是這個形狀:

Entra bearer token 的驗證出口圖:左半是身份區(可信的身份建立了沒有,沒建立就是 401)——resolver 先解析 Authorization header(恰好一個、不超過 16 KiB、Bearer 加非空 token),交給 verifier 驗 RS256 簽章與 iss/aud/exp(契約止於此,不對 claim 的意義表態),再由 resolver 讀 claims 做全 codebase 唯一一處 tid 比對、oid 與 groups(overage 訊號一律拒);任一關不過都落到同一個 401 unauthorized,帶 WWW-Authenticate: Bearer,訊息刻意不說是哪一關擋的。右半是權限區(驗過了但夠不夠,不夠就是 403)——claims 裡有 scp 走 scope gate、沒有 scp 走 app role gate,兩個 gate 通過都得到 Principal(tenant/user/groups)接上 Day 15 的 ACL 與對話授權,不通過都落到 403 insufficient_scope;scope gate 指向 app role gate 的那條虛線畫著打叉記號,代表這條 fall-through 邊不存在:是委派就裁決到底

左右兩區各只有一個拒絕出口,這不是排版巧合:分界線就是那句「可信的身份建立了沒有」。而右區那條打叉的虛線,是圖上唯一一條刻意不存在的邊。

https://ithelp.ithome.com.tw/upload/images/20260819/20168288EDs5almwwX.png
忍喵:200 個群組很多嗎?去問問那種用 AD 群組管門禁、印表機和午餐訂購的企業——這條 401 在那裡不是邊角,是日常。

要讓 403 證明得了東西,得先讓 Entra 願意發一個沒權限的 token

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 run 抓到的 bug,在我們自己的腳本裡

這天的 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 -f1cut -f2 拆——假設雙元素陣列投影會輸出 tab 分隔的一行。實測 az 2.88.0(用唯讀的 az account showod -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 錯誤加一個孤兒。

https://ithelp.ithome.com.tw/upload/images/20260819/20168288hXsu1U3AuF.png
忍喵-o tsv 的輸出形狀從來不在你以為的地方壞掉。把 GUID 檢查放在 mutation 之前這一步學起來——孤兒資源的清理成本永遠比一行 regex 貴。

這天的誠實邊界

  • scp 在場=委派」是 proxy,不是證明。 一個沒有公開任何 delegated scope 的 app,其使用者 token 會帶 roles 而無 scp,將走進 app-only gate。實際擋住這件事的是租戶設定——allowedMemberTypes: ["Application"] 的角色不能 assign 給使用者;token 內能裁決的 claim 是 idtyp(optional claim),本日未採。
  • groups 的兩層上限都是 fail-closed,不是解決。 超過 100 個群組(本 API 的 wire cap)或觸發 Entra overage(JWT 超過 200)的使用者會直接 401。正解需要 Graph lookup,本日範圍外。
  • 本實作只針對 Microsoft 公有雲。 issuer 與 JWKS 的 host 釘死 login.microsoftonline.com——這是刻意的信任邊界選擇,代價是 authority host 不同的 sovereign cloud(如 Azure Government、由世紀互聯營運的 Azure 中國)會直接 startup 失敗。要支援得把 authority 變成設定,本日不做。
  • claim key 清單是一個租戶、一天、一組設定下的觀測。 官方明說 claim 可能增減、應用程式不該硬依賴某個 claim 一定在場——上面的推論依據的是「哪類 token 帶哪類授權 claim」這個文件化行為,不是假設 claim 清單永遠長這樣。
  • smoke 驗的是身份邊界,不是端到端。 LLM/search/embeddings 全是 fake adapter;這天要證明的是 401/403/200 與 log 行為,模型輸出不在斷言裡。
  • AUTH_MODE=headers 仍是預設。 開發模式保留 Day 15 行為;OpenAPI 只文件化 bearerAuth,trusted headers 刻意不以 parameter 形式出現——不想讓任何工具鏈把開發用的信任輸入當成公開契約的一部分。

下一篇

呼叫端的身份收掉了:進來的每個 request 都對應一個驗過的 tidoid。但往外看——這支 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)協助草稿整理與程式碼驗證;技術內容與觀點由作者確認並負責。


上一篇
Day 18:把 Agent 包成 Backend API——一個 run 算幾個 turn、預算下在哪,以及誰的權限在跑工具
系列文
Backend 工程師的 Azure GenAI 實戰19
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言