.NET8升级后OpenIddict访问令牌丢失自定义Claims问题
场景
我在ASP.NET 6服务器应用中使用OpenIddict已有一段时间,该应用负责提供认证并颁发令牌供其他ASP.NET应用使用。身份由Microsoft Identity管理,支持本地账户和Google/Microsoft等社交登录提供商,在.NET 6环境下运行完全正常。
问题
升级到.NET 8后,Authorization控制器中添加的所有自定义Claims(name、自定义claim、email)均从令牌中丢失。令牌仍能正常颁发和验证,但自定义Claims完全缺失。
详细信息
1. OpenIddict配置
services .AddOpenIddict() .AddCore(options => { options.UseEntityFrameworkCore().UseDbContext<AuthorizationDbContext>(); options.UseQuartz(); }) .AddServer(options => { options.AllowClientCredentialsFlow(); options.AllowAuthorizationCodeFlow().RequireProofKeyForCodeExchange(); options.AllowRefreshTokenFlow(); options.SetTokenEndpointUris("/connect/token"); options.SetAuthorizationEndpointUris("/connect/authorize"); options.SetUserinfoEndpointUris("/connect/userinfo"); var certificatesSection = configuration.GetSection("Certificates"); string encryptionCertificatePath = certificatesSection.GetValue<string>("EncryptionCertificatePath") ?? throw new Exception("Missing EncryptionCertificatePath in appsettings.json"); string signingCertificatePath = certificatesSection.GetValue<string>("SigningCertificatePath") ?? throw new Exception("Missing SigningCertificatePath in appsettings.json"); X509Certificate2 encryptionCertificate = new X509Certificate2(encryptionCertificatePath, "", X509KeyStorageFlags.EphemeralKeySet); X509Certificate2 signingCertificate = new X509Certificate2(signingCertificatePath, "", X509KeyStorageFlags.EphemeralKeySet); options .AddEncryptionCertificate(encryptionCertificate) .AddSigningCertificate(signingCertificate); options.RegisterScopes(allScopes); options.UseAspNetCore() .EnableTokenEndpointPassthrough() .EnableAuthorizationEndpointPassthrough() .EnableUserinfoEndpointPassthrough() .EnableStatusCodePagesIntegration(); options.DisableAccessTokenEncryption(); //临时关闭令牌加密以便查看内容 }) .AddValidation(options => { options.UseLocalServer(); options.UseAspNetCore(); });
2. Authorization控制器添加自定义Claims的代码
[HttpGet("~/connect/authorize")] [HttpPost("~/connect/authorize")] [IgnoreAntiforgeryToken] public async Task<IActionResult> Authorize() { var request = HttpContext.GetOpenIddictServerRequest() ?? throw new InvalidOperationException("无法获取OpenID Connect请求。"); var result = await HttpContext.AuthenticateAsync(IdentityConstants.ApplicationScheme); if (result == null || !result.Succeeded || request.HasPrompt(OpenIddictConstants.Prompts.Login) || (request.MaxAge != null && result.Properties?.IssuedUtc != null && DateTimeOffset.UtcNow - result.Properties.IssuedUtc > TimeSpan.FromSeconds(request.MaxAge.Value) ) ) { if (request.HasPrompt(OpenIddictConstants.Prompts.None)) { return Forbid(authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, properties: new AuthenticationProperties(new Dictionary<string, string?> { [OpenIddictServerAspNetCoreConstants.Properties.Error] = OpenIddictConstants.Errors.LoginRequired, [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "用户未登录。" })); } var prompt = string.Join(" ", request.GetPrompts().Remove(Prompts.Login)); var parameters = Request.HasFormContentType ? Request.Form.Where(parameter => parameter.Key != Parameters.Prompt).ToList() : Request.Query.Where(parameter => parameter.Key != Parameters.Prompt).ToList(); parameters.Add(KeyValuePair.Create(Parameters.Prompt, new StringValues(prompt))); return Challenge( authenticationSchemes: IdentityConstants.ApplicationScheme, properties: new AuthenticationProperties { RedirectUri = Request.PathBase + Request.Path + QueryString.Create(parameters) }); } if (result.Principal.Identity?.Name == null) { throw new InvalidOperationException("无法获取Claims主体"); } // 创建新的Claims主体 var claims = new List<Claim> { // 必填的'subject' claim new Claim(OpenIddictConstants.Claims.Subject, result.Principal.Identity.Name), // 自定义claims <---- 在.NET6正常,但.NET8中丢失 new Claim(OpenIddictConstants.Claims.Name, result.Principal.Identity.Name) .SetDestinations(OpenIddictConstants.Destinations.IdentityToken, OpenIddictConstants.Destinations.AccessToken), new Claim( ClaimTypes.NameIdentifier, result.Principal.Claims.First(x => x.Type == ClaimTypes.NameIdentifier).Value) .SetDestinations(OpenIddictConstants.Destinations.IdentityToken, OpenIddictConstants.Destinations.AccessToken), new Claim("some claim", "some value").SetDestinations(OpenIddictConstants.Destinations.AccessToken), new Claim(OpenIddictConstants.Claims.Email, "some@email").SetDestinations(OpenIddictConstants.Destinations.IdentityToken, OpenIddictConstants.Destinations.AccessToken) }; var claimsIdentity = new ClaimsIdentity(claims, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); var claimsPrincipal = new ClaimsPrincipal(claimsIdentity); claimsPrincipal.SetScopes(request.GetScopes()); return SignIn(claimsPrincipal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); }
3. .NET6下的令牌内容(正常情况)
{ "sub": "radek...........", "name": "radek...........", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier": "daa1daf9-...........", "some claim": "some value", "email": "some@email", "oi_prst": "postman", "oi_au_id": "1da899ef-...........", "client_id": "postman", "oi_tkn_id": "5f78b200-...........", "scope": "offline_access Pardubice_API api", "exp": 1737714786, "iss": "https://localhost:44326/", "iat": 1737711186 }
4. .NET8下的令牌内容(自定义Claims丢失)
{ "sub": "radek...........", "oi_prst": "postman", "oi_au_id": "7989d993-...........", "client_id": "postman", "oi_tkn_id": "08f32dab-...........", "scope": "offline_access Pardubice_API api", "iss": "https://localhost:44326/", "exp": 1737715346, "iat": 1737711746 }
注:OpenIddict未记录任何错误或警告日志。
已尝试方案
- 添加
profilescope - 确保所有自定义Claims都通过
SetDestinations指定了目标令牌类型 - 调整
MapInboundClaims配置 - 清除
JwtSecurityTokenHandler的默认Claim映射
以上方法均未解决问题,且升级到.NET7时自定义Claims仍能正常显示,仅.NET8出现该问题。
解决建议
升级OpenIddict版本:当前使用的OpenIddict 3.1.1是较旧版本,发布时.NET8尚未推出,存在兼容性问题是大概率事件。建议升级到支持.NET8的版本(至少4.x及以上,最新稳定版为5.x),这是解决此类兼容性问题最直接的方案。
显式注册Claims:在OpenIddict服务器配置中显式声明需要保留的自定义Claims,避免被默认过滤规则移除:
options.AddServer(options => { // 其他配置... options.RegisterClaims(OpenIddictConstants.Claims.Name, OpenIddictConstants.Claims.Email, "some claim"); });验证Scope关联:确保请求的Scope包含对应标准Scope(如
profile对应name、email对应email),或者将自定义Claims关联到已注册的自定义Scope中,避免因Scope不匹配导致Claims被过滤。调试Claims生命周期:在
SignIn前添加调试代码,检查claimsPrincipal中的Claims是否完整;也可通过OpenIddict的事件拦截器(如ApplyClaimsTransformation)查看Claims在生成令牌前的状态,确认是否被意外过滤。
内容的提问来源于stack exchange,提问作者rk72

