基本上 SSO 這段到現在都還有問題。整包在幹嘛,講得不清不楚。發了不能用的 token 以後,我們要去查是什麼狀況,還要看有沒有時間查。
在 34 篇我寫過一句:「key rotation 沒做……這是待辦,不是取捨——我只是還沒撞到需要換 key 的那天。」那篇的架構是 Kong 一個地方驗 token,後端只信 Kong 塞進來的 header。但很多公司的微服務是每個服務各自驗 token,這時候能出錯的地方就多了。
錯誤訊息只有一行,原因通常是這三個之一
服務拿到 token 之後,驗證函式庫會逐項比對:誰發的、發給誰、用哪把鑰匙簽的、過期了沒。任何一項對不上,就只回你一行錯誤。下面是我用 jose(Node 最常用的 JWT 函式庫之一)實測拿到的原文:
| 原因 | 錯誤訊息(jose 6.2.12) | 怎麼修 |
|---|---|---|
| token 被轉給不該收它的服務(aud) | ERR_JWT_CLAIM_VALIDATION_FAILED unexpected "aud" claim value | 用 Token Exchange 換一把給下游的 token |
| IdP 換了鑰匙,公鑰快取沒更新(kid) | ERR_JWKS_NO_MATCHING_KEY no applicable key found in the JSON Web Key Set | 遇到不認得的 kid 就重抓公鑰,並限制重抓頻率 |
| 內外網的網址不一樣(iss) | ERR_JWT_CLAIM_VALIDATION_FAILED unexpected "iss" claim value | IdP 固定只用一個網址當 issuer |
換一個函式庫,錯誤訊息的字面會不同,但會指向同一個欄位:aud、kid 或 iss。
怎麼重現
我在本機用 docker compose 起了三個東西:
- Keycloak 26.8.0:發 token 的 IdP(身分提供者)。
- 服務 A、服務 B:兩個 Node 小服務。服務 B 驗 token 時比對簽章、
iss、aud,驗不過就回 401,並把函式庫的錯誤訊息印進 log。
每個原因都跑三次:先讓它壞,確認錯誤訊息真的是那個原因;再修好,確認變成 200;最後不清環境再跑一次,確認還是 200。三個原因都通過。完整的腳本和證據在我的 scenario-lab 情境 G-15。
原因一:token 被轉給不該收它的服務(aud)
token 裡有一個欄位叫 aud(audience),寫的是「這把 token 是發給誰用的」。規格要求,收到 token 的一方必須在 aud 裡找到自己,找不到就要拒絕(RFC 7519 §4.1.3)。
壞的樣子: 前端登入拿到一把 aud 是服務 A 的 token。服務 A 呼叫服務 B 時,把這把 token 原封不動轉過去。服務 B 在 aud 裡找不到自己:
code=ERR_JWT_CLAIM_VALIDATION_FAILED message="unexpected "aud" claim value" got_aud="svc-a"修法: 服務 A 不直接轉,改用 Token Exchange(RFC 8693),拿手上的 token 向 Keycloak 換一把給服務 B 用的。換到的 token,aud 是服務 B,azp(實際來要 token 的 client)是服務 A,服務 B 就驗得過。
Keycloak 這邊有兩個條件,官方文件寫得很清楚:
- 拿去換的那把 token,
aud裡必須有發出請求的服務。所以前端的 token 要帶aud=svc-a,服務 A 才能拿它去換。 - 請求裡的
audience參數不會新增對象,只會從服務 A 本來就被允許的對象裡挑。所以要先在 Keycloak 設定「服務 A 可以換給服務 B」。
另一種做法是讓前端的 token 一開始就把所有下游服務都寫進 aud。這樣不用換,但一把 token 能打所有服務,aud 就失去限縮的意義(RFC 8707)。
原因二:IdP 換了鑰匙,公鑰快取沒更新(kid)
IdP 用私鑰簽 token,下游服務用公鑰驗。公鑰放在 IdP 公開的一個網址上,叫 JWKS(JSON Web Key Set)。每把 token 的 header 裡有一個 kid(key ID),告訴驗證方「我是用哪一把鑰匙簽的」。規格要求,IdP 換鑰匙時用 kid 告訴驗證方;驗證方看到不認得的 kid,要重新去抓 JWKS(OIDC Core §10.1.1)。
壞的樣子: 服務 B 啟動時抓一次公鑰,之後永遠不重抓。接著我在 Keycloak 新增一把優先序更高的鑰匙,新發的 token 就用新鑰匙簽。服務 B 手上只有舊公鑰:
code=ERR_JWKS_NO_MATCHING_KEY message="no applicable key found in the JSON Web Key Set"這種壞法的特徵是「某個時間點起全部失敗」,而且重啟服務就會好。重啟會重抓公鑰,看起來像修好了,下次換鑰匙又會再壞一次。
修法: 改用 jose 的 createRemoteJWKSet,遇到不認得的 kid 就自己重抓,兩次重抓至少隔 5 秒。驗證時我刻意不重啟:先讓它抓過、等過冷卻,再換一次鑰匙,它自己跟上了。
限制重抓頻率很重要。我另外送了 20 把 kid 是亂寫的假 token,服務 B 一次都沒有多抓。不限速的話,任何人送亂寫 kid 的 token,就能讓服務 B 一直去打 IdP。
34 篇的 Kong 把公鑰寫死在設定檔裡,就是這種壞法的極端版本:換鑰匙那天,所有 token 一起失效。
原因三:內外網的網址不一樣(iss)
token 裡的 iss(issuer)寫的是「誰發的」,規格要求必須跟預期的值完全相同(OIDC Core §3.1.3.7)。差一個字、多一個結尾的 /、http 跟 https,都算不同。
壞的樣子: Keycloak 沒有固定網址時,iss 會跟著「你從哪個網址連進來」走。使用者從外部網址登入,iss 是外部網址;服務 B 在容器裡,設定的是內部網址:
code=ERR_JWT_CLAIM_VALIDATION_FAILED message="unexpected "iss" claim value" got_iss="http://127.0.0.1:18580/realms/g15"修法: Keycloak 設定固定的 hostname(官方文件,環境變數 KC_HOSTNAME),內外拿到的 iss 就一樣;服務 B 期望的 iss 也設成同一個值。
這個原因會連帶讓原因一的修法失效。 我手動試過一次:iss 是外部網址的 token,服務 A 拿去 Keycloak 的內部網址換,Keycloak 回 400 invalid_request,log 寫的是 subject_token validation failure。兩個問題同時存在時,只修 aud 會看起來「修了還是壞」。
看到錯誤時怎麼查
- 先看錯誤訊息指的是哪個欄位:
aud、kid(或 no matching key)、iss。 - 把 token 在本機解開看那個欄位的實際值:JWT 的前兩段是 base64 編碼的文字,不用密鑰就能解開。不要貼到外部網站,token 就是登入憑證。
- 對照服務設定的預期值:
aud有沒有自己、iss一個字一個字比、kid在不在 IdP 現在的 JWKS 裡。 - 用時間點對照,沿著 infra 每一層看有沒有卡住:這是我們實際在用的查法。從 token 被拒絕的那個時間點,往前沿著 request 經過的每一層(入口、gateway、各個服務、IdP)看那段時間有沒有異常。
這篇沒涵蓋的
- 時鐘不準造成的過期判斷錯誤(
exp、nbf)。規格允許留幾分鐘誤差(RFC 7519 §4.1.4)。 - 演算法不符(
alg),例如 IdP 改用別的演算法,服務只接受舊的。 - 拿登入用的 ID token 去打 API。ID token 的
aud是前端,不是 API(RFC 9068)。
相關文章
- 回本章:B10 認證與授權
- 前一篇的架構:34 Gateway 驗 JWT、後端只信 header
- OAuth 與 OIDC 的關係:OAuth 2.0 入門