记一次 .NET 10 JwtBearer + Keycloak 登录死循环的完整排查
背景
项目使用 .NET 10 + Next.js 的前后端分离架构,认证方案是 Keycloak SSO(Authorization Code Flow)。前端调整设计规范统一走 Keycloak 登录后,本地开发环境出现了经典的「登录死循环」:
点 Keycloak 登录 → 跳 Keycloak 授权页 → 回调保存 token → 跳首页 → 调
/me接口 → 401 → 清 session → 跳回登录页 → 再点登录 → ♾️
这篇文章记录整个排查过程,重点是 三个藏得很深的坑,以及如何用断点+堆栈逐层定位。
一、先看清死循环的机制
前端链路
bash
AuthGate(/) → 无 token → /login
/login → 点 Keycloak 登录 → Keycloak 授权(已有 session 秒回 code)
/auth/callback → 换 token → 存 localStorage → redirect /
AuthGate(/) → 有 token → 调 GET /me
/me → 401 → client.ts 401 handler:
userManager.removeUser() // 清 localStorage
window.location.assign('/login') // 跳登录
→ Keycloak 又有 session → 再签 code → 死循环
关键代码:client.ts 的 401 全局拦截器
typescript
if (res.status === 401 && typeof window !== "undefined") {
const { userManager } = await import("@/lib/auth/oidc");
await userManager.removeUser(); // 清 token
window.location.assign(`/login?redirect=${encodeURIComponent(path)}`);
throw new ApiError("身份认证已失效,正在跳转登录...", 401);
}
这个拦截器的本意是正确的------ token 过期或无效就踢回登录。但当后端 每次都 返回 401 时,它就和 Keycloak 的 session 缓存形成完美死循环。
排查技巧 :前端 Network 面板勾选 「Preserve log」,页面跳转后翻 /me 的请求,确认状态码就是 401。
二、第一个坑:PostConfigure 被注释了
项目架构
认证配置的 JwtBearer 参数不是写在 AddJwtBearer() 回调里,而是通过 IPostConfigureOptions<JwtBearerOptions> 推迟到 DI 容器构建完成后执行:
csharp
// JwtBearerPostConfigure.cs
public void PostConfigure(string? name, JwtBearerOptions options)
{
if (name != JwtBearerDefaults.AuthenticationScheme) return;
using var scope = serviceProvider.CreateScope();
var provider = scope.ServiceProvider.GetRequiredService<IIdentityProvider>();
provider.ConfigureJwtBearer(options, env); // ←-- 核心调用
}
注册代码在 AuthExtensions.cs:
csharp
services.TryAddEnumerable(
ServiceDescriptor.Singleton<
IPostConfigureOptions<JwtBearerOptions>,
JwtBearerPostConfigure>());
这行 被注释掉了 。后果是 ConfigureJwtBearer 从头到尾没被执行,JwtBearer 中间件拿到的是纯默认 JwtBearerOptions:
Authority = null→ 拉不到JWKSValidateIssuerSigningKey = true(默认)→ 需要签名SignatureValidator = null→ 没有绕过逻辑
Keycloak 的 RS256 签名 token 在没有任何密钥配置的环境下当然验不过 → 401。
教训 :当怀疑「配置为什么没生效」时,在 PostConfigure / ConfigureJwtBearer 方法入口打断点,确认整个调用链是通的。
三、第二个坑:Authority 设在了 if/else 之前
取消注释后,PostConfigure 执行了,Dev 模式的 TokenValidationParameters 也设了:
csharp
public void ConfigureJwtBearer(JwtBearerOptions options, IHostEnvironment env)
{
options.Authority = Kc.Issuer; // ← 这行在 if/else 前面!!
options.RequireHttpsMetadata = false;
if (env.IsDevelopment()) {
// 看似跳过了所有验证......
options.TokenValidationParameters = new TokenValidationParameters {
ValidateIssuerSigningKey = false,
RequireSignedTokens = false,
SignatureValidator = (token, _) => new JwtSecurityToken(token), // ←-- 第3个坑,请看后续说明
};
}
}
但 401 依旧。堆栈显示走了 JsonWebTokenHandler.ValidateSignature → ValidateAfterSignatureFailed → ValidateIssuer。
根因 :options.Authority = Kc.Issuer 在分支之前就设了。JwtBearer 中间件发现 Authority 有值,自动触发:
- 追加
/.well-known/openid-configuration拉发现文档 - 从
jwks_uri拉JWKS公钥列表 - 用配置里的
Issuer/Audience/SigningKeys覆盖TokenValidationParameters里的自定义参数 SignatureValidator被配置推导出的签名逻辑完全绕过
教训 :Authority 必须只在需要 JWKS 自动发现的环境(生产)才设置。 Dev 模式不设,防止配置覆盖。
修复:
csharp
public void ConfigureJwtBearer(JwtBearerOptions options, IHostEnvironment env)
{
options.RequireHttpsMetadata = false;
if (env.IsDevelopment() || env.IsEnvironment("Dev"))
{
// ★ Dev:不设 Authority,SignatureValidator 完全接管
options.TokenValidationParameters = new TokenValidationParameters { ... };
}
else
{
// ★ 生产:设 Authority,走完整 JWKS 校验
options.Authority = Kc.Issuer;
options.TokenValidationParameters = new TokenValidationParameters { ... };
}
}
四、第三个坑:JwtSecurityToken vs JsonWebToken
Authority 问题修复后,堆栈从 ValidateSignature 变成了 ValidateSignatureUsingDelegates------说明 SignatureValidator 终于被认识了。
断点确认了委托被调用,token 值正确,new JwtSecurityToken(token) 也没有抛异常。但 还是 401。
真正原因:类型不匹配
.NET 10 的 JwtBearer 中间件默认使用 JsonWebTokenHandler (而不是老版 JwtSecurityTokenHandler)。JsonWebTokenHandler.ValidateSignatureUsingDelegates 内部调用 SignatureValidator 后,期望返回的是 JsonWebToken 类型,而不是 JwtSecurityToken。
- 错误方式:
csharp
using System.IdentityModel.Tokens.Jwt;
// ❌ 错误:返回 JwtSecurityToken,JsonWebTokenHandler 后续处理失败
SignatureValidator = (token, _) => new JwtSecurityToken(token),
// or 使用这种写法方便调试
SignatureValidator = (token, _) => {
return new JsonWebToken(token); // ←-- F9 断点打这里
},
- 正确方式:
csharp
// 需要加 using
using Microsoft.IdentityModel.JsonWebTokens;
// ✅ 正确:返回 JsonWebToken (如需调试,代码写法同上)
SignatureValidator = (token, _) => new JsonWebToken(token),
为什么 JwtSecurityToken 不抛异常却导致 401?
JwtSecurityToken 和 JsonWebToken 都继承自基类,ValidateSignatureUsingDelegates 的返回类型没有强制约束为 JsonWebToken,所以编译器不会报错。但 JsonWebTokenHandler 内部的后续处理(claims 提取、配置校验等)强依赖 JsonWebToken 的内部结构,拿到 JwtSecurityToken 后默默地走了失败分支,最终产生 401。
教训 :在 .NET 8+ / .NET 10 项目中使用 SignatureValidator 回调时,必须返回 JsonWebToken ,不要想当然用老的 JwtSecurityToken。两个类虽名字相似,但内部实现完全不同。
五、最终修复方案总结
文件 1:KeycloakIdentityProvider.cs
csharp
using Microsoft.IdentityModel.JsonWebTokens; // ←-- 新增命名空间
public void ConfigureJwtBearer(JwtBearerOptions options, IHostEnvironment env)
{
options.RequireHttpsMetadata = false;
if (env.IsDevelopment() || env.IsEnvironment("Dev"))
{
// Dev:不设 Authority,完全绕过签名校验
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = false,
ValidateLifetime = false,
ValidateIssuerSigningKey = false,
RequireSignedTokens = false,
SignatureValidator = (token, _) => new JsonWebToken(token), // ←-- 使用 JsonWebToken
ClockSkew = TimeSpan.Zero
};
}
else
{
// 生产:完整校验
options.Authority = Kc.Issuer;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = Kc.Issuer,
ValidAudiences = validAudiences,
ValidateIssuer = true,
ValidateAudience = true,
ValidateIssuerSigningKey = true,
RequireSignedTokens = true,
ClockSkew = TimeSpan.Zero
};
}
}
文件 2:AuthExtensions.cs
csharp
// 确保这行没有被注释
services.TryAddEnumerable(ServiceDescriptor.Singleton<
IPostConfigureOptions<JwtBearerOptions>,
JwtBearerPostConfigure>());
六、排查方法论总结
| 步骤 | 做什么 | 用到什么 |
|---|---|---|
| 1 | 看清循环链路 | Network → Preserve log → /me 状态码 |
| 2 | 确认配置是否生效 | 在 PostConfigure 方法入口打断点 |
| 3 | 确认 Dev/生产分支 |
悬停 env.IsDevelopment() 、Kc.Issuer |
| 4 | 确认 SignatureValidator 是否被调用 |
在委托内部打断点,看 token 值 |
| 5 | 看堆栈走的具体路径 | ValidateSignature(忽视委托)vs ValidateSignatureUsingDelegates(使用委托) |
| 6 | 确认返回类型 | JwtSecurityToken → 换 JsonWebToken |
核心原则:不要只依赖 ValidateIssuerSigningKey = false 和 RequireSignedTokens = false 来绕过 Dev 模式验证 。这两个 flag 只是关闭了可选的校验步骤,JwtBearer 中间件在 Authority 已设置或配置已加载的情况下依然会做底层的签名密码学计算。要彻底绕过,必须用 SignatureValidator 接管整个签名流程,且返回类型要与当前 Token Handler 匹配。
七、总结
本文记录了在 .NET 10 + Keycloak SSO 认证中遇到的登录死循环问题及其排查过程。前端登录后调用 /me 接口返回 401,触发401 拦截器清除 token 并重定向,形成死循环。排查发现三个关键问题:
IPostConfigureOptions被注释:导致JwtBearer配置未生效,无法验证Keycloak的RS256签名Token。Authority设置位置错误:开发模式下提前设置Authority导致自动拉取JWKS,覆盖了自定义的TokenValidationParameters。- 类型不匹配:
SignatureValidator返回JwtSecurityToken,但.NET 10的JsonWebTokenHandler需要JsonWebToken类型,引发静默失败。
解决方法:修正配置加载顺序、隔离开发/生产环境的 Authority 设置,并确保返回正确的 Token 类型。通过断点调试和堆栈分析,逐步定位问题根源,最终解决了登录 401 循环问题。