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

自定义OAuth授权服务器无法解析客户端用户会话问题排查

问题描述

我用ASP.NET Core(.NET 7)实现了一个OAuth授权服务器,认证配置如下:

builder.Services.AddAuthentication("Cookie")
    .AddCookie("Cookie", options =>
    {
        options.LoginPath = $"/login";
    });

登录、授权和Token端点代码:

app.MapGet("/login", async (HttpContext context,string ReturnUrl) =>
{
    var claims = new List<Claim>
    {
        new Claim(ClaimTypes.NameIdentifier,Guid.NewGuid().ToString())
    };
    var claimsIdentity = new ClaimsIdentity(claims, "Cookie");
    var user = new ClaimsPrincipal(claimsIdentity);

    await context.SignInAsync("Cookie", user);

    return Results.Redirect(ReturnUrl);
});
app.MapGet("/oauth/authorize", (HttpRequest request,IDataProtectionProvider dataProtectionProvider) =>
{
    request.Query.TryGetValue("response_type", out var responseType);
    request.Query.TryGetValue("client_id", out var clientId);
    request.Query.TryGetValue("code_challenge", out var codeChallenge);
    request.Query.TryGetValue("code_challenge_method", out var codeChallengeMethod);
    request.Query.TryGetValue("redirect_uri", out var redirectUri);
    request.Query.TryGetValue("scope", out var scope);
    request.Query.TryGetValue("state", out var state);

    var issuer = request.Scheme + "://" + request.Host.Value;

    var protector = dataProtectionProvider.CreateProtector("oauth");
    var code = new OAuthCode
    (
        clientId!,
        codeChallenge!,
        codeChallengeMethod!,
        redirectUri!,
        DateTime.UtcNow.AddMinutes(5)
    );


    var codeJson = JsonSerializer.Serialize(code);
    var codeString = protector.Protect(codeJson);


    return Results.Redirect($"{redirectUri}?code={codeString}&state={state}&iss={issuer}");
})
.RequireAuthorization("Authorized");

app.MapPost("/oauth/token", async (HttpRequest request, DevKeys devKeys,IDataProtectionProvider protectionProvider) =>
{
    var bodyBytes = await request.BodyReader.ReadAsync();
    var bodyContent = Encoding.UTF8.GetString(bodyBytes.Buffer);

    string grantType = "", code = "", redirectUri = "", codeVerifier = "";
    var regex = new Regex
        (@"client_id=(?<client_id>.+?)&redirect_uri=(?<redirect_uri>.+?)&client_secret=(?<client_secret>.+?)&code=(?<code>.+?)&grant_type=(?<grant_type>.+?)&code_verifier=(?<code_verifier>.+?)");
    
    var match = regex.Match(bodyContent);
    if (match.Success)
    {
        grantType = match.Groups["grant_type"].Value;
        code = match.Groups["code"].Value;
        redirectUri = match.Groups["redirect_uri"].Value;
    }
    else
        return Results.BadRequest("Token endpoint parameters are not speicified");
    codeVerifier = bodyContent.Split('&').Last().Split('=').Last();


    var protector = protectionProvider.CreateProtector("oauth");
    var codeString = protector.Unprotect(code);
    OAuthCode? oAuthCode = JsonSerializer.Deserialize<OAuthCode>(codeString);
    if(oAuthCode == null)
        return Results.BadRequest("Invalid Code");
    
    using(var sha256 = SHA256.Create())
    {
        // 从code_verifier生成code_challenge
        var codeChallenge = WebEncoders.Base64UrlEncode(sha256.ComputeHash(Encoding.ASCII.GetBytes(codeVerifier)));

        if (oAuthCode.CodeChallenge != codeChallenge)
            return Results.BadRequest("Code Challenge Mismatch");
    }

    var handler = new JsonWebTokenHandler();
    var result = new
                {
                    access_token = handler.CreateToken(new SecurityTokenDescriptor
                    {
                        Claims = new Dictionary<string, object>
                        {
                            [JwtRegisteredClaimNames.Sub] = Guid.NewGuid().ToString(),
                            ["SomeCustomClaim"] = "CustomClaimValue"
                        },
                        Expires = DateTime.UtcNow.AddMinutes(100),
                        TokenType = "Bearer",
                        SigningCredentials = new SigningCredentials(devKeys.RsaSecurityKey, SecurityAlgorithms.RsaSha256)
                    }),
                    token_type = "Bearer"
                };

    return Results.Ok(result);
});

