自定义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

