visionA/docs/autoflow/04-architecture/mc-email-claim-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

190 lines
10 KiB
Markdown
Raw Permalink 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.

# 交接文件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 已一致,沿用同一常數即可)。