同时我实现了一个ASP.NET Core(.NET 7)客户端应用,认证配置如下:

builder.Services.AddAuthentication("Cookie")
    .AddCookie("Cookie", options =>
    {
        var del = options.Events.OnRedirectToAccessDenied;
        options.Events.OnRedirectToAccessDenied = async context =>
        {
            if (context.Request.Path.StartsWithSegments("/CustomServer"))
                await context.HttpContext.ChallengeAsync("CustomServer", new AuthenticationProperties { RedirectUri = context.Request.Path });
            else
                context.Response.Redirect("/login");

        };
    })
    .AddOAuth("CustomServer", options =>
    {
        options.ClientId = "NotRealClientId";
        options.ClientSecret = "NotRealClientIdSecret";

        options.AuthorizationEndpoint = "https://localhost:7048/oauth/authorize";
        options.TokenEndpoint = "https://localhost:7048/oauth/token";

        options.CallbackPath = "/oauth/CustomServer-cb";
        options.UserInformationEndpoint = "https://localhost:7048/user";

        options.SignInScheme = "Cookie";
        options.SaveTokens = true;

        options.UsePkce = true;

        options.ClaimActions.MapJsonKey("sub", "sub");
        options.ClaimActions.MapJsonKey("SomeCustomClaim", "SomeCustomClaim");

        options.Events.OnCreatingTicket = async ctx =>
        {
            var oAuthHandlerProvider = ctx.HttpContext.RequestServices.GetRequiredService<IAuthenticationHandlerProvider>();
            var handler = await oAuthHandlerProvider.GetHandlerAsync(ctx.HttpContext, "Cookie");
            var result = await handler!.AuthenticateAsync();
            if (!result.Succeeded)
            {
                ctx.Fail("Faild Authentication");
                return;
            }
            var user = ctx.HttpContext.User;

            var payLoadBase64 = ctx.AccessToken!.Split('.')[1];
            var payLoadJson = Encoding.UTF8.GetString(Base64UrlTextEncoder.Decode(payLoadBase64));
            var payLoad = JsonDocument.Parse(payLoadJson);

            ctx.Principal = user.Clone();
            ctx.Principal?.Identities.First(x => x.AuthenticationType == "Cookie")
                .AddClaim(new Claim("CustomServer-authorized", "true"));

            ctx.RunClaimActions(payLoad.RootElement);
        };

    })

核心问题

客户端的OnCreatingTicket事件中,执行var result = await handler!.AuthenticateAsync();始终失败,错误信息为{"Unprotect ticket failed"},但AccessToken已成功获取。我需要解析初始用户会话获取userId,将新的AccessToken关联到数据库,但目前无法实现。

对比对接GitHub的OAuth客户端实现,同样的逻辑却能正常解析用户会话:

.AddOAuth("Github", options =>
{
    options.ClientId = "NotRealClientId";
    options.ClientSecret = "NotRealClientSecret";

    options.AuthorizationEndpoint = "https://github.com/login/oauth/authorize";
    options.TokenEndpoint = "https://github.com/login/oauth/access_token";

    options.CallbackPath = "/oauth/github-cb";
    options.UserInformationEndpoint = "https://api.github.com/user";

    options.SignInScheme = "Cookie";
    options.SaveTokens = true;

    options.Events.OnCreatingTicket = async ctx =>
    {
        var oAuthHandlerProvider = ctx.HttpContext.RequestServices.GetRequiredService<IAuthenticationHandlerProvider>();
        var handler = await oAuthHandlerProvider.GetHandlerAsync(ctx.HttpContext, "Cookie");
        var result = await handler!.AuthenticateAsync();
        if (!result.Succeeded)
        {
            ctx.Fail("Faild Authentication");
            return;
        }

        var token = ctx.AccessToken;
        var user = result.Principal;

        ctx.Principal = user.Clone();
        ctx.Principal.Identities.First(x => x.AuthenticationType == "Cookie")
            .AddClaim(new Claim("github-token", "tokenExists"));
    };

});

我认为问题出在自定义OAuth授权服务器上,请问为什么会出现无法解析用户会话的情况?


解决方案

