记一次.NET 10 JwtBearer+Keycloak登录死循环的完整排查
背景
项目使用.NET 10 + Next.js的前后端分离架构,认证方案是Keycloak SSO(Authorization Code Flow)。前端调整设计规范统一走Keycloak登录后,本地开发环境出现了经典的「登录死循环」:
点 Keycloak 登录 → 跳 Keycloak 授权页 → 回调保存 token → 跳首页 → 调
/me接口 →401→ 清 session → 跳回登录页 → 再点登录 → ♾️
这篇文章记录整个排查过程,重点是三个藏得很深的坑,以及如何用断点+堆栈逐层定位。
一、先看清死循环的机制
前端链路
AuthGate(/)→ 无 token → /login /login → 点 Keycloak 登录 → Keycloak 授权(已有 session 秒回 code) /auth/callback → 换 token → 存 localStorage → redirect / AuthGate(/)→ 有 token → 调 GET /me /me →401→ client.ts401handler: userManager.removeUser()// 清 localStorage window.location.assign('/login')// 跳登录 → Keycloak 又有 session → 再签 code → 死循环关键代码:client.ts的 401 全局拦截器
if(res.status===401&&typeofwindow!=="undefined"){const{userManager}=awaitimport("@/lib/auth/oidc");awaituserManager.removeUser();// 清 tokenwindow.location.assign(`/login?redirect=${encodeURIComponent(path)}`);thrownewApiError("身份认证已失效,正在跳转登录…",401);}这个拦截器的本意是正确的——token过期或无效就踢回登录。但当后端每次都返回401时,它就和Keycloak的session缓存形成完美死循环。
排查技巧:前端Network面板勾选「Preserve log」,页面跳转后翻/me的请求,确认状态码就是401。
二、第一个坑:PostConfigure 被注释了
项目架构
认证配置的JwtBearer参数不是写在AddJwtBearer()回调里,而是通过IPostConfigureOptions<JwtBearerOptions>推迟到DI容器构建完成后执行:
// JwtBearerPostConfigure.cspublicvoidPostConfigure(string?name,JwtBearerOptionsoptions){if(name!=JwtBearerDefaults.AuthenticationScheme)return;usingvarscope=serviceProvider.CreateScope();varprovider=scope.ServiceProvider.GetRequiredService<IIdentityProvider>();provider.ConfigureJwtBearer(options,env);// ←-- 核心调用}注册代码在AuthExtensions.cs:
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也设了:
publicvoidConfigureJwtBearer(JwtBearerOptionsoptions,IHostEnvironmentenv){options.Authority=Kc.Issuer;// ← 这行在 if/else 前面!!options.RequireHttpsMetadata=false;if(env.IsDevelopment()){// 看似跳过了所有验证……options.TokenValidationParameters=newTokenValidationParameters{ValidateIssuerSigningKey=false,RequireSignedTokens=false,SignatureValidator=(token,_)=>newJwtSecurityToken(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模式不设,防止配置覆盖。
修复:
publicvoidConfigureJwtBearer(JwtBearerOptionsoptions,IHostEnvironmentenv){options.RequireHttpsMetadata=false;if(env.IsDevelopment()||env.IsEnvironment("Dev")){// ★ Dev:不设 Authority,SignatureValidator 完全接管options.TokenValidationParameters=newTokenValidationParameters{...};}else{// ★ 生产:设 Authority,走完整 JWKS 校验options.Authority=Kc.Issuer;options.TokenValidationParameters=newTokenValidationParameters{...};}}四、第三个坑:JwtSecurityToken vs JsonWebToken
Authority问题修复后,堆栈从ValidateSignature变成了ValidateSignatureUsingDelegates——说明SignatureValidator终于被认识了。
断点确认了委托被调用,token值正确,new JwtSecurityToken(token)也没有抛异常。但还是 401。
真正原因:类型不匹配
.NET 10的JwtBearer中间件默认使用JsonWebTokenHandler(而不是老版JwtSecurityTokenHandler)。JsonWebTokenHandler.ValidateSignatureUsingDelegates内部调用SignatureValidator后,期望返回的是JsonWebToken类型,而不是JwtSecurityToken。
- 错误方式:
usingSystem.IdentityModel.Tokens.Jwt;// ❌ 错误:返回 JwtSecurityToken,JsonWebTokenHandler 后续处理失败SignatureValidator=(token,_)=>newJwtSecurityToken(token),// or 使用这种写法方便调试SignatureValidator=(token,_)=>{returnnewJsonWebToken(token);// ←-- F9 断点打这里},- 正确方式:
// 需要加 usingusingMicrosoft.IdentityModel.JsonWebTokens;// ✅ 正确:返回 JsonWebToken (如需调试,代码写法同上)SignatureValidator=(token,_)=>newJsonWebToken(token),为什么 JwtSecurityToken 不抛异常却导致 401?
JwtSecurityToken和JsonWebToken都继承自基类,ValidateSignatureUsingDelegates的返回类型没有强制约束为JsonWebToken,所以编译器不会报错。但JsonWebTokenHandler内部的后续处理(claims 提取、配置校验等)强依赖JsonWebToken的内部结构,拿到JwtSecurityToken后默默地走了失败分支,最终产生401。
教训:在.NET 8+ / .NET 10项目中使用SignatureValidator回调时,必须返回JsonWebToken,不要想当然用老的JwtSecurityToken。两个类虽名字相似,但内部实现完全不同。
五、最终修复方案总结
文件 1:KeycloakIdentityProvider.cs
usingMicrosoft.IdentityModel.JsonWebTokens;// ←-- 新增命名空间publicvoidConfigureJwtBearer(JwtBearerOptionsoptions,IHostEnvironmentenv){options.RequireHttpsMetadata=false;if(env.IsDevelopment()||env.IsEnvironment("Dev")){// Dev:不设 Authority,完全绕过签名校验options.TokenValidationParameters=newTokenValidationParameters{ValidateIssuer=false,ValidateAudience=false,ValidateLifetime=false,ValidateIssuerSigningKey=false,RequireSignedTokens=false,SignatureValidator=(token,_)=>newJsonWebToken(token),// ←-- 使用 JsonWebTokenClockSkew=TimeSpan.Zero};}else{// 生产:完整校验options.Authority=Kc.Issuer;options.TokenValidationParameters=newTokenValidationParameters{ValidIssuer=Kc.Issuer,ValidAudiences=validAudiences,ValidateIssuer=true,ValidateAudience=true,ValidateIssuerSigningKey=true,RequireSignedTokens=true,ClockSkew=TimeSpan.Zero};}}文件 2:AuthExtensions.cs
// 确保这行没有被注释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(忽视委托)vsValidateSignatureUsingDelegates(使用委托) |
| 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循环问题。