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

184 lines
8.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 交接檔:轉檔服務 promote 401 根因 + 修法OAuth client 認證送法)
> 對象:維護 `kneron_model_converter` 的工程師
> 來源visionA 端Orchestrator 實測定位)
> 狀態:根因已精確定位並實測排除其他可能,待轉檔服務側修正
> 最後更新2026-06-22
> 語言zh-TW
---
## 0. 一句話結論
轉檔服務的 OAuth client 用 **HTTP Basic Auth** 把 client 認證送給 Member CenterMCOpenIddict**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`。
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 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 真檔(約 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)`
```js
function buildBasicAuthHeader(clientId, clientSecret) {
const raw = `${clientId}:${clientSecret}`;
return `Basic ${Buffer.from(raw, 'utf8').toString('base64')}`;
}
```
2. **`_fetchToken()` 內組 body / headers 的地方**lines 299309
```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 1924目前寫死「使用 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 specRFC 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 1924說明改用 `client_secret_post`,否則註解與實作不一致,未來容易被誤改回 Basic。
---
## 附錄:與我描述略有出入的實際 code 結構(供對方校正)
- `buildBasicAuthHeader()` 實際在 **lines 9195**(描述為 ~9194
- 認證 header 實際送出處在 **lines 305309headers 物件)/ 316321fetch**`Authorization` 設定在 **line 308**(描述為 ~300308大致吻合
- **額外發現**body 除了 `grant_type` / `scope`,還帶了 `audience: config.faaAudience`line 302。改成 form body 時,是在這個**既有 body** 上新增 `client_id` / `client_secret`,不是憑空新建 body。
- 檔頭 lines 1924 的 design 註解明文寫「使用 HTTP Basic auth header」並引 RFC 6749 §2.3.1 當理由 —— 這是當初的設計決策,改 code 時要連這段註解一起改掉。