You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.16 07:09:55