基本上 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 valueIdP 固定只用一個網址當 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 會看起來「修了還是壞」。

看到錯誤時怎麼查

  1. 先看錯誤訊息指的是哪個欄位:aud、kid(或 no matching key)、iss。
  2. 把 token 在本機解開看那個欄位的實際值:JWT 的前兩段是 base64 編碼的文字,不用密鑰就能解開。不要貼到外部網站,token 就是登入憑證。
  3. 對照服務設定的預期值:aud 有沒有自己、iss 一個字一個字比、kid 在不在 IdP 現在的 JWKS 裡。
  4. 用時間點對照,沿著 infra 每一層看有沒有卡住:這是我們實際在用的查法。從 token 被拒絕的那個時間點,往前沿著 request 經過的每一層(入口、gateway、各個服務、IdP)看那段時間有沒有異常。

這篇沒涵蓋的

  • 時鐘不準造成的過期判斷錯誤(exp、nbf)。規格允許留幾分鐘誤差(RFC 7519 §4.1.4)。
  • 演算法不符(alg),例如 IdP 改用別的演算法,服務只接受舊的。
  • 拿登入用的 ID token 去打 API。ID token 的 aud 是前端,不是 API(RFC 9068)。

相關文章