# 交接文件: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` 只會放入: - `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 { public CustomUserClaimsPrincipalFactory( UserManager userManager, RoleManager roleManager, IOptions options) : base(userManager, roleManager, options) { } protected override async Task 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(options => { /* 既有設定不變 */ }) .AddEntityFrameworkStores() .AddDefaultTokenProviders() .AddClaimsPrincipalFactory(); // ← 新增這行 ``` 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 後到 或自行 decode,確認 payload 含: - `email`: `` - `email_verified`: `true` / `false`(若採 §5.3) - `sub`: `` 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 已一致,沿用同一常數即可)。