Blazor Server对接外部API登录及令牌刷新的认证Cookie管理难题
背景
约半年前启动了一个Blazor Server项目,此前使用ASP.NET Framework 4.6.2,这是遗留系统迁移到现代框架的一部分,开发时边做边学Blazor。
当前认证实现概述
项目依赖独立的外部API提供登录和令牌刷新接口,当前实现流程:
- 通过ASP.NET控制器处理Cookie写入:由于Blazor Server不总是走传统HTTP请求,必须通过控制器将认证Cookie附加到HTTP响应中,之后需要强制页面重载确保Cookie生效。
- 自定义
AuthenticationStateProvider:从Cookie中提取令牌管理认证状态,因为Blazor Server不能依赖JS的localStorage/sessionStorage,所以用HttpContextAccessor获取Cookie,但这也带来了后续问题。 HttpClientFactory+DelegatingHandler:自动给所有API请求附加令牌。
核心问题:令牌刷新后的Cookie同步问题
在DelegatingHandler中检测令牌过期,调用刷新接口获取新令牌并更新Cookie后,HttpContextAccessor仍然引用旧的HttpContext——因为没有新的HTTP请求触发Circuit刷新,只能强制页面重载,体验不佳。
优化方案建议
方案1:用Scoped服务存储认证状态(替代直接依赖HttpContextAccessor)
- 初始加载同步Cookie到Scoped服务:在Circuit启动时(比如
_Host.cshtml或自定义CircuitHandler),通过HttpContextAccessor读取初始Cookie,将令牌存入Scoped生命周期的IAuthStateService。该服务生命周期与Circuit绑定,跟随用户整个会话。 - 更新令牌时同步服务与Cookie:
- 刷新令牌成功后,先更新
IAuthStateService中的令牌值。 - 通过JS互操作更新浏览器端Cookie(Blazor Server注入
IJSRuntime实现);如果是HttpOnly Cookie,需调用轻量后端接口更新。 - 调用
AuthenticationStateProvider.NotifyAuthenticationStateChanged()通知组件状态变更,无需页面重载。
- 刷新令牌成功后,先更新
- 后续请求直接从Scoped服务取令牌:
DelegatingHandler和AuthenticationStateProvider都直接从IAuthStateService获取令牌,不再依赖HttpContextAccessor。
方案2:利用CircuitHandler监听Circuit刷新
创建自定义CircuitHandler,在OnCircuitOpenedAsync或OnConnectionUpAsync时,从HttpContext读取最新Cookie,同步到Scoped认证状态服务中。用户发起新HTTP请求(如刷新页面、打开新标签)时,服务会自动更新为最新Cookie值。
关键代码示例
1. Scoped认证状态服务
public interface IAuthStateService { string AccessToken { get; } string RefreshToken { get; } Task UpdateTokens(string newAccessToken, string newRefreshToken); } public class AuthStateService : IAuthStateService { private readonly IJSRuntime _jsRuntime; private readonly AuthenticationStateProvider _authStateProvider; public string AccessToken { get; private set; } public string RefreshToken { get; private set; } public AuthStateService(IJSRuntime jsRuntime, AuthenticationStateProvider authStateProvider) { _jsRuntime = jsRuntime; _authStateProvider = authStateProvider; } // 初始化时从HttpContext加载令牌(可在CircuitHandler中调用) public void InitializeTokens(string accessToken, string refreshToken) { AccessToken = accessToken; RefreshToken = refreshToken; } public async Task UpdateTokens(string newAccessToken, string newRefreshToken) { AccessToken = newAccessToken; RefreshToken = newRefreshToken; // 通过JS更新非HttpOnly Cookie await _jsRuntime.InvokeVoidAsync("updateAuthCookie", newAccessToken, newRefreshToken); // 通知认证状态变更 await _authStateProvider.NotifyAuthenticationStateChanged( _authStateProvider.GetAuthenticationStateAsync()); } }
2. JS互操作更新Cookie(wwwroot/js/auth.js)
window.updateAuthCookie = function(accessToken, refreshToken) { const isSecure = window.location.protocol === 'https:'; document.cookie = `AccessToken=${accessToken}; path=/; SameSite=Lax; secure=${isSecure}`; document.cookie = `RefreshToken=${refreshToken}; path=/; SameSite=Lax; secure=${isSecure}`; // 若为HttpOnly Cookie,替换为调用后端接口: // await fetch('/api/auth/update-cookie', { // method: 'POST', // headers: { 'Content-Type': 'application/json' }, // body: JSON.stringify({ accessToken, refreshToken }) // }); };
3. 自定义DelegatingHandler
public class AuthDelegatingHandler : DelegatingHandler { private readonly IAuthStateService _authStateService; private readonly IHttpClientFactory _httpClientFactory; public AuthDelegatingHandler(IAuthStateService authStateService, IHttpClientFactory httpClientFactory) { _authStateService = authStateService; _httpClientFactory = httpClientFactory; } protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { // 附加当前令牌 if (!string.IsNullOrEmpty(_authStateService.AccessToken)) { request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _authStateService.AccessToken); } var response = await base.SendAsync(request, cancellationToken); // 检测令牌过期 if (response.StatusCode == HttpStatusCode.Unauthorized) { var refreshClient = _httpClientFactory.CreateClient("RefreshApi"); var refreshResponse = await refreshClient.PostAsJsonAsync("/refresh", new { RefreshToken = _authStateService.RefreshToken }); if (refreshResponse.IsSuccessStatusCode) { var newTokens = await refreshResponse.Content.ReadFromJsonAsync<TokenResponse>(); await _authStateService.UpdateTokens(newTokens.AccessToken, newTokens.RefreshToken); // 重新发起原请求 request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", newTokens.AccessToken); response = await base.SendAsync(request, cancellationToken); } } return response; } }
4. 服务注册(Program.cs)
builder.Services.AddScoped<IAuthStateService, AuthStateService>(); builder.Services.AddHttpClient("ExternalApi").AddHttpMessageHandler<AuthDelegatingHandler>(); builder.Services.AddHttpClient("RefreshApi").ConfigureHttpClient(c => c.BaseAddress = new Uri(builder.Configuration["ApiUrls:Refresh"])); builder.Services.AddTransient<AuthDelegatingHandler>(); builder.Services.AddHttpContextAccessor();
额外注意事项
- HttpOnly Cookie处理:推荐将认证Cookie设为HttpOnly以防范XSS,此时JS无法直接修改,需通过后端接口更新Cookie,后续Circuit刷新时会自动同步。
- Circuit重建同步:Blazor Server的Circuit可能因超时、重连重建,需在Circuit重建时从
HttpContext重新读取Cookie到Scoped服务,可通过自定义CircuitHandler实现。 - 状态一致性:确保
AuthenticationStateProvider始终从Scoped服务获取令牌,避免与HttpContext状态不一致。
内容的提问来源于stack exchange,提问作者Gregori Schuster
相关产品推荐
相关产品推荐

