Blazor Server集成OpenID Connect的访问令牌刷新问题咨询
Blazor Server认证令牌反复被过期值覆盖问题解决方案
核心根因
TokenProvider实例反复清空基本是预渲染(Prerendering)导致:预渲染阶段会生成独立的临时DI容器,等SignalR电路正式建立后会重新创建一套Scoped服务,预渲染阶段写入的令牌状态会直接丢弃,导致每次电路初始化都会触发shouldSetInitialValues判断为true。- Blazor Server Scoped服务生命周期与电路(Circuit)绑定,而非传统HTTP请求:
_Host.cshtml中的令牌读取逻辑仅在电路初始化的首次HTTP请求时执行,后续组件导航、交互都不会触发该逻辑。且内存中刷新得到的新令牌不会自动同步回认证Cookie,后续硬刷新页面、新建电路时从HttpContext拿到的永远是登录时写入的旧过期令牌。 - 现有初始化逻辑缺少过期判断:仅校验令牌是否为null,没有校验剩余有效期,过期令牌会直接覆盖内存中的有效值。
实现步骤
1. 修复预渲染状态丢失问题
如果不需要预渲染,直接在_Host.cshtml中将组件的render-mode设置为Server即可关闭预渲染:
<component type="typeof(App)" render-mode="Server" />
如果需要保留预渲染,调整App.razor的初始化逻辑,等电路正式建立、认证状态就绪后再执行令牌赋值,跳过预渲染阶段的无效初始化,同时增加令牌有效期判断:
[CascadingParameter] private Task<AuthenticationState>? AuthenticationStateTask { get; set; } protected override async Task OnInitializedAsync() { if (AuthenticationStateTask != null) { await AuthenticationStateTask; } // 令牌为空、或剩余有效期不足5分钟时触发重新赋值 var needRefresh = TokenProvider.AccessToken == null || TokenProvider.RefreshToken == null || TokenProvider.ExpiresAtUtc <= DateTimeOffset.UtcNow.AddMinutes(5); if (needRefresh) { // 初始令牌有效则直接赋值,否则直接走刷新逻辑 if (InitialState != null && InitialState.ExpiresAtUtc > DateTimeOffset.UtcNow.AddMinutes(5)) { TokenProvider.AccessToken = InitialState.AccessToken; TokenProvider.RefreshToken = InitialState.RefreshToken; TokenProvider.IdToken = InitialState.IdToken; TokenProvider.ExpiresAtUtc = InitialState.ExpiresAtUtc; } else { await TokenProvider.RefreshTokenAsync(); } } await base.OnInitializedAsync(); }
2. 刷新令牌后同步更新认证Cookie
不要将新令牌仅存在内存中,刷新成功后必须同步更新认证Cookie存储的令牌,否则后续新建电路时永远会拿到旧的过期令牌。封装令牌持久化方法:
public async Task PersistTokensToCookieAsync(string accessToken, string refreshToken, string idToken, DateTimeOffset expiresAt) { var httpContext = _httpContextAccessor.HttpContext; if (httpContext == null) return; var authResult = await httpContext.AuthenticateAsync(); if (!authResult.Succeeded) return; var tokenList = new List<AuthenticationToken> { new() { Name = "access_token", Value = accessToken }, new() { Name = "refresh_token", Value = refreshToken }, new() { Name = "id_token", Value = idToken } }; authResult.Properties.StoreTokens(tokenList); authResult.Properties.UpdateTokenValue("expires_at", expiresAt.ToString("o")); await httpContext.SignInAsync(authResult.Principal!, authResult.Properties); }
3. 实现带并发锁的自动刷新逻辑
在TokenProvider中封装令牌获取方法,每次请求接口前检查有效期,过期自动刷新,加锁避免多个请求同时触发刷新:
public class TokenProvider { private readonly IOpenIdConnectService _oidcService; // 自行封装的OIDC令牌交互服务 private readonly IHttpContextAccessor _httpContextAccessor; private readonly SemaphoreSlim _refreshLock = new(1, 1); public string? AccessToken { get; set; } public string? RefreshToken { get; set; } public string? IdToken { get; set; } public DateTimeOffset ExpiresAtUtc { get; set; } public async Task<string> GetValidAccessTokenAsync() { // 剩余有效期超过5分钟直接返回 if (ExpiresAtUtc > DateTimeOffset.UtcNow.AddMinutes(5)) { return AccessToken!; } await _refreshLock.WaitAsync(); try { // 锁等待后二次校验,避免重复刷新 if (ExpiresAtUtc > DateTimeOffset.UtcNow.AddMinutes(5)) { return AccessToken!; } // 调用身份提供者刷新端点获取新令牌 var newTokens = await _oidcService.RefreshTokenAsync(RefreshToken!); // 更新内存状态 AccessToken = newTokens.AccessToken; RefreshToken = newTokens.RefreshToken; IdToken = newTokens.IdToken; ExpiresAtUtc = newTokens.ExpiresAtUtc; // 同步更新Cookie await PersistTokensToCookieAsync(AccessToken, RefreshToken, IdToken, ExpiresAtUtc); return AccessToken!; } finally { _refreshLock.Release(); } } // 初始化时调用的无锁刷新逻辑 public async Task RefreshTokenAsync() { var newTokens = await _oidcService.RefreshTokenAsync(RefreshToken!); AccessToken = newTokens.AccessToken; RefreshToken = newTokens.RefreshToken; IdToken = newTokens.IdToken; ExpiresAtUtc = newTokens.ExpiresAtUtc; await PersistTokensToCookieAsync(AccessToken, RefreshToken, IdToken, ExpiresAtUtc); } }
4. 服务注册注意事项
TokenProvider必须注册为Scoped生命周期,禁止注册为Singleton,否则会出现多用户令牌串用的问题。- 提前在Program.cs中注册HttpContext访问服务:
builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped<TokenProvider>();
注意:除了_Host.cshtml页面和令牌持久化场景,不要在Blazor组件中随意使用
IHttpContextAccessor获取HttpContext,组件运行阶段HttpContext默认是null,会触发空引用异常。
内容的提问来源于stack exchange,提问作者sponkae
相关产品推荐
相关产品推荐

