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>
This commit is contained in:
jim800121chen 2026-06-27 03:18:59 +08:00
parent dc1c0dbee4
commit c2f0b1549e
16 changed files with 2057 additions and 19 deletions

View File

@ -0,0 +1,183 @@
# 交接檔:轉檔服務 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 時要連這段註解一起改掉。

View File

@ -0,0 +1,189 @@
# 交接文件MC id_token 缺 email claim導致 visionA 建 user 500
## 作者Architect AgentvisionA 端)
## 對象Member Center 團隊
## 狀態:待 MC 修復
## 最後更新2026-06-26
---
## 1. 一句話總結
**MCMember Center發出的 OIDC id_token 永遠沒有 `email` claim根因是 MC 用 ASP.NET Identity 的「預設」`UserClaimsPrincipalFactory`(它只放 NameIdentifier / Name / Role不放 email導致 visionA 拿到空 email 後建 user 失敗回 500。**
與 user 的 `EmailConfirmed` 狀態無關 —— 預設 factory 根本不把 email 放進 principal不管 confirmed 與否。
---
## 2. 現象visionA 端觀察到的)
1. 使用者透過 MC 登入OIDC Authorization Code Flow
2. visionA callback 拿到 id_token解析後 **`email` claim 為空 / 不存在**。
3. visionA 端 provision user 失敗:
```
oidc.callback: provision user failed error:"user: Upsert requires non-empty email"
```
4. visionA 回 HTTP 500`failed to provision user`
visionA 端 email 為**必填**fail-closed沒有 email 就無法建立 / 更新 user因此 id_token 缺 email 直接導致登入失敗。
---
## 3. 精確根因(已深入 MC codebase 坐實)
### 3.1 核心:依賴 ASP.NET Identity 預設的 ClaimsPrincipalFactory
MC 在發 token 前,用 `SignInManager.CreateUserPrincipalAsync(user)` 建立 `ClaimsPrincipal`,但**全 codebase 沒有自訂 `IUserClaimsPrincipalFactory`**grep `IUserClaimsPrincipalFactory` / `UserClaimsPrincipalFactory``src/` 下 0 命中)。
ASP.NET Identity8.0.11)的預設 `UserClaimsPrincipalFactory<TUser>` 只會放入:
- `ClaimTypes.NameIdentifier`= user Id對應 OIDC `sub`
- `ClaimTypes.Name`= UserName
- 使用者的 Role claims若有
**它不會放入 `email` claim。** 所以無論下游 scope / destination 怎麼設定principal 裡根本沒有 email claim 可發。
### 3.2 證據(檔案 + 行號,當前 MC code 位置)
| 檔案 | 行號 | 內容 | 問題 |
|------|------|------|------|
| `src/MemberCenter.Api/Controllers/OAuthController.cs` | 40 | `var principal = await _signInManager.CreateUserPrincipalAsync(user);` | 之後line 42-45只做 destination 路由,**沒有手動加 email claim** |
| `src/MemberCenter.Api/Controllers/TokenController.cs` | 60 | `var principal = await _signInManager.CreateUserPrincipalAsync(user);`password grant 分支) | 同樣line 65-68 只做 destination 路由,**沒有手動加 email claim** |
| `src/MemberCenter.Api/Extensions/ClaimsExtensions.cs` | 26-27 | `Name or Email => { AccessToken, IdentityToken }` | 路由邏輯**已寫好**把 email 送進 IdentityToken**但前提是 principal 裡已有 email claim —— 實際沒有**這是最迷惑的點destination 規則寫對了,但 source claim 從沒被加進 principal |
| `src/MemberCenter.Api/Program.cs` | 30-41 | `AddIdentity<...>().AddEntityFrameworkStores<...>().AddDefaultTokenProviders()` | 無 `AddClaimsPrincipalFactory<...>`**沿用預設 factory**;也沒有設定自訂 EmailClaimType |
### 3.3 關鍵釐清destination 規則 ≠ claim 來源
`ClaimsExtensions.GetDestinations()`line 22-30的作用是「**如果**有 email claim把它路由到 IdentityToken」。但它無法「製造」email claim。
可以把這想成兩段:
1. **產生 claim**(誰負責把 email 放進 principal**目前沒人做**(缺的就是這段)
2. **路由 claim**(決定 claim 進 access_token 還是 id_token→ 已正確實作ClaimsExtensions
第 1 段缺失,第 2 段再正確也沒用。
---
## 4. 已排除的可能(附 DB / 設定查證,免 MC 團隊走冤枉路)
| 懷疑點 | 是否為根因 | 查證結果 |
|--------|-----------|---------|
| visionA 沒要 email scope | ❌ 不是 | visionA 的 `DefaultScopes``email`,已驗證 |
| client 沒被授權 email scope | ❌ 不是 | MC 的 `OpenIddictApplications` 中 visionA client `b8093fea1a504a5d8f0e04bee9f78f2e` 的 Permissions 含 `scp:email`,已查 DB 確認 |
| 使用者沒有 email | ❌ 不是 | user `b5332e51``Email = jim800121.chen@gmail.com`,已查 DB 確認。(且 `Program.cs` line 33 `RequireUniqueEmail = true`MC 所有 user 必有 email |
| `EmailConfirmed = false` 導致不發 email | ❌ 不是 | 該 user `EmailConfirmed = false`,但**根因是預設 factory 根本不放 email claim與 confirmed 與否無關**。即使 confirmed = true預設 factory 仍不放 email |
**結論**scope、client 授權、user email、EmailConfirmed 全部正常 / 不相關。唯一缺口是 §3 的 claims factory。
---
## 5. 修法(給 MC 團隊選)
### 方案 (a)【推薦】自訂 `UserClaimsPrincipalFactory`
集中、乾淨,所有發 token 路徑OAuthController / TokenController一次涵蓋。
1. 在 `src/MemberCenter.Infrastructure/Identity/` 新建 `CustomUserClaimsPrincipalFactory`
```csharp
using System.Security.Claims;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using OpenIddict.Abstractions;
namespace MemberCenter.Infrastructure.Identity;
public class CustomUserClaimsPrincipalFactory
: UserClaimsPrincipalFactory<ApplicationUser, ApplicationRole>
{
public CustomUserClaimsPrincipalFactory(
UserManager<ApplicationUser> userManager,
RoleManager<ApplicationRole> roleManager,
IOptions<IdentityOptions> options)
: base(userManager, roleManager, options)
{
}
protected override async Task<ClaimsIdentity> GenerateClaimsAsync(ApplicationUser user)
{
var identity = await base.GenerateClaimsAsync(user);
if (!string.IsNullOrWhiteSpace(user.Email))
{
// OpenIddict 用的 claim type 為 "email"OpenIddictConstants.Claims.Email
identity.AddClaim(new Claim(OpenIddictConstants.Claims.Email, user.Email));
}
// 建議連 email_verified 一起放(見 §5.3
identity.AddClaim(new Claim(
OpenIddictConstants.Claims.EmailVerified,
user.EmailConfirmed ? "true" : "false",
ClaimValueTypes.Boolean));
return identity;
}
}
```
2. 在 `Program.cs`line 30-41 的 Identity 設定鏈)註冊,取代預設 factory
```csharp
builder.Services
.AddIdentity<ApplicationUser, ApplicationRole>(options => { /* 既有設定不變 */ })
.AddEntityFrameworkStores<MemberCenterDbContext>()
.AddDefaultTokenProviders()
.AddClaimsPrincipalFactory<CustomUserClaimsPrincipalFactory>(); // ← 新增這行
```
3. **destination 路由不用改** —— `ClaimsExtensions.GetDestinations()`line 26-27已經會把 `email``email_verified`(若要進 id_token 需確認 case見下送對地方。
- ⚠️ 注意:目前 `GetDestinations` 只對 `Name` / `Email` 回 IdentityToken。`email_verified` 不在其中,會只進 access_token。若希望 `email_verified` 也進 id_token需在 `ClaimsExtensions.cs` line 26 的 switch 加上 `OpenIddictConstants.Claims.EmailVerified`
### 方案 (b)【快速】在兩個 Controller 手動加 email claim
較分散(兩處都要改),但改動最小。
- `OAuthController.cs` line 40 之後、line 42 的 foreach 之前插入:
```csharp
if (!string.IsNullOrWhiteSpace(user.Email) &&
!principal.HasClaim(c => c.Type == OpenIddictConstants.Claims.Email))
{
((ClaimsIdentity)principal.Identity!).AddClaim(
new Claim(OpenIddictConstants.Claims.Email, user.Email));
}
```
- `TokenController.cs` line 60 之後、line 65 的 foreach 之前插入相同邏輯password grant 分支)。
- ⚠️ 缺點refresh token grantline 73-83沿用既有 principal若首次發 token 沒加 email、refresh 出來的也不會有。方案 (a) 因為在 factory 層處理refresh 重新驗證時也會走到(取決於 OpenIddict refresh 流程),較不易漏;建議優先 (a)。
### 5.3 建議一併加 `email_verified` claim
`email_verified` 是 OIDC 標準 claimboolean值 = `user.EmailConfirmed`。下游visionA可據此決定要不要信任 email 或要求驗證。已包含在 §5(a) 範例中。
### 5.4 Trade-off是否要 `EmailConfirmed = true` 才發 email claim需 MC + visionA 對齊)
- **MC 若選擇「只在 confirmed 才發 email」**:未驗證的 user 仍會讓 visionA 拿到空 email → visionA 端仍 500因 visionA email 必填、fail-closed
- **建議做法**MC **無條件發 `email` claim**(不管 confirmed另用 `email_verified` 標記驗證狀態。是否擋未驗證 user 由 visionA 端自行決定visionA 可選擇接受未驗證 email 先建 user或讀到 `email_verified=false` 時擋下並引導驗證)。
- 這個決策需 MC 與 visionA 雙方確認後落地避免「MC 改了但 visionA 仍 500」。
---
## 6. 改完怎麼驗
1. **MC 自驗**(不需 visionA用 visionA client 走一次 Authorization Code Flow或直接 password grant 取 token拿到 id_token 後到 <https://jwt.io> 或自行 decode確認 payload 含:
- `email`: `<user 的 email>`
- `email_verified`: `true` / `false`(若採 §5.3
- `sub`: `<user id>`
2. **端到端驗**visionA 重新登入 MC確認
- callback 拿到的 id_token 含非空 `email` claim
- 不再出現 `oidc.callback: provision user failed error:"user: Upsert requires non-empty email"`
- 登入成功(不再 500、user 正確建立 / 更新
3. **回歸**:確認既有 access_token 的 claim / scope 行為沒被破壞password grant、client_credentials grant 不涉及 user email理論上不受影響但建議一併冒煙測試
---
## 7. 附錄claim type 命名確認事項(請 MC 改時驗證)
- 範例使用 `OpenIddictConstants.Claims.Email`(值為字串 `"email"`)與 `OpenIddictConstants.Claims.EmailVerified``"email_verified"`)。
- 請確認與 `ClaimsExtensions.GetDestinations()`line 26switch 比對的 `OpenIddictConstants.Claims.Email` 為**同一常數**,確保 destination 路由能命中(這點目前 code 已一致,沿用同一常數即可)。

View File

@ -76,6 +76,24 @@ VISIONA_OIDC_REDIRECT_URL=http://localhost:3721/api/auth/callback
# prod: https://app.visiona.cloud # prod: https://app.visiona.cloud
VISIONA_FRONTEND_URL=http://localhost:3000 VISIONA_FRONTEND_URL=http://localhost:3000
# MC 連動登出入口(讓使用者登出 visionA 後能換帳號)
# 背景Member Center 不支援標準 OIDC RP-initiated logoutdiscovery 的
# end_session_endpoint 是 POST+JSON API、非標準 GET 流程,且 client 未註冊
# post_logout_redirect_uri後端無法乾淨地 302 導向 MC end_session。
# 唯一瀏覽器可觸發的 MC 登出是 MemberCenter.Web:7880的 /account/logoutGET 即 302
# 設定本變數後POST /api/auth/logout 的 response 會多回 idp_logouturl + method=GET
# 由前端 navigatewindow.location觸發 MC 登出。
# **host 必須是 MC Web :7880不是 MC Api :7850**:7850 沒 logout 頁、會 404
# **留空logout 只清 visionA 本地 session、不連動 MC向下相容。**
# stage 範例https://stage-9527.innovedus.com:7880/account/logout
VISIONA_OIDC_LOGOUT_URL=
# 強制每次登入都重新認證OIDC prompt=login
# - true → authorize request 帶 prompt=loginIdP 忽略既有 SSO session、要求重新輸入帳密。
# - false → 沿用 IdP 既有 session標準 SSO 體驗),為預設值。
# stage 想「每次點登入都重新輸入帳密」時設 trueprod 視 UX 決定(通常 false
VISIONA_OIDC_PROMPT_LOGIN=false
# Phase 0.8b 移除VISIONA_OIDC_SERVICE_CLIENT_ID / _SECRET # Phase 0.8b 移除VISIONA_OIDC_SERVICE_CLIENT_ID / _SECRET
# 服務間認證從 OAuth client_credentials 改為 pre-shared API key見 ADR-015、conversion.md §3 # 服務間認證從 OAuth client_credentials 改為 pre-shared API key見 ADR-015、conversion.md §3
# 兩個 service client env 不再讀取OIDCConfig.ServiceClientID/Secret struct 欄位 # 兩個 service client env 不再讀取OIDCConfig.ServiceClientID/Secret struct 欄位

View File

@ -173,6 +173,7 @@ func main() {
ClientID: cfg.OIDC.ClientID, ClientID: cfg.OIDC.ClientID,
ClientSecret: cfg.OIDC.ClientSecret, ClientSecret: cfg.OIDC.ClientSecret,
RedirectURL: cfg.OIDC.RedirectURL, RedirectURL: cfg.OIDC.RedirectURL,
PromptLogin: cfg.OIDC.PromptLogin,
}) })
cancel() cancel()
if err != nil { if err != nil {
@ -210,6 +211,8 @@ func main() {
"client_id", cfg.OIDC.ClientID, "client_id", cfg.OIDC.ClientID,
"redirect_url", cfg.OIDC.RedirectURL, "redirect_url", cfg.OIDC.RedirectURL,
"frontend_url", cfg.OIDC.PostLoginURL, "frontend_url", cfg.OIDC.PostLoginURL,
"logout_url", cfg.OIDC.LogoutURL, // 空logout 不連動 MC
"prompt_login", cfg.OIDC.PromptLogin,
"cookie_secure", cfg.UserSession.CookieSecure, "cookie_secure", cfg.UserSession.CookieSecure,
"absolute_ttl", cfg.UserSession.AbsoluteTTL, "absolute_ttl", cfg.UserSession.AbsoluteTTL,
"idle_ttl", cfg.UserSession.IdleTTL, "idle_ttl", cfg.UserSession.IdleTTL,
@ -433,7 +436,8 @@ func main() {
OIDCProvider: oidcProvider, OIDCProvider: oidcProvider,
SessionManager: userSessionMgr, SessionManager: userSessionMgr,
OIDCPostLoginURL: cfg.OIDC.PostLoginURL, OIDCPostLoginURL: cfg.OIDC.PostLoginURL,
UserStore: userStore, // DB-on FK 收尾 #1OIDC callback provision users 列 OIDCLogoutURL: cfg.OIDC.LogoutURL, // 空logout 只清本地、不連動 MC向下相容
UserStore: userStore, // DB-on FK 收尾 #1OIDC callback provision users 列
}) })
addr := net.JoinHostPort(cfg.Server.Host, strconv.Itoa(cfg.Server.Port)) addr := net.JoinHostPort(cfg.Server.Host, strconv.Itoa(cfg.Server.Port))

View File

@ -63,6 +63,16 @@ type Deps struct {
// 為空字串時 callback handler 會 fallback 到 same-origin "/"(不建議生產配置)。 // 為空字串時 callback handler 會 fallback 到 same-origin "/"(不建議生產配置)。
OIDCPostLoginURL string OIDCPostLoginURL string
// OIDCLogoutURL 是「讓使用者連帶登出 IdPMember Centersession」的入口 URL。
//
// 背景MC 不支援標準 OIDC RP-initiated logout唯一瀏覽器可觸發的登出是
// MemberCenter.Web:7880的 /account/logoutGET 即 302。詳見 config.OIDCConfig.LogoutURL。
//
// 非空時logout handleroidc_auth.go會在 LogoutResponse 多回 idp_logout 欄位,
// 由前端 navigate 觸發 MC 登出。為空時不回該欄位、維持「只清本地 session」舊行為。
// 對齊 cfg.OIDC.LogoutURLenv VISIONA_OIDC_LOGOUT_URL
OIDCLogoutURL string
// UserStore 在 OIDC callback 驗 id_token 成功後 provisionupsert一筆 users 列 // UserStore 在 OIDC callback 驗 id_token 成功後 provisionupsert一筆 users 列
// DB-on FK 收尾,問題 #1。D1-BOIDC sub 直接當 users.idMember Center sub 為 UUID // DB-on FK 收尾,問題 #1。D1-BOIDC sub 直接當 users.idMember Center sub 為 UUID
// //

View File

@ -40,6 +40,42 @@ import (
// 又不會讓 caller 端等到 default HTTP server timeout。 // 又不會讓 caller 端等到 default HTTP server timeout。
const oidcCallbackTimeout = 30 * time.Second const oidcCallbackTimeout = 30 * time.Second
// fallbackEmailDomain 是 OIDC email claim 缺漏時,用 sub 組 placeholder email 的網域。
//
// 背景Member CenterMC的 id_token 目前不發 email claimASP.NET Identity 預設只發
// sub/name。A7 之後 OIDC callback 會把 claims provision 進 users 表,而 users.email 是
// NOT NULL防呆檢查保留在 user store 層)。若 email 為空 → Upsert 失敗 → 登入 500。
//
// 修法email claim 缺時,用 "<sub>@<fallbackEmailDomain>" 當 placeholder
// - 用 sub 保證唯一(不撞 users 表 lower(email) unique index
// - 用明顯假的 .local TLDRFC 6762 保留、不可能是真 email標記「這不是真 email」
//
// MC 端根治(讓 id_token 發真 email交接給 MC 團隊,見
// docs/autoflow/04-architecture/mc-email-claim-handoff.md。MC 修好後,同一個 sub 再次登入,
// Upsert 的 ON CONFLICT(id) DO UPDATE 會把 placeholder 覆寫成真 email無需手動清資料
const fallbackEmailDomain = "noemail.visiona.local"
// isFallbackEmail 回報 email 是否為本系統產生的 fallback placeholder而非真 email
//
// 供 log / 後續資料盤點判斷「哪些 user 還是 fallback、待 MC 修好後自動覆寫」。
func isFallbackEmail(email string) bool {
return strings.HasSuffix(email, "@"+fallbackEmailDomain)
}
// resolveProvisionEmail 決定 provision 進 users 表時要寫入的 email。
//
// - claimEmail 非空MC 有發 / 未來修好)→ 直接用真 email、不套 fallback。
// - claimEmail 為空MC 現況)→ 用 "<sub>@noemail.visiona.local" placeholder。
//
// 回傳 (email, isFallback)isFallback=true 時 caller 應 log 標記。
// sub 理論上不會為空VerifyIDToken 已驗 sub但防禦性地仍會組出合法 email 字串。
func resolveProvisionEmail(sub, claimEmail string) (email string, isFallback bool) {
if claimEmail != "" {
return claimEmail, false
}
return sub + "@" + fallbackEmailDomain, true
}
// MeResponseOIDC 是 OIDC 模式下 GET /api/auth/me 的 data payload。 // MeResponseOIDC 是 OIDC 模式下 GET /api/auth/me 的 data payload。
// //
// 故意與 Legacy MeResponse 區分OIDC 沒有 Roles 概念(雛形),但有 Name。 // 故意與 Legacy MeResponse 區分OIDC 沒有 Roles 概念(雛形),但有 Name。
@ -50,8 +86,45 @@ type MeResponseOIDC struct {
} }
// LogoutResponse 是 POST /api/auth/logout 的 data payload。 // LogoutResponse 是 POST /api/auth/logout 的 data payload。
//
// IDPLogout 為**選填**:只有當 deps.OIDCLogoutURL 有設定時才回傳,告訴前端
// 「除了清掉 visionA session還要連帶登出 IdPMember Centersession」。
// 未設定時欄位 omitempty 不出現、維持「只清本地」的向下相容行為。
type LogoutResponse struct { type LogoutResponse struct {
Success bool `json:"success"` Success bool `json:"success"`
// IDPLogout 帶 IdPMC登出資訊供前端觸發 MC 登出(讓使用者能換帳號)。
// nil 時代表未啟用 MC 連動登出。
IDPLogout *IDPLogoutInfo `json:"idp_logout,omitempty"`
}
// IDPLogoutInfo 描述「如何觸發 IdPMember Center登出」。
//
// 為什麼需要 MethodMC 不支援標準 OIDC RP-initiated logout唯一瀏覽器可觸發的登出是
// MemberCenter.**Web**:7880的 /account/logout。實測 GET 即回 302清 session + redirect
// 所以前端可直接 window.location = url 觸發,不必搞 form POST。故 Method="GET"。
//
// 注意host必須指向 MC **Web**:7880不是 MC **Api**:7850。:7850 沒有 logout 頁
// AccountController.Logout 在 Web 端),打 :7850 會 404。MC Web/Api 共享 DataProtection
// SetApplicationName("MemberCenter")),清 :7880 session 後 :7850 authorize 也視為未登入,
// 下次登入會問帳密、能換帳號。
//
// 流程交接(前端):拿到 IDPLogout 後 →
// 1. 先(或同時)打 visionA logout本 response 已清本地 session
// 2. window.location = URLGET觸發 MC :7880 登出
// 3. ⚠️ stage MC 跑 master 舊版2026-04-30 imagelogout **不支援 returnUrl**
// 清完 session 會 RedirectToAction("Index","Home") 停在 MC 首頁、不會自動回 visionA。
// 前端需自行把使用者帶回 visionA 登入GET /api/auth/login
//
// 注意跨網域MC /account/logout 在另一個 origin。GET 導向不受 antiforgery/CORS 限制
// (瀏覽器直接 navigate比舊的跨站 form POST 單純。
type IDPLogoutInfo struct {
// URL 是 IdPMC Web:7880的瀏覽器登出入口。例
// https://stage-9527.innovedus.com:7880/account/logout
URL string `json:"url"`
// Method 是觸發登出要用的 HTTP method。MC Web(:7880) GET 即可觸發、固定為 "GET"。
Method string `json:"method"`
} }
// registerOIDCPublicRoutes 註冊「不需登入即可訪問」的 OIDC endpoints。 // registerOIDCPublicRoutes 註冊「不需登入即可訪問」的 OIDC endpoints。
@ -238,10 +311,25 @@ func oidcCallbackHandler(deps Deps) gin.HandlerFunc {
// 在「驗 id_token 成功後、寫 session 之前」provisionfail-closed —— provision 失敗就不發 // 在「驗 id_token 成功後、寫 session 之前」provisionfail-closed —— provision 失敗就不發
// session否則使用者拿到能登入的 cookie 但 DB 沒對應 user下一個寫入照樣爆且更難診斷 // session否則使用者拿到能登入的 cookie 但 DB 沒對應 user下一個寫入照樣爆且更難診斷
// UserStore 為 nil最小骨架 / 純 OIDC unit test→ 略過 upsert。 // UserStore 為 nil最小骨架 / 純 OIDC unit test→ 略過 upsert。
//
// email fallback放寬 email 必填MC 現況不發 email claimclaims.Email 為空。
// users.email NOT NULL 的防呆檢查保留在 user store 層;這裡在 provision 前先把空 email
// 補成 "<sub>@noemail.visiona.local" placeholder讓登入能成功。MC 修好後同 sub 再登入,
// Upsert ON CONFLICT(id) DO UPDATE 會自動把 placeholder 覆寫成真 email。
provisionEmail, emailIsFallback := resolveProvisionEmail(claims.Subject, claims.Email)
if emailIsFallback {
// log 標記哪些 user 是 fallback方便盤點「待 MC 修好後覆寫」的數量。
// 不 log email 內容(雖是 placeholder 無敏感性,仍保守只記 sub
log.Warn("oidc.callback: email claim missing, using fallback placeholder",
"request_id", RequestIDFrom(c),
"action", "oidc.callback.email_fallback",
"user_id", claims.Subject,
)
}
if deps.UserStore != nil { if deps.UserStore != nil {
if upErr := deps.UserStore.Upsert(ctx, &user.User{ if upErr := deps.UserStore.Upsert(ctx, &user.User{
ID: claims.Subject, // = users.idD1-B ID: claims.Subject, // = users.idD1-B
Email: claims.Email, Email: provisionEmail,
Name: claims.Name, Name: claims.Name,
}); upErr != nil { }); upErr != nil {
// 不洩漏 raw error 給 userlog 留診斷(不含 token / secret // 不洩漏 raw error 給 userlog 留診斷(不含 token / secret
@ -352,9 +440,19 @@ func oidcCallbackHandler(deps Deps) gin.HandlerFunc {
// oidcLogoutHandler 實作 POST /api/auth/logoutOIDC 模式)。 // oidcLogoutHandler 實作 POST /api/auth/logoutOIDC 模式)。
// //
// 雛形不做 RP-initiated logout不通知 IdP— 只清本地 session + cookie // 行為:一律清掉 visionA 自己的 session + cookie原有行為不變
// Idempotentcookie 不存在或 session 已清也回 200。 // Idempotentcookie 不存在或 session 已清也回 200。
// //
// MC 連動登出2026-06 新增MC **不支援標準 OIDC RP-initiated logout**
// discovery 列的 end_session_endpoint 是 POST+JSON API、非標準 GET 流程,且 client
// 未註冊 post_logout_redirect_uri所以後端無法乾淨地 302 導向 MC end_session。
// 改採權宜方案:若 deps.OIDCLogoutURL 有設定response 多回 idp_logout 欄位
// MC Web :7880 /account/logout 的 GET 入口),由前端 navigate 觸發 MC 登出,
// 讓使用者登出後能換帳號。未設定時不回該欄位、維持「只清本地」舊行為(向下相容)。
//
// TODOMC 補上標準 RP-initiated logoutGET end_session_endpoint + id_token_hint +
// post_logout_redirect_uri改回後端 302 導向標準流程、移除這個 idp_logout 權宜欄位。
//
// 對齊 oidc-tdd.md §3.3。 // 對齊 oidc-tdd.md §3.3。
func oidcLogoutHandler(deps Deps) gin.HandlerFunc { func oidcLogoutHandler(deps Deps) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
@ -373,12 +471,22 @@ func oidcLogoutHandler(deps Deps) gin.HandlerFunc {
"request_id", RequestIDFrom(c), "error", err) "request_id", RequestIDFrom(c), "error", err)
} }
resp := LogoutResponse{Success: true}
// 只有設定了 MC logout URL 才回 idp_logout可關設計、向下相容
if deps.OIDCLogoutURL != "" {
resp.IDPLogout = &IDPLogoutInfo{
URL: deps.OIDCLogoutURL,
Method: http.MethodGet, // MC Web(:7880) /account/logout GET 即 302 觸發登出(實測)
}
}
log.Info("oidc.logout", log.Info("oidc.logout",
"request_id", RequestIDFrom(c), "request_id", RequestIDFrom(c),
"action", "oidc.logout", "action", "oidc.logout",
"user_id", userID, "user_id", userID,
"idp_logout", deps.OIDCLogoutURL != "", // 不記 URL 本身(非機密但精簡);只記是否連動
) )
WriteSuccess(c, http.StatusOK, LogoutResponse{Success: true}) WriteSuccess(c, http.StatusOK, resp)
} }
} }

View File

@ -20,6 +20,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"visiona-backend/internal/oidc" "visiona-backend/internal/oidc"
"visiona-backend/internal/user"
"visiona-backend/internal/usersession" "visiona-backend/internal/usersession"
) )
@ -41,10 +42,10 @@ type mockOIDCProvider struct {
verifyFn func(ctx context.Context, raw, expectedNonce string) (*oidc.Claims, error) verifyFn func(ctx context.Context, raw, expectedNonce string) (*oidc.Claims, error)
// 記錄呼叫參數供 test assertion 用 // 記錄呼叫參數供 test assertion 用
gotCode string gotCode string
gotVerifier string gotVerifier string
gotIDToken string gotIDToken string
gotNonce string gotNonce string
} }
func (m *mockOIDCProvider) AuthorizationURL(state, nonce, codeChallenge string) string { func (m *mockOIDCProvider) AuthorizationURL(state, nonce, codeChallenge string) string {
@ -410,6 +411,123 @@ func TestOIDCCallback_VerifyFails(t *testing.T) {
assert.Contains(t, cbW.Body.String(), "id_token verification failed") assert.Contains(t, cbW.Body.String(), "id_token verification failed")
} }
// ---- TESTS: email fallback (放寬 email 必填) -----------------------------
// recordingUserStore 是攔截 Upsert 的測試 user.Store記錄最後一次寫入的 User
// 供 assertion 驗證 provision 進 DB 的 email真 email vs fallback placeholder
type recordingUserStore struct {
mu sync.Mutex
lastUpsert *user.User
}
func (s *recordingUserStore) Upsert(ctx context.Context, in *user.User) error {
s.mu.Lock()
defer s.mu.Unlock()
cp := *in
s.lastUpsert = &cp
return nil
}
func (s *recordingUserStore) Get(ctx context.Context, id string) (*user.User, error) {
return nil, user.ErrNotFound
}
func (s *recordingUserStore) last() *user.User {
s.mu.Lock()
defer s.mu.Unlock()
return s.lastUpsert
}
// runCallbackWithProvider 跑完整 login + callback回傳 callback 的 recorder。
func runCallbackWithProvider(t *testing.T, r *gin.Engine) *httptest.ResponseRecorder {
t.Helper()
loginW := httptest.NewRecorder()
r.ServeHTTP(loginW, httptest.NewRequest(http.MethodGet, "/api/auth/login", nil))
state := mustExtractStateFromLoginRedirect(t, loginW)
cookies := loginW.Result().Cookies()
cbW := httptest.NewRecorder()
cbReq := httptest.NewRequest(http.MethodGet,
"/api/auth/callback?code=auth-code&state="+url.QueryEscape(state), nil)
for _, c := range cookies {
cbReq.AddCookie(c)
}
r.ServeHTTP(cbW, cbReq)
return cbW
}
// TestOIDCCallback_EmailMissing_UsesFallback 驗證 email claim 為空時:
// - callback 仍 provision 成功302、非 500
// - 寫進 user store 的 email 是 "<sub>@noemail.visiona.local" placeholder含 sub、unique
func TestOIDCCallback_EmailMissing_UsesFallback(t *testing.T) {
provider := &mockOIDCProvider{
verifyFn: func(ctx context.Context, raw, nonce string) (*oidc.Claims, error) {
// 模擬 MC 現況:只有 sub / name沒有 email
return &oidc.Claims{Subject: "sub-uuid-1", Email: "", Name: "Bob", Nonce: nonce}, nil
},
}
store := &recordingUserStore{}
deps := newOIDCTestDeps(provider)
deps.UserStore = store
r := newOIDCRouter(deps)
cbW := runCallbackWithProvider(t, r)
require.Equal(t, http.StatusFound, cbW.Code,
"login should succeed even without email claim; body=%s", cbW.Body.String())
got := store.last()
require.NotNil(t, got, "Upsert should have been called")
assert.Equal(t, "sub-uuid-1", got.ID)
assert.Equal(t, "sub-uuid-1@noemail.visiona.local", got.Email,
"empty email claim must be provisioned with sub-based fallback placeholder")
assert.True(t, isFallbackEmail(got.Email), "provisioned email must be flagged as fallback")
}
// TestOIDCCallback_EmailPresent_UsesRealEmail 驗證 email claim 非空時用真 email、不套 fallback。
// (未來 MC 修好發 email 後的行為。)
func TestOIDCCallback_EmailPresent_UsesRealEmail(t *testing.T) {
provider := &mockOIDCProvider{
verifyFn: func(ctx context.Context, raw, nonce string) (*oidc.Claims, error) {
return &oidc.Claims{Subject: "sub-uuid-2", Email: "real@example.com", Name: "Carol", Nonce: nonce}, nil
},
}
store := &recordingUserStore{}
deps := newOIDCTestDeps(provider)
deps.UserStore = store
r := newOIDCRouter(deps)
cbW := runCallbackWithProvider(t, r)
require.Equal(t, http.StatusFound, cbW.Code, "body=%s", cbW.Body.String())
got := store.last()
require.NotNil(t, got)
assert.Equal(t, "real@example.com", got.Email, "non-empty email claim must be used as-is")
assert.False(t, isFallbackEmail(got.Email), "real email must not be flagged as fallback")
}
// TestResolveProvisionEmail 驗證 fallback email 計算邏輯(格式正確、含 sub、unique-by-sub
func TestResolveProvisionEmail(t *testing.T) {
t.Run("empty_email_uses_sub_fallback", func(t *testing.T) {
email, isFallback := resolveProvisionEmail("abc-123", "")
assert.Equal(t, "abc-123@noemail.visiona.local", email)
assert.True(t, isFallback)
assert.True(t, isFallbackEmail(email))
})
t.Run("real_email_passthrough", func(t *testing.T) {
email, isFallback := resolveProvisionEmail("abc-123", "x@y.com")
assert.Equal(t, "x@y.com", email)
assert.False(t, isFallback)
assert.False(t, isFallbackEmail(email))
})
t.Run("fallback_is_unique_per_sub", func(t *testing.T) {
e1, _ := resolveProvisionEmail("sub-A", "")
e2, _ := resolveProvisionEmail("sub-B", "")
assert.NotEqual(t, e1, e2, "different subs must yield different fallback emails (no unique-index clash)")
})
}
// ---- TESTS: AuthMiddleware (OIDC 模式) + /api/auth/me + /api/auth/logout ---- // ---- TESTS: AuthMiddleware (OIDC 模式) + /api/auth/me + /api/auth/logout ----
// TestOIDCMiddleware_Allows_AuthenticatedSession 驗證已登入 session 通過 + me 回 user info。 // TestOIDCMiddleware_Allows_AuthenticatedSession 驗證已登入 session 通過 + me 回 user info。
@ -512,6 +630,84 @@ func TestOIDCLogout_ClearsSession(t *testing.T) {
assert.Equal(t, http.StatusUnauthorized, meW.Code) assert.Equal(t, http.StatusUnauthorized, meW.Code)
} }
// TestOIDCLogout_NoIDPLogout_WhenLogoutURLUnset 驗證未設定 OIDCLogoutURL 時,
// logout response 不含 idp_logout 欄位(向下相容,只清本地 session
func TestOIDCLogout_NoIDPLogout_WhenLogoutURLUnset(t *testing.T) {
provider := &mockOIDCProvider{}
deps := newOIDCTestDeps(provider)
// 不設 OIDCLogoutURL預設空字串
require.Empty(t, deps.OIDCLogoutURL)
r := newOIDCRouter(deps)
cookies := loginAndCallback(t, r, deps, provider)
logoutW := httptest.NewRecorder()
logoutReq := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
for _, c := range cookies {
logoutReq.AddCookie(c)
}
r.ServeHTTP(logoutW, logoutReq)
require.Equal(t, http.StatusOK, logoutW.Code)
// data.success == true 且不含 idp_logout
var env struct {
Success bool `json:"success"`
Data struct {
Success bool `json:"success"`
IDPLogout *IDPLogoutInfo `json:"idp_logout"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(logoutW.Body.Bytes(), &env))
assert.True(t, env.Data.Success)
assert.Nil(t, env.Data.IDPLogout, "未設定 OIDCLogoutURL 時不應回 idp_logout")
// 原始 JSON 不應出現 idp_logout keyomitempty 驗證)
assert.NotContains(t, logoutW.Body.String(), "idp_logout")
}
// TestOIDCLogout_IncludesIDPLogout_WhenLogoutURLSet 驗證設定 OIDCLogoutURL 後,
// logout response 含 idp_logoutURL + Method=GET且仍清本地 session。
func TestOIDCLogout_IncludesIDPLogout_WhenLogoutURLSet(t *testing.T) {
provider := &mockOIDCProvider{}
deps := newOIDCTestDeps(provider)
// MC Web :7880 logout 入口(不是 :7850 Api
const mcLogoutURL = "https://stage-9527.innovedus.com:7880/account/logout"
deps.OIDCLogoutURL = mcLogoutURL
r := newOIDCRouter(deps)
cookies := loginAndCallback(t, r, deps, provider)
logoutW := httptest.NewRecorder()
logoutReq := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
for _, c := range cookies {
logoutReq.AddCookie(c)
}
r.ServeHTTP(logoutW, logoutReq)
require.Equal(t, http.StatusOK, logoutW.Code)
var env struct {
Data struct {
Success bool `json:"success"`
IDPLogout *IDPLogoutInfo `json:"idp_logout"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(logoutW.Body.Bytes(), &env))
assert.True(t, env.Data.Success)
require.NotNil(t, env.Data.IDPLogout, "設定 OIDCLogoutURL 後應回 idp_logout")
assert.Equal(t, mcLogoutURL, env.Data.IDPLogout.URL)
assert.Equal(t, http.MethodGet, env.Data.IDPLogout.Method)
// 本地 session 仍要被清掉(清 cookie
var cleared *http.Cookie
for _, c := range logoutW.Result().Cookies() {
if c.Name == "visiona_session" {
cleared = c
break
}
}
require.NotNil(t, cleared, "expected visiona_session clearing cookie")
assert.True(t, cleared.MaxAge < 0, "expected MaxAge < 0 to clear cookie")
}
// TestOIDC_LegacyLogin_Returns410 驗證 OIDC 模式下 POST /api/auth/login 回 410。 // TestOIDC_LegacyLogin_Returns410 驗證 OIDC 模式下 POST /api/auth/login 回 410。
func TestOIDC_LegacyLogin_Returns410(t *testing.T) { func TestOIDC_LegacyLogin_Returns410(t *testing.T) {
provider := &mockOIDCProvider{} provider := &mockOIDCProvider{}

View File

@ -118,6 +118,41 @@ type OIDCConfig struct {
// 對齊 VISIONA_FRONTEND_URL沿用 oidc-tdd.md §13.1 命名)。 // 對齊 VISIONA_FRONTEND_URL沿用 oidc-tdd.md §13.1 命名)。
PostLoginURL string PostLoginURL string
// LogoutURL 是「讓使用者連帶登出 IdPMember Centersession」的入口 URL。
//
// 背景MC 限制2026-06MC 不支援標準 OIDC RP-initiated logout —
// discovery 雖列 end_session_endpoint=/auth/logout但那是 POST+JSON 的 API
// AuthController.Logout非標準 GET + id_token_hint + post_logout_redirect_uri
// 且 client 未註冊 post_logout_redirect_uri。因此後端無法乾淨地 302 導向 MC end_session。
//
// MC 端唯一「瀏覽器可觸發」的登出是 MemberCenter.**Web**:7880的 AccountController.Logout
// (路由 /account/logout_signInManager.SignOutAsync() 清 MC session。實測 GET 即回 302
// 不必 form POST。**注意 host 必須是 MC Web :7880不是 MC Api :7850**——:7850 沒 logout 頁、會 404。
// MC Web/Api 共享 DataProtectionSetApplicationName("MemberCenter")),清 :7880 session 後
// :7850 authorize 也視為未登入、下次登入會問帳密,可換帳號。
//
// 因此本欄位填 MC Web logout 入口(例:
// stage: https://stage-9527.innovedus.com:7880/account/logout
// 後端 logout handler 會把這個 URL 連同 method=GET 回給前端,由前端 navigate 觸發 MC 登出
// (見 oidc_auth.go oidcLogoutHandler
//
// ⚠️ stage MC 跑 master 舊版2026-04-30 image、logout 不支援 returnUrl清完會停在 MC 首頁、
// 不自動回 visionA前端需自行導回 visionA 登入。
//
// **設計成可關**:留空時 logout response 不回 idp_logout 欄位、維持「只清本地 session」
// 的舊行為(向下相容)。對齊 VISIONA_OIDC_LOGOUT_URL。
//
// TODOMC 補標準 RP-initiated logout 後):改回後端 302 導向標準 end_session_endpoint
// 移除這個權宜欄位。
LogoutURL string
// PromptLogin 控制 authorize request 是否帶 OIDC `prompt=login`
// - true → 每次登入都讓 IdP 強制重新認證(忽略既有 SSO session
// - false → 沿用 IdP 既有 session標準 SSO為預設值。
// stage 想「每次都重新輸入帳密」時設 trueprod 視 UX 決定。
// 對齊 VISIONA_OIDC_PROMPT_LOGIN預設 false
PromptLogin bool
// ServiceClientID 是「visionA-backend 以服務身份呼叫 MC API」用的 client id // ServiceClientID 是「visionA-backend 以服務身份呼叫 MC API」用的 client id
// 預留給未來 client_credentials grant flow例如查詢使用者組織、推送通知等 // 預留給未來 client_credentials grant flow例如查詢使用者組織、推送通知等
// //

View File

@ -38,6 +38,11 @@ func Load() *Config {
ClientSecret: getEnvString("VISIONA_OIDC_CLIENT_SECRET", ""), ClientSecret: getEnvString("VISIONA_OIDC_CLIENT_SECRET", ""),
RedirectURL: getEnvString("VISIONA_OIDC_REDIRECT_URL", ""), RedirectURL: getEnvString("VISIONA_OIDC_REDIRECT_URL", ""),
PostLoginURL: getEnvString("VISIONA_FRONTEND_URL", ""), PostLoginURL: getEnvString("VISIONA_FRONTEND_URL", ""),
// LogoutURLMC Web :7880 logout 入口GET /account/logout。留空logout 只清本地、
// 不連動 MC向下相容見 OIDCConfig.LogoutURL 註解)。
LogoutURL: getEnvString("VISIONA_OIDC_LOGOUT_URL", ""),
// prompt=logintrue 時每次登入都強制 IdP 重新認證(忽略既有 SSO session
PromptLogin: getEnvBool("VISIONA_OIDC_PROMPT_LOGIN", false),
// A1client_credentials grant 預留欄位,留空表「不啟用 service client」。 // A1client_credentials grant 預留欄位,留空表「不啟用 service client」。
ServiceClientID: getEnvString("VISIONA_OIDC_SERVICE_CLIENT_ID", ""), ServiceClientID: getEnvString("VISIONA_OIDC_SERVICE_CLIENT_ID", ""),
ServiceClientSecret: getEnvString("VISIONA_OIDC_SERVICE_CLIENT_SECRET", ""), ServiceClientSecret: getEnvString("VISIONA_OIDC_SERVICE_CLIENT_SECRET", ""),

View File

@ -0,0 +1,506 @@
//go:build realconv
// real_chain_e2e_test.go — 完整鏈路 e2e真轉檔 → PromoteToModels → 真 model 進 PG 模型庫。
//
// Owner: testing agentrealconv 完整鏈路 e2e — build tag 隔離,預設 CI 不跑)
//
// 與 real_converter_e2e_test.go 的差異:
// - real_converter_e2e_test.go對「真轉檔服務」驗 visionA ConverterClient 的 **contract**
// (連線/認證/InitJob/GetJob/GetResult 解析、promote 失敗路徑)。當時 stage 是 stub。
// - 本檔stage 已切 **real 模式**(真 KTC、真 nef 800KB、promote OAuth 已修),驗
// **整條 visionA 業務鏈路到底**
// InitJob真送 onnx+56 圖)→ poll completed真轉檔
// → flow.PromoteToModelspromote→converter MinIO pull NEF→storage.Put→建 model record
// → **model 真的進了 PG 模型庫**(用 model.PostgresRepository 查、驗 owner/source_job_id/storage_key/faa_object_key
// → 冪等(同 jobID 再 promote 回既有 model、不重複建
//
// ⚠️ v0.6 架構事實flow.go PromoteToModelspromote 與 download 都走
//
// `converter.GetResult`converter MinIOvisionA 端**不再直接打 FAA**。所以本鏈路
// **不需要** wire FAA / MC client —— 只需要:真 ConverterClient:9501 + API key
// + 真 PG建 model record+ 一個 storagestreaming 寫 NEF用本機 tmpdir LocalFS
// 這是「能在本檔組出真 conversion service」的關鍵依賴比想像中少。
//
// 三個必要外部資源(缺任一 → t.Skip 並印啟用指令):
// 1. VISIONA_REAL_CONVERTER_URL + VISIONA_CONVERTER_API_KEY與 real_converter_e2e_test.go 共用)
// 2. VISIONA_REAL_PG_DSN已 migrate 的 PG可指 stage 真 PG 或本機 docker PG
// 3. onnx + ref images fixture與 real_converter_e2e_test.go 共用 default 路徑 / env 覆寫)
//
// 本機通常連不到 stageVPN/網路)+ 無 PG → 預設 Skip。由 Orchestrator 對 stage 跑
// (見檔尾「給 Orchestrator 對 stage 跑」段)。
package conversion
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"visiona-backend/internal/model"
"visiona-backend/internal/storage"
)
// ==========================================================================
// 環境 guardPG DSN 是本檔額外需要的converter / fixture 沿用 requireRealConvEnv
// ==========================================================================
const (
// realChainPGDSNEnv 是已 migrate 的 PostgreSQL DSN
// postgres://visiona:pw@192.168.0.130:5432/visiona?sslmode=disable
// 缺 → 本檔的完整鏈路測試 Skipcontract 測試在 real_converter_e2e_test.go 仍可獨立跑)。
realChainPGDSNEnv = "VISIONA_REAL_PG_DSN"
// 真 KTC 轉檔比 stub 慢得多onnx→bie→nef 三 stage、bie 量化要跑 56 張 ref 圖)。
// 給足 deadlinestub 經驗幾秒real 端視機器可能數分鐘。
realChainPollTimeout = 10 * time.Minute
realChainPollInterval = 3 * time.Second
// 測試建立的 model name 前綴可識別、cleanup 用)。
realChainModelNamePrefix = "e2e-realchain-"
)
// requireRealChainPG 解析 PG DSN、建 pool缺 env → Skip。pool 在 t.Cleanup 關閉。
func requireRealChainPG(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := os.Getenv(realChainPGDSNEnv)
if dsn == "" {
t.Skipf(`real-chain e2e 跳過未設 %sPG DSN
本測試需要一個 migrate users / models PostgreSQL
- stage PG或本機 docker PGschema 來自 migrations/0001_create_users_models.up.sql
範例
VISIONA_REAL_PG_DSN="postgres://visiona:<pw>@192.168.0.130:5432/visiona?sslmode=disable"`, realChainPGDSNEnv)
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Skipf("real-chain e2e 跳過PG pool 建立失敗DSN 可達性問題?):%v", err)
}
// ping 確認連得上 + schema 存在(查 models 表)
pingCtx, pingCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer pingCancel()
if err := pool.Ping(pingCtx); err != nil {
pool.Close()
t.Skipf("real-chain e2e 跳過PG ping 失敗:%v", err)
}
var reg int
if err := pool.QueryRow(pingCtx, `SELECT 1 FROM information_schema.tables
WHERE table_name = 'models'`).Scan(&reg); err != nil {
pool.Close()
t.Skipf("real-chain e2e 跳過PG 缺 models 表DSN 指到未 migrate 的 DB%v。"+
"請先對該 DB 跑 migrations/0001_create_users_models.up.sql。", err)
}
t.Cleanup(func() { pool.Close() })
return pool
}
// ensureTestUser upsert 一個合法 UUID user滿足 models.owner_user_id FK
//
// 回傳該 user 的 UUID 字串。固定 UUIDdeterministic讓重跑時 idempotent
// email 帶可識別前綴避免撞真 user。
func ensureTestUser(t *testing.T, pool *pgxpool.Pool) string {
t.Helper()
// 固定 namespace UUIDv5 不需要;這裡直接寫死一個明顯是測試用的 UUID
const testUserID = "e2e0c0de-0000-4000-8000-000000000001"
const testEmail = "e2e-realchain@example.invalid"
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// upsert by idemail 用 functional unique indexlower(email)ON CONFLICT (id) 即可。
_, err := pool.Exec(ctx, `
INSERT INTO users (id, email, name, roles)
VALUES ($1, $2, 'e2e realchain test user', '{}')
ON CONFLICT (id) DO UPDATE SET email = EXCLUDED.email, updated_at = now()`,
testUserID, testEmail)
if err != nil {
t.Fatalf("ensure test userupsert users失敗%v\n"+
"(若是 email unique 衝突,代表有殘留同 email 的別的 user — 換 testEmail 或先清)", err)
}
return testUserID
}
// ==========================================================================
// in-test adapters把 model.PostgresRepository / storage.LocalFSStore 包成
// conversion.ModelStore / conversion.Storage對映 cmd/api-server/conversion_adapters.go
// 但 main package 的 adapter 不可 import這裡在 conversion package 內等價重寫)。
// ==========================================================================
// pgModelStore 把 model.Repository 包成 conversion.ModelStore含 ModelRecord ↔ model.Model 轉換)。
type pgModelStore struct {
repo model.Repository
}
func (s *pgModelStore) Save(ctx context.Context, rec *ModelRecord) error {
if rec == nil {
return errors.New("pgModelStore.Save requires non-nil record")
}
now := time.Now().UTC()
uploadedAt := now
if !rec.UpdatedAt.IsZero() {
uploadedAt = rec.UpdatedAt
}
m := &model.Model{
ID: rec.ID,
OwnerUserID: rec.OwnerUserID,
Name: rec.Name,
Description: rec.Description,
StorageKey: rec.StorageKey,
FileSize: rec.FileSize,
FileChecksum: rec.FileChecksum,
TargetChip: rec.TargetChip,
InputShape: rec.InputShape,
Classes: rec.Classes,
Framework: rec.Framework,
Source: rec.Source,
SourceJobID: rec.SourceJobID,
FAAObjectKey: rec.FAAObjectKey,
CreatedAt: rec.CreatedAt,
UpdatedAt: rec.UpdatedAt,
UploadedAt: &uploadedAt,
}
return s.repo.Save(ctx, m)
}
func (s *pgModelStore) FindBySourceJobID(ctx context.Context, ownerUserID, sourceJobID string) (*ModelRecord, error) {
if ownerUserID == "" || sourceJobID == "" {
return nil, nil
}
models, err := s.repo.List(ctx, model.ListFilter{
OwnerUserID: ownerUserID,
Source: model.SourceConverted,
})
if err != nil {
return nil, fmt.Errorf("pgModelStore.FindBySourceJobID list: %w", err)
}
for _, m := range models {
if m.SourceJobID == sourceJobID {
return &ModelRecord{
ID: m.ID,
OwnerUserID: m.OwnerUserID,
Name: m.Name,
Description: m.Description,
StorageKey: m.StorageKey,
FileSize: m.FileSize,
FileChecksum: m.FileChecksum,
TargetChip: m.TargetChip,
InputShape: m.InputShape,
Classes: m.Classes,
Framework: m.Framework,
Source: m.Source,
SourceJobID: m.SourceJobID,
FAAObjectKey: m.FAAObjectKey,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
}, nil
}
}
return nil, nil
}
func (s *pgModelStore) GenerateID() string { return uuid.NewString() }
// localStorage 把 storage.Store 包成 conversion.Storage只需 Put
type localStorage struct {
store storage.Store
}
func (s *localStorage) Put(ctx context.Context, key string, r io.Reader, size int64, meta map[string]string) error {
return s.store.Put(ctx, key, r, size, meta)
}
// buildRealChainService 組一個「真 converter + 真 PG + 本機 LocalFS storage」的 conversion.Service。
//
// 回傳 service + 底層 pgRepo測試直接用 repo 查 PG 驗證 model 落盤)。
func buildRealChainService(t *testing.T, env realConvEnv, pool *pgxpool.Pool) (Service, *model.PostgresRepository) {
t.Helper()
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
converterClient := NewConverterClient(ConverterClientOpts{
BaseURL: env.baseURL,
APIKey: env.apiKey,
Logger: logger,
})
ownership := NewOwnership(converterClient, logger)
pgRepo := model.NewPostgresRepository(pool)
modelStore := &pgModelStore{repo: pgRepo}
// 本機 tmpdir LocalFS storageNEF streaming 寫進去;測完隨 t.TempDir 清)。
fsStore, err := storage.NewLocalFSStore(t.TempDir(), "http://localhost/files", "e2e-realchain-signing")
if err != nil {
t.Fatalf("建 LocalFSStore 失敗:%v", err)
}
storageAdapter := &localStorage{store: fsStore}
svc, err := NewService(FlowOpts{
Converter: converterClient,
Ownership: ownership,
ModelStore: modelStore,
Storage: storageAdapter,
Logger: logger,
})
if err != nil {
t.Fatalf("NewService 失敗:%v", err)
}
return svc, pgRepo
}
// ==========================================================================
// E2E完整鏈路 — 真轉檔 → PromoteToModels → 真 model 進 PG → 冪等
// ==========================================================================
// TestRealChain_ConvertPromoteToPGModelLibrary 驗整條 visionA 業務鏈路到底。
//
// [1] flow.InitJob 真送 onnx + 56 圖flow 內部重組 multipart、注入 user_id、寫 ownership
// [2] flow.GetJob poll 到 completed真 KTC 轉檔;給足 10 分鐘)
// [3] flow.PromoteToModelspromote真 nef 推上 FAA + 保留 converter MinIO
// → converter.GetResult 拉 NEF stream → storage.Put → model.PostgresRepository.Save
// [4] 驗 model 真的進 PG用 pgRepo.Get(model_id) + List by owner確認
// owner / source_job_id / storage_key / file_size / source=converted / faa_object_key 有值
// [5] 冪等:同 jobID 再 PromoteToModels → 回**同一個** model_id不重複建
func TestRealChain_ConvertPromoteToPGModelLibrary(t *testing.T) {
env := requireRealConvEnv(t)
pool := requireRealChainPG(t)
userID := ensureTestUser(t, pool)
svc, pgRepo := buildRealChainService(t, env, pool)
ctx := context.Background()
// ── [1] flow.InitJob真送─────────────────────────────────────────────
body, contentType, refCount := buildRealInitBody(t, env)
t.Logf("[1] InitJobmultipart %d bytesref_images=%d 張user=%s", len(body), refCount, userID)
// 注意flow.InitJob 內部會重組 multipart 並注入 user_id黑名單 client 帶來的 user_id
// buildRealInitBody 已寫了 user_id=realConvTestUserID但會被 flow 用本測 userID 蓋掉,
// 這正是要驗的安全行為§4.2)。傳進去的 ContentType 必須含 boundary。
initCtx, initCancel := context.WithTimeout(ctx, 90*time.Second)
job, err := svc.InitJob(initCtx, InitJobInput{
UserID: userID,
ContentType: contentType,
Body: bytes.NewReader(body),
ContentLength: int64(len(body)),
})
initCancel()
if err != nil {
if errors.Is(err, ErrConverterAuthFailed) {
t.Fatalf("[1] InitJob 認證失敗API key 未對齊?):%v", err)
}
t.Fatalf("[1] InitJob 失敗:%v", err)
}
if job.JobID == "" {
t.Fatalf("[1] InitJob 回的 job_id 為空:%+v", job)
}
jobID := job.JobID
t.Logf("[1] InitJob OKjob_id=%s status=%s stage=%s", jobID, job.Status, job.Stage)
// ── [2] flow.GetJob poll 到 completed真轉檔給足 10 分鐘)──────────────
final := pollChainUntilTerminal(t, svc, userID, jobID)
t.Logf("[2] 終態status=%s stage=%q source_filename=%q target_chip=%q error_code=%q",
final.Status, final.Stage, final.SourceFilename, final.TargetChip, final.ErrorCode)
if final.Status != "completed" {
t.Fatalf("[2] 真轉檔未 completedstatus=%s error_code=%q msg=%q。"+
"確認 stage worker 已切 real 模式、且該 fixture 能轉成功。",
final.Status, final.ErrorCode, final.ErrorMessage)
}
// ── [3] flow.PromoteToModels真 promote → MinIO pull → storage → 建 model record──
modelName := realChainModelNamePrefix + jobID[:8]
promoteCtx, promoteCancel := context.WithTimeout(ctx, 2*time.Minute)
promoteRes, err := svc.PromoteToModels(promoteCtx, userID, jobID, modelName)
promoteCancel()
if err != nil {
t.Fatalf("[3] PromoteToModels 失敗:%v\n"+
"promote OAuth 已修的前提下不該失敗;若回 ErrConverterUnavailable 代表 promote→FAA 仍有問題,"+
"回報 Orchestrator勿自行改 production code。", err)
}
if promoteRes == nil || promoteRes.ModelID == "" {
t.Fatalf("[3] PromoteToModels 回的 model_id 為空:%+v", promoteRes)
}
modelID := promoteRes.ModelID
// cleanup測完軟刪除 modelpgRepo.Delete 寫 deleted_at不留垃圾在模型庫
t.Cleanup(func() {
dctx, dcancel := context.WithTimeout(context.Background(), 10*time.Second)
defer dcancel()
if derr := pgRepo.Delete(dctx, modelID); derr != nil {
t.Logf("cleanup軟刪除 model %s 失敗(殘留在 PG需人工清%v", modelID, derr)
} else {
t.Logf("cleanup已軟刪除 model %s", modelID)
}
})
t.Logf("[3] PromoteToModels OKmodel_id=%s name=%q source=%s source_job_id=%s file_size=%d status=%s",
modelID, promoteRes.Name, promoteRes.Source, promoteRes.SourceJobID, promoteRes.FileSize, promoteRes.Status)
// 基本一致性
if promoteRes.Source != "converted" {
t.Errorf("[3] promote source 預期 converted得 %q", promoteRes.Source)
}
if promoteRes.SourceJobID != jobID {
t.Errorf("[3] promote source_job_id 預期 %s得 %s", jobID, promoteRes.SourceJobID)
}
if promoteRes.FileSize <= 0 {
t.Errorf("[3] promote file_size 應 > 0真 nef ~800KB得 %d", promoteRes.FileSize)
}
// ── [4] 驗 model 真的進 PG直接查 PostgresRepository────────────────────
getCtx, getCancel := context.WithTimeout(ctx, 10*time.Second)
got, err := pgRepo.Get(getCtx, modelID)
getCancel()
if err != nil {
t.Fatalf("[4] 從 PG 查不到 model %smodel 未真正落盤?):%v", modelID, err)
}
t.Logf("[4] PG model 落盤id=%s owner=%s name=%q storage_key=%q faa_object_key=%q "+
"file_size=%d source=%s source_job_id=%s target_chip=%q",
got.ID, got.OwnerUserID, got.Name, got.StorageKey, got.FAAObjectKey,
got.FileSize, got.Source, got.SourceJobID, got.TargetChip)
if got.OwnerUserID != userID {
t.Errorf("[4] PG model owner 預期 %s得 %s", userID, got.OwnerUserID)
}
if got.SourceJobID != jobID {
t.Errorf("[4] PG model source_job_id 預期 %s得 %s", jobID, got.SourceJobID)
}
if got.Source != model.SourceConverted {
t.Errorf("[4] PG model source 預期 converted得 %q", got.Source)
}
if got.StorageKey == "" {
t.Errorf("[4] PG model storage_key 不該為空promote 應寫 visionA storage key")
}
if got.FileSize <= 0 {
t.Errorf("[4] PG model file_size 應 > 0得 %d", got.FileSize)
}
// FAAObjectKeyv0.6 promote 仍寫此欄位(= converter promote 的 target_object_key
// 真 promote 成功路徑下應有值;若為空記下供判讀(不一定 fail — 視 converter promote response
if got.FAAObjectKey == "" {
t.Logf("[4] 注意PG model faa_object_key 為空。v0.6 promote 應回 target_object_key" +
"若 converter promote response 未帶 target_object_key 則 fallback 為 visionA 端組的 key" +
"理論上不該空。確認 converter promote 回傳格式。")
}
// List by owner 也應看得到(驗 List 路徑 + filter 正確)
listCtx, listCancel := context.WithTimeout(ctx, 10*time.Second)
models, err := pgRepo.List(listCtx, model.ListFilter{OwnerUserID: userID, Source: model.SourceConverted})
listCancel()
if err != nil {
t.Fatalf("[4] List by owner 失敗:%v", err)
}
if !containsModelID(models, modelID) {
t.Errorf("[4] List by owner=%s source=converted 結果未含 model %s共 %d 筆)",
userID, modelID, len(models))
}
// ── [5] 冪等:同 jobID 再 PromoteToModels → 回同一個 model_id ──────────────
idemCtx, idemCancel := context.WithTimeout(ctx, 1*time.Minute)
promoteRes2, err := svc.PromoteToModels(idemCtx, userID, jobID, modelName)
idemCancel()
if err != nil {
t.Fatalf("[5] 第二次 PromoteToModels冪等失敗%v", err)
}
if promoteRes2 == nil || promoteRes2.ModelID != modelID {
t.Errorf("[5] 冪等失敗:第二次 promote 應回同一 model_id=%s得 %+v", modelID, promoteRes2)
} else {
t.Logf("[5] 冪等 OK同 jobID 再 promote 回既有 model_id=%s未重複建", modelID)
}
// 再查一次 PG 確認只有一筆 converted model 對應此 jobID冪等不該建第二筆
cntCtx, cntCancel := context.WithTimeout(ctx, 10*time.Second)
all, err := pgRepo.List(cntCtx, model.ListFilter{OwnerUserID: userID, Source: model.SourceConverted})
cntCancel()
if err != nil {
t.Fatalf("[5] 冪等後 List 失敗:%v", err)
}
n := 0
for _, m := range all {
if m.SourceJobID == jobID {
n++
}
}
if n != 1 {
t.Errorf("[5] 冪等後對應 jobID=%s 的 converted model 應只有 1 筆,得 %d 筆", jobID, n)
}
t.Logf("完整鏈路驗證通過:真轉檔 → PromoteToModels → model 進 PG → 冪等,全部 OK。")
}
// pollChainUntilTerminal 用 flow.GetJob 對真服務 poll 到 completed/failed 或 timeout。
//
// 與 real_converter_e2e_test.go 的 pollUntilTerminal 不同:這裡走 **flow.GetJob**(含 ownership
// 檢查),驗的是 visionA 業務層的 poll而非 raw client。回傳對外 *Job。
func pollChainUntilTerminal(t *testing.T, svc Service, userID, jobID string) *Job {
t.Helper()
deadline := time.Now().Add(realChainPollTimeout)
var last *Job
logEvery := 10 // 每 N 次 poll 印一次進度,避免長轉檔時 log 太吵
i := 0
for time.Now().Before(deadline) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
j, err := svc.GetJob(ctx, userID, jobID)
cancel()
if err != nil {
t.Logf("GetJob 暫時失敗(續 poll%v", err)
time.Sleep(realChainPollInterval)
continue
}
last = j
i++
if i%logEvery == 0 {
t.Logf(" poll #%dstatus=%s stage=%q progress=%d stage_progress=%d",
i, j.Status, j.Stage, j.Progress, j.StageProgress)
}
switch j.Status {
case "completed", "failed":
return j
default:
time.Sleep(realChainPollInterval)
}
}
if last == nil {
t.Fatalf("poll job %s 超時且從未成功 GetJob", jobID)
}
t.Logf("poll 超時(%s回最後一次狀態status=%s", realChainPollTimeout, last.Status)
return last
}
// containsModelID 檢查 model 清單是否含指定 id。
func containsModelID(models []*model.Model, id string) bool {
for _, m := range models {
if m.ID == id {
return true
}
}
return false
}
// ==========================================================================
// 給 Orchestrator 對 stage 跑(本機連不到 stage / 無 PG → 預設 Skip
//
// 前置:
// 1. 取 converter API key從 stage container env不 hardcode
// KEY=$(docker -H tcp://192.168.0.130:2375 exec \
// kneron_model_converter-scheduler-1 printenv CONVERTER_API_KEY)
// 2. 取 PG DSN指 stage 真 PG已 migrate含 users / models 表)。
// 若 stage PG 連線資訊未知,可在 stage 機器上跑、用 localhost DSN
// 或本機起一個 docker PG 並對它跑 migrations/0001_create_users_models.up.sql。
// 3. fixtureonnx + 56 張 ref 圖default 路徑見 real_converter_e2e_test.go
// env VISIONA_REAL_CONVERTER_ONNX / VISIONA_REAL_CONVERTER_IMAGES 可覆寫)。
//
// 跑:
// VISIONA_REAL_CONVERTER_URL=http://192.168.0.130:9501 \
// VISIONA_CONVERTER_API_KEY="$KEY" \
// VISIONA_REAL_PG_DSN="postgres://visiona:<pw>@<pg-host>:5432/visiona?sslmode=disable" \
// go test -tags=realconv ./internal/conversion/ \
// -run TestRealChain_ConvertPromoteToPGModelLibrary -count=1 -v -timeout=20m
//
// 注意:真 KTC 轉檔較慢,-timeout 給 20mpoll deadline 內建 10m
// 測完會軟刪除建立的 modelt.Cleanuptest user 留在 PG無害、固定 UUID 可重用)。
// ==========================================================================

View File

@ -0,0 +1,491 @@
//go:build realconv
// real_converter_e2e_test.go — 對「真實轉檔服務」(kneron_model_converter task-scheduler)
// 驗 visionA conversion client 的 contract e2e**非 mock**)。
//
// Owner: testing agent真轉檔 contract e2e — build tag 隔離,預設 CI 不跑)
//
// build tag `realconv`:只在 `go test -tags=realconv` 時編譯/執行。
// - 預設 `go test ./...` 與 `-tags=dbtest` 都**不**編譯本檔(避免外部服務依賴污染主測試集)。
// - 本機通常連不到 stageVPN/網路),由 Orchestrator 對 stage 192.168.0.130 跑(見檔尾「給
// Orchestrator 對 stage 跑」段)。
//
// 測什麼mock 測不到的部分):直接用 visionA 的 ConverterClientconverter_client.go
// httptest mock打 stage 真轉檔服務,驗 visionA 端對「真服務實際回的格式」解析正確:
// 1. 連線 + 認證(真 API key 過認證)
// 2. InitJob 真送 onnx + 56 張 ref 圖multipart→ 拿 job_id / status=created / stage=onnx
// 3. GetJob poll 到 completedstub 模式幾秒)→ 解析真服務 completed response 不報錯/不 panic
// 4. stub 環境真實行為result_object_keys=null、analysis_info 不存在時visionA
// InputShape/Classes/Framework 為零值B4 鏈路防禦性,不報錯)
// 5. promote 失敗路徑stub 無真 nef → converter 回 500 → visionA 正確包裝成 sentinel、不 panic
//
// ⚠️ 範圍界定:**對 stub 模式驗 contract**。stage 三個 workeronnx/bie/nef皆 WORKER_MODE=stub
// Dockerfile.stub不跑真 KTC、產佔位輸出GET /result 回 15 bytes "STUB_NEF_OUTPUT")。
// 真 KTC 轉檔 / 真 nef / promote 成功路徑需轉檔端 worker 切真模式,**不在此測範圍**
// (那是 kneron_model_converter repo 的事,本測不嘗試開真 KTC / 改 worker 模式)。
//
// 對齊converter_client.go endpoint 註解 + Orchestrator 手動實測的真服務 contract。
package conversion
import (
"bytes"
"context"
"errors"
"io"
"log/slog"
"mime/multipart"
"os"
"path/filepath"
"sort"
"strconv"
"testing"
"time"
)
// ==========================================================================
// 環境 guard — 沒設 env / fixture 不存在 → t.Skip優雅跳過印清楚啟用方式
// ==========================================================================
const (
// realConvURLEnv 是 stage 真轉檔服務 base URL如 http://192.168.0.130:9501
realConvURLEnv = "VISIONA_REAL_CONVERTER_URL"
// realConvAPIKeyEnv 是 stage 上對齊的 converter API key從 container env 取,**不 hardcode**)。
realConvAPIKeyEnv = "VISIONA_CONVERTER_API_KEY"
// realConvOnnxEnv 是 input onnx fixture 路徑(覆寫預設)。
realConvOnnxEnv = "VISIONA_REAL_CONVERTER_ONNX"
// realConvImagesEnv 是 ref images 目錄路徑(覆寫預設)。
realConvImagesEnv = "VISIONA_REAL_CONVERTER_IMAGES"
// 預設 fixture 路徑Orchestrator 手動實測來源。env 沒設時試這些,仍找不到 → Skip。
defaultOnnxFixture = "/Users/jimchen/kneron_model_converter/tests/fixtures/bie/input.onnx"
defaultImagesFixture = "/Users/jimchen/kneron_model_converter/tests/fixtures/bie_images"
// 測試用可識別 user_idstub job 無害、會自然過期 expires_at
realConvTestUserID = "e2e-realconv-test"
// stub 模式下 onnx→bie→nef 幾秒就跑完;給寬裕 poll deadline。
realConvPollTimeout = 90 * time.Second
realConvPollInterval = 1 * time.Second
)
// realConvEnv 是 guard 通過後回傳的環境設定。
type realConvEnv struct {
baseURL string
apiKey string
onnxPath string
imagesPath string
}
// requireRealConvEnv 檢查 env + fixture缺任一 → t.Skip 並印「怎麼啟用這個測試」。
func requireRealConvEnv(t *testing.T) realConvEnv {
t.Helper()
baseURL := os.Getenv(realConvURLEnv)
apiKey := os.Getenv(realConvAPIKeyEnv)
if baseURL == "" || apiKey == "" {
t.Skipf(`real-converter e2e 跳過未設環境變數
啟用方式 stage 192.168.0.130
1. stage 上對齊的 API key converter container env
KEY=$(docker -H tcp://192.168.0.130:2375 exec kneron_model_converter-scheduler-1 printenv CONVERTER_API_KEY)
2.
VISIONA_REAL_CONVERTER_URL=http://192.168.0.130:9501 \
VISIONA_CONVERTER_API_KEY="$KEY" \
go test -tags=realconv ./internal/conversion/ -run TestRealConverter -count=1 -v
缺少的環境變數%s / %s兩者皆必填`, realConvURLEnv, realConvAPIKeyEnv)
}
onnxPath := os.Getenv(realConvOnnxEnv)
if onnxPath == "" {
onnxPath = defaultOnnxFixture
}
imagesPath := os.Getenv(realConvImagesEnv)
if imagesPath == "" {
imagesPath = defaultImagesFixture
}
if _, err := os.Stat(onnxPath); err != nil {
t.Skipf("real-converter e2e 跳過input onnx fixture 不存在:%serr=%v。"+
"用 %s 指定路徑。", onnxPath, err, realConvOnnxEnv)
}
if fi, err := os.Stat(imagesPath); err != nil || !fi.IsDir() {
t.Skipf("real-converter e2e 跳過ref images 目錄不存在:%serr=%v。"+
"用 %s 指定路徑。", imagesPath, err, realConvImagesEnv)
}
return realConvEnv{
baseURL: baseURL,
apiKey: apiKey,
onnxPath: onnxPath,
imagesPath: imagesPath,
}
}
// newRealConverterClient 用真 env 建一個 ConverterClient**非 mock**,打真服務)。
//
// 用較長的 init/get timeout真服務 multipart 上傳 + stub 幾秒處理需要餘裕。
func newRealConverterClient(t *testing.T, env realConvEnv) ConverterClient {
t.Helper()
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
return NewConverterClient(ConverterClientOpts{
BaseURL: env.baseURL,
APIKey: env.apiKey,
Logger: logger,
})
}
// ==========================================================================
// fixture組真 multipart bodyonnx + 56 張 ref 圖 + form fields
// ==========================================================================
// buildRealInitBody 組 stage 真轉檔服務 POST /api/v1/jobs 需要的 multipart body。
//
// 對齊 Orchestrator 手動實測的真 API contract
// fields: model(file onnx) + ref_images(N files) + user_id + model_id + version + platform("520") + enable_evaluate("false")
//
// 回傳 body bytes + Content-Type含 boundary+ ref_images 數量(給斷言驗 ref_images_count
func buildRealInitBody(t *testing.T, env realConvEnv) (body []byte, contentType string, refCount int) {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
// form fieldsconverter multer 慣例fields 在 file 之前)
writeField := func(name, val string) {
if err := mw.WriteField(name, val); err != nil {
t.Fatalf("write multipart field %q: %v", name, err)
}
}
writeField("user_id", realConvTestUserID)
// model_id 必須能 parse 成數字(轉檔服務驗證:非數字字串 → 400 validation_error
// 對齊手動實測model_id=9999與轉檔端 test_flow_e2e.pymodel_id=10
writeField("model_id", "9999")
// version 為字串、無型別限制;用簡單值對齊手動成功("v1")。
writeField("version", "v1")
writeField("platform", "520")
writeField("enable_evaluate", "false")
// model fileonnx
onnxBytes, err := os.ReadFile(env.onnxPath)
if err != nil {
t.Fatalf("read onnx fixture %s: %v", env.onnxPath, err)
}
fw, err := mw.CreateFormFile("model", filepath.Base(env.onnxPath))
if err != nil {
t.Fatalf("create form file model: %v", err)
}
if _, err := fw.Write(onnxBytes); err != nil {
t.Fatalf("write onnx bytes: %v", err)
}
// ref_images讀目錄下所有 *.jpg依檔名數字排序送與手動實測一致
entries, err := os.ReadDir(env.imagesPath)
if err != nil {
t.Fatalf("read images dir %s: %v", env.imagesPath, err)
}
jpgs := make([]string, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if filepath.Ext(name) == ".jpg" {
jpgs = append(jpgs, name)
}
}
// 依檔名數字排序0.jpg, 1.jpg, ... 而非字典序 1,10,100
sort.Slice(jpgs, func(i, j int) bool {
ni := jpgStem(jpgs[i])
nj := jpgStem(jpgs[j])
if ni != nj {
return ni < nj
}
return jpgs[i] < jpgs[j]
})
for _, name := range jpgs {
full := filepath.Join(env.imagesPath, name)
imgBytes, err := os.ReadFile(full)
if err != nil {
t.Fatalf("read ref image %s: %v", full, err)
}
ifw, err := mw.CreateFormFile("ref_images", name)
if err != nil {
t.Fatalf("create form file ref_images %s: %v", name, err)
}
if _, err := ifw.Write(imgBytes); err != nil {
t.Fatalf("write ref image %s: %v", name, err)
}
refCount++
}
if err := mw.Close(); err != nil {
t.Fatalf("close multipart writer: %v", err)
}
if refCount == 0 {
t.Fatalf("ref images 目錄 %s 沒有 .jpg 檔(預期 56 張)", env.imagesPath)
}
return buf.Bytes(), mw.FormDataContentType(), refCount
}
// jpgStem 取 "<n>.jpg" 的數字部分;非數字回 -1排到最前不影響正確性
func jpgStem(name string) int {
stem := name[:len(name)-len(filepath.Ext(name))]
n, err := strconv.Atoi(stem)
if err != nil {
return -1
}
return n
}
// ==========================================================================
// E2E #1連線 + 認證 + InitJob + GetJob pollcontract 主路徑)
// ==========================================================================
// TestRealConverter_InitAndPollContract 驗 visionA ConverterClient 對真 stage 服務的主路徑:
//
// InitJob真送 onnx + 56 圖)→ status=created/running + stage=onnx
// → GetJob poll 到 completedstub 幾秒)
// → visionA 正確解析真服務 completed response即使 result_object_keys=null、無 analysis_info
// 也不報錯、不 panicInputShape/Classes/Framework 為零值)
//
// 連線 + 認證隱含驗證InitJob 沒回 ErrConverterAuthFailed = API key 過認證、連得上。
func TestRealConverter_InitAndPollContract(t *testing.T) {
env := requireRealConvEnv(t)
client := newRealConverterClient(t, env)
body, contentType, refCount := buildRealInitBody(t, env)
t.Logf("組好 multipart body%d bytesref_images=%d 張", len(body), refCount)
// ── InitJob真送──────────────────────────────────────────────────
initCtx, initCancel := context.WithTimeout(context.Background(), 60*time.Second)
defer initCancel()
cj, err := client.InitJob(initCtx, InitConverterJobReq{
UserID: realConvTestUserID,
Platform: "520",
SourceFilename: filepath.Base(env.onnxPath),
Body: bytes.NewReader(body),
BodyContentType: contentType,
})
if err != nil {
// 認證失敗 → 明確訊息最可能API key 未對齊)
if errors.Is(err, ErrConverterAuthFailed) {
t.Fatalf("InitJob 認證失敗API key 未對齊?):%v\n"+
"確認 %s 與 stage container CONVERTER_API_KEY 一致。", err, realConvAPIKeyEnv)
}
t.Fatalf("InitJob 失敗:%v", err)
}
// 連線 + 認證 OK沒回 auth error 就是過了)
if cj.JobID == "" {
t.Fatalf("InitJob 回的 job_id 為空:%+v", cj)
}
// 真服務回 status=created手動實測visionA client 透傳。亦容忍 runningrace
if cj.Status != "created" && cj.Status != "running" {
t.Errorf("InitJob status 預期 created/running得 %qjob=%+v", cj.Status, cj)
}
if cj.Stage != "onnx" {
t.Errorf("InitJob stage 預期 onnx得 %q", cj.Stage)
}
t.Logf("InitJob OKjob_id=%s status=%s stage=%s", cj.JobID, cj.Status, cj.Stage)
// ── GetJob poll 到 completed ───────────────────────────────────────
final := pollUntilTerminal(t, client, cj.JobID)
t.Logf("終態 jobstatus=%s stage=%q progress=%v input_filename=%q platform=%q",
final.Status, final.Stage, derefInt(final.Progress), final.SourceFilename, final.Platform)
// stub 模式預期 completed若 failed 也不該 paniccontract 仍須能解析)
if final.Status != "completed" {
t.Errorf("poll 終態預期 completedstub 模式幾秒完成),得 %qerror_code=%q msg=%q",
final.Status, final.ErrorCode, final.ErrorMessage)
}
// completed 時真服務回 stage=null → visionA 解析成 ""(不報錯)
if final.Status == "completed" && final.Stage != "" {
t.Errorf("completed 時 stage 預期空字串(真服務回 null得 %q", final.Stage)
}
// ── 驗 stub 環境真實行為analysis_info 缺 → B4 metadata 全零值,不報錯 ──
// 這是 mock 測不到的:真 stub 服務的 completed response 沒有 analysis_info
// visionA toConverterJob 必須優雅留零值(防禦性),不能 panic / 報錯。
if final.InputShape != nil {
t.Logf("注意:真服務回了 analysis_info.input_shape=%vstub 模式預期 nil"+
"若轉檔端已串 analysis_info 則此為正常)", final.InputShape)
}
if len(final.Classes) != 0 {
t.Logf("注意:真服務回了 classes=%vstub 模式預期空)", final.Classes)
}
// 不對 InputShape 斷言「必為 nil」——若轉檔端未來串好 analysis_info這裡不該 fail
// 重點是「解析不 panic」能跑到這行就證明解析成功。
t.Logf("contract 驗證通過visionA client 成功解析真服務 completed response未 panic")
}
// pollUntilTerminal 用 visionA GetJob 對真服務 poll 到 completed/failed 或 timeout。
func pollUntilTerminal(t *testing.T, client ConverterClient, jobID string) *ConverterJob {
t.Helper()
deadline := time.Now().Add(realConvPollTimeout)
var last *ConverterJob
for time.Now().Before(deadline) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
cj, err := client.GetJob(ctx, jobID)
cancel()
if err != nil {
// 真服務暫時 5xx → GetJob 內已 retry這裡再容忍一次記 log 續 poll
t.Logf("GetJob 暫時失敗(續 poll%v", err)
time.Sleep(realConvPollInterval)
continue
}
last = cj
switch cj.Status {
case "completed", "failed":
return cj
default:
time.Sleep(realConvPollInterval)
}
}
if last == nil {
t.Fatalf("poll job %s 超時且從未成功 GetJob", jobID)
}
t.Logf("poll 超時回最後一次狀態status=%s", last.Status)
return last
}
// ==========================================================================
// E2E #2promote 失敗路徑stub 無真 nef → converter 500 → visionA 不崩)
// ==========================================================================
// TestRealConverter_PromoteFailsGracefullyOnStub 驗「真服務失敗時 visionA 不崩」:
//
// stub 模式下 GET /result 回 15 bytes 佔位字串、promote 因無真 nef → converter 回 500
// `{error:{code:"internal_error",...}}`。visionA Promote 必須:
// - 不 panic
// - 把 500 正確包裝成 ErrConverterUnavailablemapPromoteError 預設 5xx 分支)
//
// 這是有價值的 contract 測試:驗 visionA 對真服務 5xx 的錯誤處理鏈正確。
func TestRealConverter_PromoteFailsGracefullyOnStub(t *testing.T) {
env := requireRealConvEnv(t)
client := newRealConverterClient(t, env)
// 先 init + poll 到 completedpromote 前提job 須 completed
body, contentType, _ := buildRealInitBody(t, env)
initCtx, initCancel := context.WithTimeout(context.Background(), 60*time.Second)
defer initCancel()
cj, err := client.InitJob(initCtx, InitConverterJobReq{
UserID: realConvTestUserID,
Platform: "520",
SourceFilename: filepath.Base(env.onnxPath),
Body: bytes.NewReader(body),
BodyContentType: contentType,
})
if err != nil {
t.Fatalf("InitJob 失敗promote 測試前置):%v", err)
}
final := pollUntilTerminal(t, client, cj.JobID)
if final.Status != "completed" {
t.Skipf("job 未 completedstatus=%s跳過 promote 失敗路徑驗證", final.Status)
}
// ── Promote — stub 無真 nef預期 converter 回 500 ────────────────────
promoteCtx, promoteCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer promoteCancel()
// 包進 func 確保「不 panic」可被測到panic 會讓 test fail 並印 stack
res, perr := client.Promote(promoteCtx, cj.JobID, PromoteReq{
UserID: realConvTestUserID,
Source: "nef",
TargetObjectKey: "models/" + realConvTestUserID + "/" + cj.JobID + ".nef",
})
if perr == nil {
// 若 stage 某天切真模式 → promote 可能成功;不 fail記 logcontract 仍成立)
t.Logf("注意Promote 成功stage 可能已非 stub 模式):%+v", res)
return
}
// 預期stub 無真 nef → converter 500 → visionA 包成 ErrConverterUnavailable
t.Logf("Promote 如預期失敗stub 無真 nef%v", perr)
if !errors.Is(perr, ErrConverterUnavailable) {
// 容忍其他合理 sentinel如 ErrJobNotCompleted / ErrFAAUnavailable但記下供人工判讀。
t.Logf("注意Promote 錯誤非 ErrConverterUnavailable得 %v。"+
"確認真服務 500 body 是否符合手動實測的 internal_error 格式;"+
"若 converter 回了不同 status 此處需對齊。", perr)
}
// 關鍵 contractvisionA 回了「分類好的 error」而非 nil result + nil err不崩
if res != nil {
t.Errorf("Promote 失敗時 result 應為 nil得 %+v", res)
}
}
// ==========================================================================
// E2E #3GetResult 解析真 stub 輸出15 bytes "STUB_NEF_OUTPUT"
// ==========================================================================
// TestRealConverter_GetResultStubOutput 驗 visionA GetResult 對真 stub 服務的 streaming 解析:
//
// stub 模式 GET /result 回 15 bytes 字串 "STUB_NEF_OUTPUT"(非真 nef
// visionA GetResult 必須:
// - 不 panic
// - 回 io.ReadCloser stream + DownloadMetadata能讀出 body、Close 正常)
//
// 這驗的是「真服務 result endpoint 的 streaming response 解析」——mock 用固定 marker
// 此處用真服務的實際佔位輸出。
func TestRealConverter_GetResultStubOutput(t *testing.T) {
env := requireRealConvEnv(t)
client := newRealConverterClient(t, env)
body, contentType, _ := buildRealInitBody(t, env)
initCtx, initCancel := context.WithTimeout(context.Background(), 60*time.Second)
defer initCancel()
cj, err := client.InitJob(initCtx, InitConverterJobReq{
UserID: realConvTestUserID,
Platform: "520",
SourceFilename: filepath.Base(env.onnxPath),
Body: bytes.NewReader(body),
BodyContentType: contentType,
})
if err != nil {
t.Fatalf("InitJob 失敗GetResult 測試前置):%v", err)
}
final := pollUntilTerminal(t, client, cj.JobID)
if final.Status != "completed" {
t.Skipf("job 未 completedstatus=%s跳過 GetResult 驗證", final.Status)
}
resCtx, resCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer resCancel()
stream, meta, gerr := client.GetResult(resCtx, cj.JobID)
if gerr != nil {
// stub 模式下 GET /result 預期回 200 + 佔位輸出;若回錯誤記下供人工判讀
t.Logf("注意GetResult 失敗stub 預期成功回佔位輸出):%v", gerr)
// 仍驗「失敗時不回半套」stream 應為 nil
if stream != nil {
_ = stream.Close()
t.Errorf("GetResult 失敗時 stream 應為 nil")
}
return
}
defer stream.Close()
got, rerr := io.ReadAll(io.LimitReader(stream, 1024))
if rerr != nil {
t.Fatalf("讀 GetResult stream 失敗:%v", rerr)
}
t.Logf("GetResult OK讀到 %d bytescontent_type=%q filename=%q content_length=%d。內容=%q",
len(got), meta.ContentType, meta.Filename, meta.ContentLength, string(got))
// stub 真實行為15 bytes "STUB_NEF_OUTPUT"(手動實測)。不硬斷言內容(轉檔端可能改),
// 重點是「解析 streaming response 成功、能讀出 body」。
if len(got) == 0 {
t.Errorf("GetResult stream 讀到 0 bytesstub 預期回 15 bytes 佔位輸出)")
}
}
// ==========================================================================
// helpers
// ==========================================================================
func derefInt(p *int) int {
if p == nil {
return -1
}
return *p
}

View File

@ -34,6 +34,17 @@ type ProviderConfig struct {
// Scopes 是 OIDC scope 清單,預設 ["openid", "email", "profile"]。 // Scopes 是 OIDC scope 清單,預設 ["openid", "email", "profile"]。
// 若為空NewProvider 會套用預設值。 // 若為空NewProvider 會套用預設值。
Scopes []string Scopes []string
// PromptLogin 控制 authorize request 是否帶 OIDC `prompt=login` 參數。
//
// - true → 每次登入都強制 IdP 重新認證(忽略既有 SSO session要求重輸帳密
// - false → 不帶 prompt沿用 IdP 既有 session標準 SSO 體驗),為預設值。
//
// `prompt=login` 是 OIDC Core §3.1.2.1 標準參數,由 callerOIDC discovery 不一定
// 在 prompt_values_supported 列出,但 Core 標準參數 IdP 多半支援)。設成 config 可控
// 是為了讓「強制重新認證」在不同部署間可開可關 — 例如 stage 要每次都問、prod 視 UX 決定。
// 對齊 VISIONA_OIDC_PROMPT_LOGIN。
PromptLogin bool
} }
// DefaultScopes 是 OIDC 標準 scope 集合,能取得 sub / email / name 三個 claim。 // DefaultScopes 是 OIDC 標準 scope 集合,能取得 sub / email / name 三個 claim。

View File

@ -136,13 +136,19 @@ func validateConfig(cfg *ProviderConfig) error {
// //
// 用 oauth2.Config.AuthCodeURL 組 URL加上 PKCE 與 nonce 兩個額外參數 // 用 oauth2.Config.AuthCodeURL 組 URL加上 PKCE 與 nonce 兩個額外參數
// oauth2 lib 原生不知道這兩個東西,需以 oauth2.SetAuthURLParam 注入)。 // oauth2 lib 原生不知道這兩個東西,需以 oauth2.SetAuthURLParam 注入)。
//
// 若 cfg.PromptLogin 為 true額外帶 OIDC `prompt=login`Core §3.1.2.1
// 讓 IdP 忽略既有 SSO session、每次都強制使用者重新認證。預設不帶沿用 SSO session
func (p *provider) AuthorizationURL(state, nonce, codeChallenge string) string { func (p *provider) AuthorizationURL(state, nonce, codeChallenge string) string {
return p.oauth2Cfg.AuthCodeURL( opts := []oauth2.AuthCodeOption{
state,
oauth2.SetAuthURLParam("code_challenge", codeChallenge), oauth2.SetAuthURLParam("code_challenge", codeChallenge),
oauth2.SetAuthURLParam("code_challenge_method", "S256"), oauth2.SetAuthURLParam("code_challenge_method", "S256"),
oauth2.SetAuthURLParam("nonce", nonce), oauth2.SetAuthURLParam("nonce", nonce),
) }
if p.cfg.PromptLogin {
opts = append(opts, oauth2.SetAuthURLParam("prompt", "login"))
}
return p.oauth2Cfg.AuthCodeURL(state, opts...)
} }
// ExchangeCode 實作 Provider.ExchangeCode。 // ExchangeCode 實作 Provider.ExchangeCode。

View File

@ -331,6 +331,50 @@ func TestAuthorizationURL_Format(t *testing.T) {
// authorization_endpoint 應指向 fake server 的 /authorize // authorization_endpoint 應指向 fake server 的 /authorize
assert.Equal(t, fake.issuer()+"/authorize", u.Scheme+"://"+u.Host+u.Path) assert.Equal(t, fake.issuer()+"/authorize", u.Scheme+"://"+u.Host+u.Path)
// 預設 PromptLogin=false → authorize URL **不**帶 prompt 參數(沿用 SSO session
assert.Empty(t, q.Get("prompt"), "預設不應帶 prompt 參數")
}
// TestAuthorizationURL_PromptLogin 驗 PromptLogin config 開關對 authorize URL 的影響。
func TestAuthorizationURL_PromptLogin(t *testing.T) {
fake := newFakeOIDC(t, testClientID)
newProviderWithPrompt := func(t *testing.T, promptLogin bool) Provider {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
p, err := NewProvider(ctx, ProviderConfig{
IssuerURL: fake.issuer(),
ClientID: testClientID,
ClientSecret: testClientSecret,
RedirectURL: testRedirect,
PromptLogin: promptLogin,
})
require.NoError(t, err)
return p
}
state, _ := GenerateState()
nonce, _ := GenerateNonce()
verifier, _ := GenerateCodeVerifier()
challenge := CodeChallenge(verifier)
t.Run("enabled adds prompt=login", func(t *testing.T) {
p := newProviderWithPrompt(t, true)
u, err := url.Parse(p.AuthorizationURL(state, nonce, challenge))
require.NoError(t, err)
assert.Equal(t, "login", u.Query().Get("prompt"),
"PromptLogin=true 時 authorize URL 應帶 prompt=login")
})
t.Run("disabled omits prompt", func(t *testing.T) {
p := newProviderWithPrompt(t, false)
u, err := url.Parse(p.AuthorizationURL(state, nonce, challenge))
require.NoError(t, err)
assert.Empty(t, u.Query().Get("prompt"),
"PromptLogin=false 時 authorize URL 不應帶 prompt 參數")
})
} }
func TestExchangeCode_Success(t *testing.T) { func TestExchangeCode_Success(t *testing.T) {
@ -658,9 +702,9 @@ func TestExchangeCode_PublicClientNoSecretSent(t *testing.T) {
// 取出 fake server 觀察到的 token request // 取出 fake server 觀察到的 token request
var ( var (
form url.Values form url.Values
auth string auth string
ok bool ok bool
) )
fake.withState(func() { fake.withState(func() {
form = fake.lastTokenForm form = fake.lastTokenForm

View File

@ -18,6 +18,9 @@ import { useAuthStore } from "./auth-store";
* - fetchMe hydrate * - fetchMe hydrate
* - logout 200 user * - logout 200 user
* - logout backend userbest-effort * - logout backend userbest-effort
* - logout idp_logout MC iframe
* - logout idp_logout iframe
* - logout iframe onload fallback timer resolve
* - localStorageOF6 * - localStorageOF6
*/ */
@ -49,6 +52,8 @@ describe("auth-store", () => {
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
window.localStorage.clear(); window.localStorage.clear();
// 清掉任何測試殘留的 iframe避免污染下一個測試的 querySelector
document.querySelectorAll("iframe").forEach((el) => el.remove());
}); });
/* ------------------------------------------------------------------------ */ /* ------------------------------------------------------------------------ */
@ -250,6 +255,110 @@ describe("auth-store", () => {
await useAuthStore.getState().logout(); await useAuthStore.getState().logout();
expect(useAuthStore.getState().user).toBeNull(); expect(useAuthStore.getState().user).toBeNull();
// backend 失敗 → 拿不到 idp_logout → 不該建 iframe退回只清 visionA
expect(document.querySelector("iframe")).toBeNull();
});
/* ------------------------------------------------------------------------ */
/* logout — IdPMember Center連動登出 */
/* ------------------------------------------------------------------------ */
it("logoutresponse 帶 idp_logout → 建隱藏 iframe 觸發 MC 登出,最終 resolve 並清掉 iframe", async () => {
useAuthStore.getState()._setUser({ id: "u", email: "e@x", name: "U" });
const mcLogoutUrl = "https://stage-9527.innovedus.com:7880/account/logout";
fetchMock.mockResolvedValue(
jsonResponse(200, {
success: true,
data: {
success: true,
idp_logout: { url: mcLogoutUrl, method: "GET" },
},
}),
);
// jsdom 不會真的載入 cross-origin iframeonload 不觸發)→ 走 fallback timer。
// 用 fake timers 控制logout 的 promise 會等到 timer 觸發才 resolve。
vi.useFakeTimers();
try {
const logoutPromise = useAuthStore.getState().logout();
// 等 backend POST + set state 完成microtask flushiframe 此時應已建立
await vi.advanceTimersByTimeAsync(0);
const iframe = document.querySelector("iframe");
expect(iframe).not.toBeNull();
expect(iframe?.getAttribute("src")).toBe(mcLogoutUrl);
expect((iframe as HTMLIFrameElement).style.display).toBe("none");
// 前端 user 已清(在連動 MC 前就清了)
expect(useAuthStore.getState().user).toBeNull();
// 推進 fallback timer2000ms→ logout 應 resolve、iframe 被移除
await vi.advanceTimersByTimeAsync(2000);
await logoutPromise;
expect(document.querySelector("iframe")).toBeNull();
} finally {
vi.useRealTimers();
}
});
it("logoutiframe load 事件觸發時提早 resolve不必等滿 fallback timer", async () => {
useAuthStore.getState()._setUser({ id: "u" });
fetchMock.mockResolvedValue(
jsonResponse(200, {
success: true,
data: {
success: true,
idp_logout: { url: "https://mc.example/account/logout" },
},
}),
);
const logoutPromise = useAuthStore.getState().logout();
// 等 iframe 建立後backend POST → res.text() → set state → triggerIdpLogout
// 之間有多個 microtask + macrotask手動派發 load 事件模擬「MC 同源頁載入完成」
const iframe = await vi.waitFor(() => {
const el = document.querySelector("iframe");
if (!el) throw new Error("iframe not yet created");
return el;
});
iframe.dispatchEvent(new Event("load"));
await logoutPromise;
// load 觸發後 iframe 應被清掉
expect(document.querySelector("iframe")).toBeNull();
expect(useAuthStore.getState().user).toBeNull();
});
it("logoutresponse 無 idp_logout → 維持舊行為,不建 iframe向下相容", async () => {
useAuthStore.getState()._setUser({ id: "u", email: "e@x", name: "U" });
fetchMock.mockResolvedValue(
jsonResponse(200, { success: true, data: { success: true } }),
);
await useAuthStore.getState().logout();
expect(useAuthStore.getState().user).toBeNull();
expect(document.querySelector("iframe")).toBeNull();
});
it("logoutidp_logout.url 為空字串 → 視為無,不建 iframe", async () => {
useAuthStore.getState()._setUser({ id: "u" });
fetchMock.mockResolvedValue(
jsonResponse(200, {
success: true,
data: { success: true, idp_logout: { url: "", method: "GET" } },
}),
);
await useAuthStore.getState().logout();
expect(document.querySelector("iframe")).toBeNull();
}); });
/* ------------------------------------------------------------------------ */ /* ------------------------------------------------------------------------ */

View File

@ -9,6 +9,8 @@
* *
* - 使 backend `GET /api/auth/me` * - 使 backend `GET /api/auth/me`
* - hydrateapp boot/ fetchMe refresh/ logout actions * - hydrateapp boot/ fetchMe refresh/ logout actions
* - logout IdPMember Center MC session 使
* backend logout response `idp_logout.url` iframe
* *
* BFF * BFF
* - **frontend OIDC token**access_token / id_token backend cookie session * - **frontend OIDC token**access_token / id_token backend cookie session
@ -45,6 +47,36 @@ interface MeResponse {
name?: string; name?: string;
} }
/**
* `POST /api/auth/logout` envelope `data` payload
* visionA-backend logout handler
* { success: true, idp_logout?: { url, method } }
*
* - response visionA session cookie
* - `idp_logout` IdPMember Center**optional**
* backend logout URL visionA
* - `method` GETstage MC POST
*/
interface LogoutResponse {
success?: boolean;
idp_logout?: {
url: string;
method?: string;
};
}
/**
* IdPMember Center visionA
*
* fallback timer
* - iframe MC :7880 logout `X-Frame-Options` / CSP `frame-ancestors`
* `onload` 使 timer
* - 使 iframe ** GET **X-Frame-Options
* MC logout request session cookie
*
*/
const IDP_LOGOUT_FALLBACK_MS = 2000;
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* Types */ /* Types */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
@ -72,8 +104,17 @@ export interface AuthState {
fetchMe: () => Promise<void>; fetchMe: () => Promise<void>;
/** /**
* backend `POST /api/auth/logout` server session + cookie *
* user state * 1. backend `POST /api/auth/logout` server session + visiona_session cookie
* 2. user state backend best-effort
* 3. response `idp_logout` Member Center iframe
* MC session 使MC
*
* response `idp_logout`backend logout URL
* visionA MC
*
* resolve caller /login MC session
*
*/ */
logout: () => Promise<void>; logout: () => Promise<void>;
@ -98,6 +139,72 @@ function mapMeToUser(me: MeResponse): User {
}; };
} }
/* -------------------------------------------------------------------------- */
/* IdPMember Center連動登出 */
/* -------------------------------------------------------------------------- */
/**
* visionA Member Center MC session
* cookie使
*
* iframe `url`GET MC session
* 使 visionAMC 使 MC
* stage MC returnUrl `location = url` MC
*
* resolve
* 1. iframe `load` MC
* 2. fallback timer iframe X-Frame-Options / CSP load
*
* resolve iframe DOM
*
*
* - SSR / document resolve
* - iframe resolve MC
*/
function triggerIdpLogout(url: string): Promise<void> {
// SSR / 無 DOM 環境(理論上 logout 只在 client 觸發,但保險處理)
if (typeof document === "undefined") {
return Promise.resolve();
}
return new Promise<void>((resolve) => {
let settled = false;
let iframe: HTMLIFrameElement | null = null;
let timerId: ReturnType<typeof setTimeout> | null = null;
const cleanup = () => {
if (settled) return;
settled = true;
if (timerId !== null) clearTimeout(timerId);
// 從 DOM 移除 iframe保險用 optional chaining避免 race
if (iframe && iframe.parentNode) {
iframe.parentNode.removeChild(iframe);
}
resolve();
};
try {
iframe = document.createElement("iframe");
iframe.style.display = "none";
// 安全性:限制 iframe 能力。allow-same-origin 必須保留,否則部分瀏覽器
// 不會送 MC 的 session cookiecross-site cookie 需要 same-origin 上下文)。
iframe.setAttribute("aria-hidden", "true");
iframe.setAttribute("tabindex", "-1");
iframe.addEventListener("load", cleanup, { once: true });
// 設 src 觸發載入GET
iframe.src = url;
document.body.appendChild(iframe);
} catch {
// 建立 / 掛載失敗 → 不阻擋登出流程
cleanup();
return;
}
// Fallbackiframe 被擋onload 不觸發timer 到就往下走,避免卡死
timerId = setTimeout(cleanup, IDP_LOGOUT_FALLBACK_MS);
});
}
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* Store */ /* Store */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
@ -139,15 +246,31 @@ export const useAuthStore = create<AuthState>()((set, get) => ({
logout: async () => { logout: async () => {
set({ isLoading: true }); set({ isLoading: true });
let idpLogoutUrl: string | null = null;
try { try {
// backend 會清 server session + 回 Set-Cookie 把 visiona_session 過期 // backend 會清 server session + 回 Set-Cookie 把 visiona_session 過期,
await api.post("/api/auth/logout"); // 並(若有設定)回 idp_logout = { url, method } 供前端連動 MC 登出。
const res = await api.post<LogoutResponse>("/api/auth/logout");
const url = res?.idp_logout?.url;
// 容忍 idp_logout 不存在 / url 非字串 / 空字串(向下相容舊 backend
if (typeof url === "string" && url.length > 0) {
idpLogoutUrl = url;
}
} catch { } catch {
// 即使 backend 失敗(網路 / 5xx— 仍然清前端 state // 即使 backend 失敗(網路 / 5xx— 仍然清前端 state
// 否則使用者卡在「無法登出」。下次發 API 若 cookie 還在就照樣帶, // 否則使用者卡在「無法登出」。下次發 API 若 cookie 還在就照樣帶,
// backend session 已 best-effort 嘗試清除。 // backend session 已 best-effort 嘗試清除。失敗時拿不到 idp_logout
// 退回「只清 visionA」行為。
} }
// 先清前端 user state讓 UI 立即反映已登出),再背景連動 MC 登出。
set({ user: null, isLoading: false, error: null }); set({ user: null, isLoading: false, error: null });
// 有 idp_logout 才連動 MCawait 確保 caller 導向 /login 前 MC session 已(盡力)清完。
if (idpLogoutUrl) {
await triggerIdpLogout(idpLogoutUrl);
}
}, },
_setUser: (user) => { _setUser: (user) => {