接 DB 後真人 OIDC 登入暴露 MC OIDC provider 實作不完整,visionA 端逐項繞過, 讓登入/換帳號可用;另補真轉檔服務的整合 e2e。 OIDC 登入修復(MC 端根因另有交接檔,visionA 先繞過): - email fallback:MC id_token 不發 email claim(ASP.NET Identity 預設 factory 只發 sub/name)→ A7 email 必填擋住登入。callback email 空時用 <sub>@noemail.visiona.local placeholder,不污染 schema,MC 修好發真 email 後 ON CONFLICT 自動覆寫 - prompt=login:authorize 帶 prompt=login(config VISIONA_OIDC_PROMPT_LOGIN,預設關) - logout 連動 MC:logout 回 idp_logout(MC Web :7880 /account/logout,GET),前端用 隱藏 iframe 觸發清 MC session(Web/Api 共享 DataProtection)→ 能換帳號。 config VISIONA_OIDC_LOGOUT_URL、向下相容(未設則只清本地) 真轉檔鏈路 e2e(//go:build realconv,按需對 stage 跑、不污染主測試集): - real_converter_e2e:give 真轉檔服務 contract(init→poll→completed/promote/result) - real_chain_e2e:真轉檔→PromoteToModels→model 進 PG→冪等 全鏈路(對 stage 跑 PASS) 交接檔(給對應團隊根治): - mc-email-claim-handoff:MC 加 email claim(自訂 UserClaimsPrincipalFactory) - converter-promote-oauth-handoff:轉檔服務 OAuth 用 form body 非 Basic Auth 全程 Reviewer 審查 + 對 stage 真環境驗證。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
8.8 KiB
交接檔:轉檔服務 promote 401 根因 + 修法(OAuth client 認證送法)
對象:維護
kneron_model_converter的工程師 來源:visionA 端(Orchestrator 實測定位) 狀態:根因已精確定位並實測排除其他可能,待轉檔服務側修正 最後更新:2026-06-22 語言:zh-TW
0. 一句話結論
轉檔服務的 OAuth client 用 HTTP Basic Auth 把 client 認證送給 Member Center(MC,OpenIddict),但 MC 拒絕 Basic Auth、只接受 client_id / client_secret 放在 POST form body(OAuth2 的 client_secret_post 方式)。把認證從 Basic header 改成 form body 即可解決。憑證、scope、endpoint、真轉檔全部正常,不需要動。
1. 現象
visionA「轉檔 → 進模型庫」鏈路的最後一步 promote 一直失敗:
- visionA 呼叫轉檔服務
POST /api/v1/jobs/{id}/promote - 回應
500:{ "error": { "code": "internal_error", "message": "promote 過程發生未預期錯誤" } }
scheduler log 證據(依序)
oauth.token_endpoint_error scope:"files:upload.write" status:401 error_code:"invalid_client"
promote.faa_put_failed OAuthClientError 401
→ 對外回 500
也就是說:promote 階段去 MC 換 service token 時,MC 回 401 invalid_client,導致後續 FAA PUT 拿不到 token,最終轉檔服務對 visionA 回 500。
2. 精確根因
apps/task-scheduler/src/auth/oauthClient.js 取 token 時,把 client 認證放在 HTTP Basic Auth header:
Authorization: Basic base64(client_id:client_secret)
而 body 只帶 grant_type / scope / audience。
MC(OpenIddict)不接受 Basic Auth 形式的 client 認證,只接受 client_id / client_secret 放在 application/x-www-form-urlencoded 的 POST body(即 OAuth2 spec 的 client_secret_post token endpoint auth method)。
實測對照表(同一組 client_id + 同一個 secret,打同一個 MC endpoint)
| 認證送法 | 請求內容 | MC 回應 |
|---|---|---|
form body(client_secret_post) |
body 含 client_id / client_secret / grant_type / scope,無 Authorization header |
✅ 成功拿到 token。JWT 解出 scope: files:upload.write、aud: file_access_api |
HTTP Basic Auth(client_secret_basic) |
Authorization: Basic base64(client_id:client_secret),body 只有 grant_type/scope |
❌ 401 invalid_client「The specified client credentials are invalid.」 |
- client_id:
4242ba63099d4f318dd3f143d27ef4c5 - MC token endpoint:
https://stage-9527.innovedus.com:7850/oauth/token - 兩種方式用的是完全相同的 client_id + secret,差別只在「認證放哪」。
結論:
invalid_client不是「憑證錯」,是「MC 不認 Basic Auth 這種送法」。OpenIddict 預設行為即如此(client 須在 client registration 設定允許的 auth method;此 client 走 form body)。
3. 已排除的其他可能(請不要往這些方向查,會浪費時間)
| 懷疑點 | 結論 | 證據 |
|---|---|---|
| 憑證錯(client_id / secret 不對) | ❌ 不是 | 轉檔服務 container 內的 KNERON_CONVERTER_CLIENT_SECRET 與 warrenchen 給的 secret sha256 完全一致;且 form body 方式用同一組憑證能成功換到 token |
| scope 沒授權 | ❌ 不是 | MC 已授權此 client files:upload.write;form body 方式換到的 token JWT 內 scope 即含 files:upload.write |
| token endpoint URL 設錯 | ❌ 不是 | 轉檔服務設的 MEMBER_CENTER_TOKEN_URL 與實測成功的 URL 相同(.../oauth/token) |
| FAA audience 錯 | ❌ 不是 | form body 換到的 token aud: file_access_api,與預期一致 |
| 真轉檔(KTC)有問題 | ❌ 不是 | 真 KTC 轉檔已成功,產出 nef 真檔(約 800KB);promote 卡的純粹是 OAuth 換 token 那一步 |
唯一變因就是 client 認證的送法(Basic header vs form body)。
4. 要改的位置與改法
檔案
apps/task-scheduler/src/auth/oauthClient.js
當前 code 位置(已確認,行號為現況)
-
buildBasicAuthHeader()(lines 91–95)— 產生Basic base64(id:secret):function buildBasicAuthHeader(clientId, clientSecret) { const raw = `${clientId}:${clientSecret}`; return `Basic ${Buffer.from(raw, 'utf8').toString('base64')}`; } -
_fetchToken()內組 body / headers 的地方(lines 299–309):const body = new URLSearchParams({ grant_type: 'client_credentials', scope, audience: config.faaAudience, }).toString(); const headers = { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', Authorization: buildBasicAuthHeader(config.clientId, config.clientSecret), // ← 問題在這 }; -
檔頭 design 註解(lines 19–24)目前寫死「使用 HTTP Basic auth header」並引 RFC 6749 §2.3.1 —— 改完一併更新,避免下一個人又改回去。
改法(最小修正:Basic header → form body)
把 client_id / client_secret 從 Basic header 移進 body,移除 Authorization header:
const body = new URLSearchParams({
grant_type: 'client_credentials',
client_id: config.clientId, // ← 新增
client_secret: config.clientSecret, // ← 新增
scope,
audience: config.faaAudience,
}).toString();
const headers = {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
// 不再送 Authorization Basic header
};
buildBasicAuthHeader()改完後若無其他使用處可移除(連同_internals的 export 與相關測試)。- 這是 OAuth2 spec(RFC 6749 §2.3.1)允許的兩種 client 認證方式之一(
client_secret_post),MC 接受這種。
安全注意(務必保留)
- 原本「絕不把
client_secret/ token / Authorization 內容寫入 log」的約束必須維持。client_secret移進 body 後,一樣不能出現在任何 log(注意:URLSearchParams字串、body變數、錯誤訊息都不可被 log 出來)。 - 檔內
logEvent()目前不 log body,維持即可;新增/修改時別不小心把body帶進 log fields。 tryParseOauthErrorBody/ error log 維持只揭露status+ 標準error_code,不要為了 debug 把 request body dump 出來。
5. 改完怎麼驗
驗法 A:直接 curl MC(最快,先確認 MC 端接受 form body)
curl -s -X POST 'https://stage-9527.innovedus.com:7850/oauth/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-H 'Accept: application/json' \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode 'client_id=4242ba63099d4f318dd3f143d27ef4c5' \
--data-urlencode 'client_secret=<從環境變數帶入,勿貼進 shell history>' \
--data-urlencode 'scope=files:upload.write' \
--data-urlencode 'audience=<faaAudience,對應 config.fileAccessAgent.audience>'
預期:回 200 + { access_token, token_type, expires_in, ... }。把 access_token 丟 jwt.io 解,應看到 scope: files:upload.write、aud: file_access_api。
安全提醒:
client_secret不要直接貼在指令裡(會進 shell history)。用--data-urlencode "client_secret=$KNERON_CONVERTER_CLIENT_SECRET"從環境變數帶。
驗法 B:重跑 promote(端到端)
改完部署後,由 visionA 重新觸發一次轉檔 → promote:
- scheduler log 應從
oauth.token_endpoint_error status:401變成oauth.token_obtained scope:"files:upload.write"。 - promote 對 visionA 回
200(不再 500)。 - nef 成功推進 FAA / 模型庫。
6. 小提醒(可選,非必要)
- 若想更穩健,可讓
oauthClient用 config 支援兩種 auth method(client_secret_basic/client_secret_post)切換,預設走post。但目前最簡單、足夠解決問題的做法就是直接改成 form body,不需要為此加複雜度。 - 改完記得更新
oauthClient.js檔頭 design 註解(lines 19–24)說明改用client_secret_post,否則註解與實作不一致,未來容易被誤改回 Basic。
附錄:與我描述略有出入的實際 code 結構(供對方校正)
buildBasicAuthHeader()實際在 lines 91–95(描述為 ~91–94)。- 認證 header 實際送出處在 lines 305–309(headers 物件)/ 316–321(fetch),
Authorization設定在 line 308(描述為 ~300–308,大致吻合)。 - 額外發現:body 除了
grant_type/scope,還帶了audience: config.faaAudience(line 302)。改成 form body 時,是在這個既有 body 上新增client_id/client_secret,不是憑空新建 body。 - 檔頭 lines 19–24 的 design 註解明文寫「使用 HTTP Basic auth header」並引 RFC 6749 §2.3.1 當理由 —— 這是當初的設計決策,改 code 時要連這段註解一起改掉。