接 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>
10 KiB
交接文件: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 端觀察到的)
-
使用者透過 MC 登入(OIDC Authorization Code Flow)。
-
visionA callback 拿到 id_token,解析後
emailclaim 為空 / 不存在。 -
visionA 端 provision user 失敗:
oidc.callback: provision user failed error:"user: Upsert requires non-empty email" -
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,對應 OIDCsub)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。
可以把這想成兩段:
- 產生 claim(誰負責把 email 放進 principal)→ 目前沒人做(缺的就是這段)
- 路由 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)一次涵蓋。
-
在
src/MemberCenter.Infrastructure/Identity/新建CustomUserClaimsPrincipalFactory: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; } } -
在
Program.cs(line 30-41 的 Identity 設定鏈)註冊,取代預設 factory:builder.Services .AddIdentity<ApplicationUser, ApplicationRole>(options => { /* 既有設定不變 */ }) .AddEntityFrameworkStores<MemberCenterDbContext>() .AddDefaultTokenProviders() .AddClaimsPrincipalFactory<CustomUserClaimsPrincipalFactory>(); // ← 新增這行 -
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.csline 26 的 switch 加上OpenIddictConstants.Claims.EmailVerified。
- ⚠️ 注意:目前
方案 (b)【快速】在兩個 Controller 手動加 email claim
較分散(兩處都要改),但改動最小。
-
OAuthController.csline 40 之後、line 42 的 foreach 之前插入: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.csline 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 無條件發
emailclaim(不管 confirmed),另用email_verified標記驗證狀態。是否擋未驗證 user 由 visionA 端自行決定(visionA 可選擇接受未驗證 email 先建 user,或讀到email_verified=false時擋下並引導驗證)。 - 這個決策需 MC 與 visionA 雙方確認後落地,避免「MC 改了但 visionA 仍 500」。
6. 改完怎麼驗
- 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>
- 端到端驗:visionA 重新登入 MC,確認:
- callback 拿到的 id_token 含非空
emailclaim - 不再出現
oidc.callback: provision user failed error:"user: Upsert requires non-empty email" - 登入成功(不再 500)、user 正確建立 / 更新
- callback 拿到的 id_token 含非空
- 回歸:確認既有 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 已一致,沿用同一常數即可)。