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

10 KiB
Raw Blame History

交接文件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 500failed 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 沒有自訂 IUserClaimsPrincipalFactorygrep IUserClaimsPrincipalFactory / UserClaimsPrincipalFactorysrc/ 下 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 的 DefaultScopesemail,已驗證
client 沒被授權 email scope 不是 MC 的 OpenIddictApplications 中 visionA client b8093fea1a504a5d8f0e04bee9f78f2e 的 Permissions 含 scp:email,已查 DB 確認
使用者沒有 email 不是 user b5332e51Email = jim800121.chen@gmail.com,已查 DB 確認。(且 Program.cs line 33 RequireUniqueEmail = trueMC 所有 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

    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.csline 30-41 的 Identity 設定鏈)註冊,取代預設 factory

    builder.Services
        .AddIdentity<ApplicationUser, ApplicationRole>(options => { /* 既有設定不變 */ })
        .AddEntityFrameworkStores<MemberCenterDbContext>()
        .AddDefaultTokenProviders()
        .AddClaimsPrincipalFactory<CustomUserClaimsPrincipalFactory>(); // ← 新增這行
    
  3. destination 路由不用改 —— ClaimsExtensions.GetDestinations()line 26-27已經會把 emailemail_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 之前插入:

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