Blazor Server(.NET8) EntraID认证调用下游API遇MSAL缓存异常
我正在开发采用Server交互模式的.NET 8 Blazor Web App,通过Microsoft Entra ID完成用户认证,认证功能正常,可确认用户已登录且包含oid、tid等声明。但调用下游API执行以下代码时出现错误:
var token = await _tokenAcquisition.GetAccessTokenForUserAsync(scopes, user);
错误信息:
MsalUiRequiredException: No account or login hint was passed to the AcquireTokenSilent call
详细MSAL日志:
Found 0 accounts in MSAL cache No refresh tokens or accounts found Token cache could not satisfy silent request
即便用户已认证,该问题依然存在。以下是我的配置代码:
Program.cs 配置
// Program.cs builder.Services.AddBlazorServerAuthentication(builder.Configuration); builder.Services.AddBlazorServerAuthorization(); // Add services to the container. _ = builder.Services.AddRazorComponents() .AddInteractiveServerComponents() .AddMicrosoftIdentityConsentHandler(); _ = builder.Services.AddServerSideBlazor() .AddMicrosoftIdentityConsentHandler(); _ = builder.Services.AddHttpContextAccessor(); _ = builder.Services.AddCascadingAuthenticationState(); _ = builder.Services.AddTransient<AccessTokenHandler>(); _ = builder.Services .AddHttpClient("WebApiClient", client => client.BaseAddress = builder.Configuration.GetValue<Uri>("DownstreamApi:BaseUrl")) .AddHttpMessageHandler<AccessTokenHandler>();
认证扩展方法
public static void AddBlazorServerAuthentication(this IServiceCollection services, IConfiguration configuration) { JwtSecurityTokenHandler.DefaultMapInboundClaims = false; string[] scopes = configuration.GetValue<string>("DownstreamApi:Scopes")?.Split(';'); _ = services.AddAuthentication(options => { options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; }) .AddBearerToken() .AddMicrosoftIdentityWebApp(options => { configuration.Bind("AzureAd", options); options.MaxAge = TimeSpan.FromHours(8); options.AutomaticRefreshInterval = TimeSpan.FromMinutes(15); options.Events.OnRemoteFailure = async context => { try { AuthenticationProperties authenticationProperties = new() { RedirectUri = "/" }; authenticationProperties.Parameters["logoutHint"] = context?.HttpContext?.User?.Identity?.Name; await context.HttpContext.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme, authenticationProperties); await context.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); Console.WriteLine("Authentication failed. Logging out user silently"); context.HandleResponse(); await Task.CompletedTask; } catch (Exception) { throw; } }; }) .EnableTokenAcquisitionToCallDownstreamApi(scopes) .AddInMemoryTokenCaches(); } public static void AddBlazorServerAuthorization(this IServiceCollection services) { _ = services.AddAuthorization(options => { options.FallbackPolicy = new AuthorizationPolicyBuilder() .RequireAuthenticatedUser() .Build(); }); }
AccessTokenHandler 实现
//AccessTokenHandler public class AccessTokenHandler : DelegatingHandler { private readonly ITokenAcquisition _tokenAcquisition; private readonly IConfiguration _config; private readonly IHttpContextAccessor _httpContextAccessor; public AccessTokenHandler(ITokenAcquisition tokenAcquisition, IConfiguration config, IHttpContextAccessor httpContextAccessor) { _tokenAcquisition = tokenAcquisition; _config = config; _httpContextAccessor = httpContextAccessor; } protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { string[] scopes = _config["DownstreamApi:Scopes"]?.Split(' ', StringSplitOptions.RemoveEmptyEntries); if (scopes?.Length > 0) { string token = await _tokenAcquisition.GetAccessTokenForUserAsync(scopes, user: _httpContextAccessor.HttpContext?.User, authenticationScheme: OpenIdConnectDefaults.AuthenticationScheme); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); } return await base.SendAsync(request, cancellationToken); } }
appsettings.json 配置
"AzureAd": { "Instance": "https://login.microsoftonline.com/", "ClientId": "xxxxxxx", "ClientSecret": "xyxyxyxyxy", "TenantId": "zzzzzzzzz" }, "DownstreamApi": { "BaseUrl": "https://localhost:44385", "Scopes": "api://yyyyyyyyyy/access_as_user" }
问题原因及解决步骤
原因分析
MSAL无法在缓存中找到账户,核心原因包括:
- 认证流程未将用户刷新令牌写入MSAL缓存
- 范围分割符配置不一致导致令牌请求无效
- 令牌获取时指定的认证方案与上下文不匹配
- 内存缓存的生命周期与Blazor Server会话模型不兼容
解决步骤
开启认证令牌保存
在AddMicrosoftIdentityWebApp配置中添加SaveTokens = true,确保认证后刷新令牌被保存到票据,供MSAL填充缓存:.AddMicrosoftIdentityWebApp(options => { configuration.Bind("AzureAd", options); options.SaveTokens = true; // 新增此行 // 其他原有配置... })统一范围分割规则
修正AddBlazorServerAuthentication中的范围分割符,与AccessTokenHandler保持一致(用空格分割):// AddBlazorServerAuthentication中 string[] scopes = configuration.GetValue<string>("DownstreamApi:Scopes")?.Split(' ', StringSplitOptions.RemoveEmptyEntries);使用正确的认证方案获取令牌
将GetAccessTokenForUserAsync中的认证方案改为Cookie默认方案,匹配认证配置:string token = await _tokenAcquisition.GetAccessTokenForUserAsync(scopes, user: _httpContextAccessor.HttpContext?.User, authenticationScheme: CookieAuthenticationDefaults.AuthenticationScheme);生产环境替换为分布式令牌缓存
内存缓存在多实例或服务重启时会丢失令牌,推荐用Redis等分布式缓存替代:// 替换原有AddInMemoryTokenCaches() .AddDistributedTokenCaches() .AddStackExchangeRedisCache(options => { options.Configuration = "你的Redis连接字符串"; });确保HttpContext上下文有效
确认IHttpContextAccessor已正确注入,且AccessTokenHandler的调用处于请求上下文内(避免在后台任务等无HttpContext场景调用)
内容的提问来源于stack exchange,提问作者Vasil Indzhev

