接 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>
184 lines
8.8 KiB
Markdown
184 lines
8.8 KiB
Markdown
# 交接檔:轉檔服務 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=<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 時要連這段註解一起改掉。
|