如何通过OIDC在Azure AD/AWS Cognito中透明使用Refresh Token?
我开发的系统包含两个姊妹Web应用,每个应用由Razor前端和Minimal API后端组成:
- 第一个应用使用Azure AD认证企业员工
- 第二个应用使用AWS Cognito用户池认证客户
两个应用均已正确对接各自的身份提供商(IdP),通过令牌向后端做身份验证。现在遇到的问题是:令牌过期后,认证Cookie仍有效,网站显示用户已认证,但转发给后端的令牌已经过期。
我希望通过refresh_token自动获取新令牌,因此在CookieAuthenticationOptions的OnValidatePrincipal事件中注入了自定义逻辑。目前GetFreshTokenAsync能成功获取新令牌,但更新令牌后,控制器/端点请求HttpClient时,context.GetTokenAsync("id_token")仍返回旧令牌,导致后端返回401错误。
认证流程配置
builder.Services .AddAuthentication(options => { options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; }) .AddCookie(options => { options.Events = new() { OnValidatePrincipal = OnValidatePrincipalAsync }; }) .AddOpenIdConnect(options => { var azureAdOptions = new AzureAdOptions(); builder.Configuration.GetSection("Authentication").Bind(azureAdOptions); builder.Configuration.GetSection("Authentication").Bind(options); options.Scope.Add("openid"); options.Scope.Add("profile"); options.Scope.Add("offline_access"); options.GetClaimsFromUserInfoEndpoint = true; options.MapInboundClaims = false; options.TokenValidationParameters = new() { NameClaimType = "name" }; });
OnValidatePrincipalAsync及依赖方法
async Task OnValidatePrincipalAsync(CookieValidatePrincipalContext context) { if (context.Principal?.Identity?.IsAuthenticated ?? false) { var idToken = context.Properties.GetTokenValue("id_token"); var jwt = new JwtSecurityToken(idToken); if (jwt.ValidTo.ToLocalTime() < DateTime.Now) { var refreshToken = context.Properties.GetTokenValue("refresh_token"); if (refreshToken is null) { context.RejectPrincipal(); return; } var tokenResponse = await GetFreshTokenAsync(context.HttpContext, refreshToken); if (tokenResponse is null) { context.RejectPrincipal(); return; } var expirationValue = DateTime.Now.ToLocalTime().AddSeconds(tokenResponse.ExpiresIn).ToString("o", CultureInfo.InvariantCulture); context.Properties.UpdateTokenValue("refresh_token", tokenResponse.RefreshToken); context.Properties.UpdateTokenValue("id_token", tokenResponse.IdToken); context.Properties.UpdateTokenValue("access_token", tokenResponse.AccessToken); context.Properties.UpdateTokenValue("expires_at", expirationValue); context.ShouldRenew = true; } } } async Task<TokenResponse?> GetFreshTokenAsync(HttpContext httpContext, string refreshToken) { var serviceProvider = httpContext.RequestServices; var cancellationToken = httpContext.RequestAborted; var oidcOptions = serviceProvider.GetRequiredService<IOptionsSnapshot<OpenIdConnectOptions>>().Get(OpenIdConnectDefaults.AuthenticationScheme); var configuration = oidcOptions.Configuration ?? await oidcOptions.ConfigurationManager!.GetConfigurationAsync(cancellationToken).ConfigureAwait(false); var httpClientFactory = serviceProvider.GetRequiredService<IHttpClientFactory>(); var address = configuration.TokenEndpoint; var clientId = oidcOptions.ClientId!; var clientSecret = oidcOptions.ClientSecret!; using var httpClient = httpClientFactory.CreateClient(); var requestBody = new Dictionary<string, string>() { ["grant_type"] = "refresh_token", ["client_id"] = clientId, ["refresh_token"] = refreshToken, ["scope"] = string.Join(" ", oidcOptions.Scope), ["client_secret"] = clientSecret }; using var httpRequest = new HttpRequestMessage(HttpMethod.Post, address); httpRequest.Content = new FormUrlEncodedContent(requestBody); using var httpResponse = await httpClient.SendAsync(httpRequest, cancellationToken); if (!httpResponse.IsSuccessStatusCode) { return null; } var tokenResponse = await httpResponse.Content.ReadFromJsonAsync<TokenResponse>(cancellationToken); return tokenResponse; }
调用后端获取令牌的方式
services.AddHttpClient(AdminApiName, async (sp, http) => { var context = sp.GetRequiredService<IHttpContextAccessor>().HttpContext!; var idToken = await context.GetTokenAsync("id_token"); http.BaseAddress = configuration.GetServiceUri("admin-api"); http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", idToken); });
问题核心在于:OnValidatePrincipal中更新的context.Properties是要写入到响应Cookie中的,但当前请求的HttpContext里的认证属性并没有同步更新,所以当前请求后续调用GetTokenAsync拿到的还是旧令牌。
方案一:同步更新当前请求的认证属性
在OnValidatePrincipalAsync方法中,更新完context.Properties后,手动将新令牌同步到当前HttpContext的认证信息中:
// 更新完context.Properties之后添加以下代码 var authResult = AuthenticateResult.Success(new AuthenticationTicket(context.Principal, context.Properties, context.Scheme.Name)); context.HttpContext.User = authResult.Principal; context.HttpContext.Items[typeof(AuthenticateResult)] = authResult;
这样当前请求后续调用GetTokenAsync时,就能从更新后的AuthenticationTicket中获取新令牌。
方案二:直接从Cookie属性获取令牌
修改HttpClient的配置代码,跳过GetTokenAsync,直接从AuthenticationProperties中取更新后的令牌:
services.AddHttpClient(AdminApiName, (sp, http) => { var context = sp.GetRequiredService<IHttpContextAccessor>().HttpContext!; // 直接从认证属性中获取更新后的令牌 var authResult = context.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme).Result; var idToken = authResult.Properties.GetTokenValue("id_token"); http.BaseAddress = configuration.GetServiceUri("admin-api"); http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", idToken); });
注:这里使用.Result是因为AddHttpClient的配置委托不支持异步,若要避免同步等待,可改用IHttpMessageHandlerBuilderFilter实现异步令牌获取。
额外优化点
- 令牌过期判断时建议留缓冲时间,避免网络延迟导致令牌在请求过程中过期:
var bufferTime = TimeSpan.FromMinutes(5); if (jwt.ValidTo.ToLocalTime() < DateTime.Now.Add(bufferTime)) { // 执行刷新逻辑 }
- 确保
TokenResponse类的属性与IdP返回的字段完全匹配(如expires_in对应ExpiresIn),避免序列化失败。
内容的提问来源于stack exchange,提问作者Kralizek

