接 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>
190 lines
10 KiB
Markdown
190 lines
10 KiB
Markdown
# 交接文件:MC id_token 缺 email claim(導致 visionA 建 user 500)
|
||
|
||
## 作者:Architect Agent(visionA 端)
|
||
## 對象:Member Center 團隊
|
||
## 狀態:待 MC 修復
|
||
## 最後更新:2026-06-26
|
||
|
||
---
|
||
|
||
## 1. 一句話總結
|
||
|
||
**MC(Member 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 Identity(8.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 grant(line 73-83)沿用既有 principal,若首次發 token 沒加 email、refresh 出來的也不會有。方案 (a) 因為在 factory 層處理,refresh 重新驗證時也會走到(取決於 OpenIddict refresh 流程),較不易漏;建議優先 (a)。
|
||
|
||
### 5.3 建議一併加 `email_verified` claim
|
||
|
||
`email_verified` 是 OIDC 標準 claim(boolean),值 = `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 26)switch 比對的 `OpenIddictConstants.Claims.Email` 為**同一常數**,確保 destination 路由能命中(這點目前 code 已一致,沿用同一常數即可)。
|