OpenIddict中access_token、refresh_token无法写入Cookie问题排查
问题描述
在IdentityServer中使用OpenIddict实现认证与授权,需要将access_token和refresh_token存入Cookie中。编写了继承IOpenIddictServerHandler<OpenIddictServerEvents.HandleTokenRequestContext>的自定义处理器,拦截令牌并尝试写入Cookie,同时从响应体中移除令牌。目前移除操作正常生效,但令牌并未成功写入Cookie。当前环境为IdentityServer搭配React客户端与API使用。
自定义令牌处理器代码
using OpenIddict.Abstractions; using OpenIddict.Server; namespace Company.WebApi.CustomToken; public class CustomTokenEndpointHandler : IOpenIddictServerHandler<OpenIddictServerEvents.HandleTokenRequestContext> { private readonly IHttpContextAccessor _httpContextAccessor; public CustomTokenEndpointHandler(IHttpContextAccessor httpContextAccessor) { _httpContextAccessor = httpContextAccessor; } public async ValueTask HandleAsync(OpenIddictServerEvents.HandleTokenRequestContext context) { if (context.Request == null) { throw new NullReferenceException("Request missing in HandleTokenRequestContext"); } var clientId = context.Request.ClientId; if (clientId != "logistic_company_react_client") { await ValueTask.CompletedTask; return; } var accessToken = context.Transaction.Response?.GetParameter(OpenIddictConstants.Parameters.AccessToken)?.ToString(); var refreshToken = context.Transaction.Response?.GetParameter(OpenIddictConstants.Parameters.RefreshToken)?.ToString(); _httpContextAccessor.HttpContext.Response.OnStarting(() => { if (!string.IsNullOrEmpty(accessToken)) { _httpContextAccessor.HttpContext.Response.Cookies.Append("access_token", accessToken, new CookieOptions { HttpOnly = true, Secure = false, SameSite = SameSiteMode.Lax, Expires = DateTimeOffset.UtcNow.AddHours(1) }); } if (!string.IsNullOrEmpty(refreshToken)) { _httpContextAccessor.HttpContext.Response.Cookies.Append("refresh_token", refreshToken, new CookieOptions { HttpOnly = true, Secure = false, SameSite = SameSiteMode.Lax, Expires = DateTimeOffset.UtcNow.AddDays(7) }); } context.Transaction.Response?.RemoveParameter(OpenIddictConstants.Parameters.AccessToken); context.Transaction.Response?.RemoveParameter(OpenIddictConstants.Parameters.RefreshToken); return Task.CompletedTask; }); await ValueTask.CompletedTask; } }
事件处理器注册代码片段
services.AddHttpContextAccessor(); services.AddOpenIddict() .AddServer(options => { options.AddEventHandler<OpenIddictServerEvents.HandleTokenRequestContext>( builder => builder.UseScopedHandler<CustomTokenEndpointHandler>()); // 其他配置... }
令牌交换接口代码
[HttpPost("~/connect/token")] public async Task<IActionResult> Exchange(CancellationToken cancellationToken) { var request = HttpContext.GetOpenIddictServerRequest(); if (request is null) { return Problem(ApplicationErrors.SpecifiedGrantTypeNotSupported.Name); } if (request.IsAuthorizationCodeGrantType() || request.IsRefreshTokenGrantType()) { ClaimsPrincipal claimsPrincipal = (await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)).Principal; var command = new TokenQuery(claimsPrincipal, request); var result = await _sender.Send(command, cancellationToken); // 获取ClaimsIdentity if (result.IsFailure && result.Error.Code == "User.CannotFindUserFromToken") { return Forbid( authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, properties: new AuthenticationProperties(new Dictionary<string, string?> { [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant, [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = result.Error.Name })); } return SignIn(new ClaimsPrincipal(result.Value.ClaimsIdentity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } throw new InvalidOperationException("The specified grant type is not supported."); }
问题分析与解决方案
核心问题
原代码使用HandleTokenRequestContext事件,这个事件触发时OpenIddict还未生成最终的令牌,导致context.Transaction.Response中的令牌值为空,自然无法写入Cookie。而移除操作生效是因为即使参数不存在,RemoveParameter也不会报错。
修复方案
改用ProcessSignInContext事件,该事件在令牌生成完成、响应发送前触发,能直接拿到已生成的令牌值。同时优化Cookie配置,避免上下文和路径问题。
修改后的自定义处理器
using OpenIddict.Abstractions; using OpenIddict.Server.AspNetCore; namespace Company.WebApi.CustomToken; public class CustomTokenCookieHandler : IOpenIddictServerHandler<OpenIddictServerEvents.ProcessSignInContext> { private readonly IHttpContextAccessor _httpContextAccessor; public CustomTokenCookieHandler(IHttpContextAccessor httpContextAccessor) { _httpContextAccessor = httpContextAccessor; } public async ValueTask HandleAsync(OpenIddictServerEvents.ProcessSignInContext context) { var clientId = context.Request.ClientId; if (clientId != "logistic_company_react_client") { return; } var httpContext = _httpContextAccessor.HttpContext; if (httpContext == null) { return; } // 直接从事件上下文获取已生成的令牌 var accessToken = context.AccessToken; var refreshToken = context.RefreshToken; if (!string.IsNullOrEmpty(accessToken)) { httpContext.Response.Cookies.Append("access_token", accessToken, new CookieOptions { HttpOnly = true, Secure = httpContext.Request.IsHttps, // 根据请求自动适配HTTPS SameSite = SameSiteMode.Lax, // 跨域场景需改为None,同时Secure必须为true Expires = DateTimeOffset.UtcNow.AddHours(1), Path = "/" // 确保Cookie在整个站点生效 }); } if (!string.IsNullOrEmpty(refreshToken)) { httpContext.Response.Cookies.Append("refresh_token", refreshToken, new CookieOptions { HttpOnly = true, Secure = httpContext.Request.IsHttps, SameSite = SameSiteMode.Lax, Expires = DateTimeOffset.UtcNow.AddDays(7), Path = "/" }); } // 从响应中移除令牌参数 context.Response.RemoveParameter(OpenIddictConstants.Parameters.AccessToken); context.Response.RemoveParameter(OpenIddictConstants.Parameters.RefreshToken); await ValueTask.CompletedTask; } }
修改事件注册
services.AddHttpContextAccessor(); services.AddOpenIddict() .AddServer(options => { // 替换为ProcessSignInContext事件 options.AddEventHandler<OpenIddictServerEvents.ProcessSignInContext>( builder => builder.UseScopedHandler<CustomTokenCookieHandler>()); // 其他原有配置... });
额外注意事项
- 跨域场景适配:如果React客户端与IdentityServer不在同一域名下,需将
SameSite设为SameSiteMode.None,同时Secure必须设为true(浏览器强制要求SameSite=None的Cookie必须通过HTTPS传输),还可根据需求设置Domain属性为共享根域名。 - Cookie路径:指定
Path = "/"确保Cookie在整个站点范围内可被读取,避免因请求路径不同导致Cookie无法访问。 - HTTPS环境:生产环境必须将
Secure设为true,否则浏览器会拒绝存储HttpOnly Cookie。
内容的提问来源于stack exchange,提问作者keks01
相关产品推荐
相关产品推荐

