# 交接檔:轉檔服務 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`: ```json { "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 位置(已確認,行號為現況) 1. **`buildBasicAuthHeader()`**(lines 91–95)— 產生 `Basic base64(id:secret)`: ```js function buildBasicAuthHeader(clientId, clientSecret) { const raw = `${clientId}:${clientSecret}`; return `Basic ${Buffer.from(raw, 'utf8').toString('base64')}`; } ``` 2. **`_fetchToken()` 內組 body / headers 的地方**(lines 299–309): ```js 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 19–24)目前寫死「使用 HTTP Basic auth header」並引 RFC 6749 §2.3.1 —— 改完一併更新,避免下一個人又改回去。 ### 改法(最小修正:Basic header → form body) 把 `client_id` / `client_secret` 從 Basic header 移進 body,移除 `Authorization` header: ```js 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) ```bash 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=' ``` 預期:回 `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 時要連這段註解一起改掉。