问题根源在于自定义授权服务器的授权端点逻辑缺失,以及客户端会话关联逻辑的不合理:

1. 授权服务器的核心问题

你的授权端点/oauth/authorize仅生成了授权码,但没有将当前认证用户的标识(比如NameIdentifier)存入授权码,也没有在后续发放Token时将用户身份传递给客户端。而GitHub的OAuth流程会在授权成功后返回用户标识,客户端可以自然关联已有会话。

同时,客户端从授权服务器重定向回来时,请求中可能没有携带有效的客户端Cookie(或Cookie因数据保护密钥不一致导致解密失败),直接尝试认证Cookie必然失败。

2. 修复步骤

第一步:修改授权服务器的授权端点,存入用户标识

在授权端点中,获取当前认证用户的Claims,添加到OAuthCode对象中:

// 注入ClaimsPrincipal获取当前用户
app.MapGet("/oauth/authorize", (HttpRequest request, IDataProtectionProvider dataProtectionProvider, ClaimsPrincipal user) =>
{
    // 省略原有参数解析逻辑...

    // 获取当前用户的NameIdentifier,确保用户已认证
    var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? throw new UnauthorizedAccessException();

    var code = new OAuthCode
    (
        clientId!,
        codeChallenge!,
        codeChallengeMethod!,
        redirectUri!,
        DateTime.UtcNow.AddMinutes(5),
        userId // 新增用户ID字段
    );

    // 省略后续序列化和重定向逻辑...
})
.RequireAuthorization("Authorized");

第二步:修改Token端点,在JWT中传入用户ID

生成AccessToken时,使用授权码中的用户ID作为sub字段,而非新生成的Guid:

// 解析OAuthCode后获取userId
var userId = oAuthCode.UserId;

var handler = new JsonWebTokenHandler();
var result = new
{
    access_token = handler.CreateToken(new SecurityTokenDescriptor
    {
        Claims = new Dictionary<string, object>
        {
            [JwtRegisteredClaimNames.Sub] = userId, // 使用授权服务器的用户ID
            ["SomeCustomClaim"] = "CustomClaimValue"
        },
        // 省略其他配置...
    }),
    token_type = "Bearer"
};

第三步:调整客户端OnCreatingTicket逻辑

不需要强制从Cookie解析会话,直接从AccessToken的Payload中提取用户ID,按需关联本地会话:

options.Events.OnCreatingTicket = async ctx =>
{
    // 解析AccessToken获取用户ID
    var payLoadBase64 = ctx.AccessToken!.Split('.')[1];
    var payLoadJson = Encoding.UTF8.GetString(Base64UrlTextEncoder.Decode(payLoadBase64));
    var payLoad = JsonDocument.Parse(payLoadJson);
    var userId = payLoad.RootElement.GetProperty("sub").GetString();

    // 尝试关联客户端本地会话
    var oAuthHandlerProvider = ctx.HttpContext.RequestServices.GetRequiredService<IAuthenticationHandlerProvider>();
    var handler = await oAuthHandlerProvider.GetHandlerAsync(ctx.HttpContext, "Cookie");
    var result = await handler!.AuthenticateAsync();
    
    if (result.Succeeded)
    {
        // 关联已有会话,添加授权标识
        ctx.Principal = result.Principal.Clone();
        ctx.Principal?.Identities.First(x => x.AuthenticationType == "Cookie")
            .AddClaim(new Claim("CustomServer-authorized", "true"));
    }
    else
    {
        // 基于授权服务器返回的用户ID新建会话
        var claims = new List<Claim>
        {
            new Claim(ClaimTypes.NameIdentifier, userId!),
            new Claim("CustomServer-authorized", "true")
        };
        var identity = new ClaimsIdentity(claims, "Cookie");
        ctx.Principal = new ClaimsPrincipal(identity);
    }

    ctx.RunClaimActions(payLoad.RootElement);
};

额外注意事项

  • 确保授权服务器和客户端的数据保护密钥一致:本地测试可共享密钥文件,生产环境使用统一密钥管理服务,否则客户端解密Cookie会失败。
  • 授权端点的RequireAuthorization("Authorized")需确保对应策略存在,或使用默认授权策略,保证只有已认证用户能访问。

内容的提问来源于stack exchange,提问作者Shaka

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.07 05:27:03