ASP.NET Core 10 JwtBearer + Keycloak OIDC 本地开发 401 循环跳转排查全记录

记一次 .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 时,它就和 Keycloaksession 缓存形成完美死循环。

排查技巧 :前端 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 → 拉不到 JWKS
  • ValidateIssuerSigningKey = true(默认)→ 需要签名
  • SignatureValidator = null → 没有绕过逻辑

KeycloakRS256 签名 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.ValidateSignatureValidateAfterSignatureFailedValidateIssuer

根因options.Authority = Kc.Issuer 在分支之前就设了。JwtBearer 中间件发现 Authority 有值,自动触发:

  1. 追加 /.well-known/openid-configuration 拉发现文档
  2. jwks_uriJWKS 公钥列表
  3. 用配置里的 Issuer/Audience/SigningKeys 覆盖 TokenValidationParameters 里的自定义参数
  4. 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 10JwtBearer 中间件默认使用 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?

JwtSecurityTokenJsonWebToken 都继承自基类,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 = falseRequireSignedTokens = false 来绕过 Dev 模式验证 。这两个 flag 只是关闭了可选的校验步骤,JwtBearer 中间件在 Authority 已设置或配置已加载的情况下依然会做底层的签名密码学计算。要彻底绕过,必须用 SignatureValidator 接管整个签名流程,且返回类型要与当前 Token Handler 匹配

七、总结

本文记录了在 .NET 10 + Keycloak SSO 认证中遇到的登录死循环问题及其排查过程。前端登录后调用 /me 接口返回 401,触发401 拦截器清除 token 并重定向,形成死循环。排查发现三个关键问题:

  1. IPostConfigureOptions 被注释:导致 JwtBearer 配置未生效,无法验证 KeycloakRS256签名Token
  2. Authority 设置位置错误:开发模式下提前设置 Authority 导致自动拉取 JWKS,覆盖了自定义的 TokenValidationParameters
  3. 类型不匹配:SignatureValidator 返回 JwtSecurityToken,但 .NET 10JsonWebTokenHandler 需要 JsonWebToken 类型,引发静默失败。

解决方法:修正配置加载顺序、隔离开发/生产环境的 Authority 设置,并确保返回正确的 Token 类型。通过断点调试和堆栈分析,逐步定位问题根源,最终解决了登录 401 循环问题。