visionA/docs/autoflow/04-architecture/converter-promote-oauth-handoff.md
jim800121chen c2f0b1549e feat: OIDC 登入修復(email fallback / prompt=login / logout 連動)+ 真轉檔鏈路 e2e
接 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>
2026-06-27 03:18:59 +08:00

8.8 KiB
Raw Blame History

交接檔:轉檔服務 promote 401 根因 + 修法OAuth client 認證送法)

對象:維護 kneron_model_converter 的工程師 來源visionA 端Orchestrator 實測定位) 狀態:根因已精確定位並實測排除其他可能,待轉檔服務側修正 最後更新2026-06-22 語言zh-TW


0. 一句話結論

轉檔服務的 OAuth client 用 HTTP Basic Auth 把 client 認證送給 Member CenterMCOpenIddictMC 拒絕 Basic Auth、只接受 client_id / client_secret 放在 POST form bodyOAuth2 的 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

MCOpenIddict不接受 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 bodyclient_secret_post body 含 client_id / client_secret / grant_type / scope Authorization header 成功拿到 token。JWT 解出 scope: files:upload.writeaud: file_access_api
HTTP Basic Authclient_secret_basic Authorization: Basic base64(client_id:client_secret)body 只有 grant_type/scope 401 invalid_client「The specified client credentials are invalid.」
  • client_id4242ba63099d4f318dd3f143d27ef4c5
  • MC token endpointhttps://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.writeform 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 真檔(約 800KBpromote 卡的純粹是 OAuth 換 token 那一步

唯一變因就是 client 認證的送法Basic header vs form body


4. 要改的位置與改法

檔案

apps/task-scheduler/src/auth/oauthClient.js

當前 code 位置(已確認,行號為現況)

  1. buildBasicAuthHeader()lines 9195— 產生 Basic base64(id:secret)

    function buildBasicAuthHeader(clientId, clientSecret) {
      const raw = `${clientId}:${clientSecret}`;
      return `Basic ${Buffer.from(raw, 'utf8').toString('base64')}`;
    }
    
  2. _fetchToken() 內組 body / headers 的地方lines 299309

    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),  // ← 問題在這
    };
    
  3. 檔頭 design 註解lines 1924目前寫死「使用 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 specRFC 6749 §2.3.1)允許的兩種 client 認證方式之一(client_secret_postMC 接受這種。

安全注意(務必保留)

  • 原本「絕不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.writeaud: 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 methodclient_secret_basic / client_secret_post)切換,預設走 post。但目前最簡單、足夠解決問題的做法就是直接改成 form body,不需要為此加複雜度。
  • 改完記得更新 oauthClient.js 檔頭 design 註解lines 1924說明改用 client_secret_post,否則註解與實作不一致,未來容易被誤改回 Basic。

附錄:與我描述略有出入的實際 code 結構(供對方校正)

  • buildBasicAuthHeader() 實際在 lines 9195(描述為 ~9194
  • 認證 header 實際送出處在 lines 305309headers 物件)/ 316321fetchAuthorization 設定在 line 308(描述為 ~300308大致吻合
  • 額外發現body 除了 grant_type / scope,還帶了 audience: config.faaAudienceline 302。改成 form body 時,是在這個既有 body 上新增 client_id / client_secret,不是憑空新建 body。
  • 檔頭 lines 1924 的 design 註解明文寫「使用 HTTP Basic auth header」並引 RFC 6749 §2.3.1 當理由 —— 這是當初的設計決策,改 code 時要連這段註解一起改掉。