使用独立Identity Server 4的ASP.NET Core应用切换至Azure SignalR Service时协商请求返回401的问题排查
解决Azure SignalR Service下的JWT认证及令牌过期重试问题
针对你遇到的两个核心问题,我整理了具体的解决方案,一步步帮你排查修复:
一、解决Azure SignalR协商请求401(签名密钥找不到)的问题
你当前的JWT配置硬编码了IssuerSigningKey和IssuerSigningKeyResolver,这是导致问题的关键。虽然自托管SignalR模式下运行正常,但Azure SignalR Service的请求转发逻辑,加上Identity Server 4的密钥管理机制,会让硬编码的密钥和令牌实际使用的签名密钥不匹配(比如密钥ID kid 不一致)。
正确的做法是让AddJwtBearer自动从Identity Server的JWKS(JSON Web Key Set)端点获取签名密钥,而不是手动指定。修改你的认证配置如下:
services.AddAuthentication("Bearer") .AddJwtBearer("Bearer", options => { var appSettings = Configuration.Get<AppSettingsModel>(); options.Authority = appSettings.Authority; options.RefreshOnIssuerKeyNotFound = true; if (environment.IsDevelopment()) { options.RequireHttpsMetadata = false; } options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters { ValidateAudience = false // 移除手动设置的IssuerSigningKey和IssuerSigningKeyResolver }; options.Events = new JwtBearerEvents { OnMessageReceived = context => { var accessToken = ""; var headerToken = context.Request.Headers[HeaderNames.Authorization].ToString().Replace("Bearer ", ""); if (!string.IsNullOrEmpty(headerToken)) { accessToken = headerToken; } var queryStringToken = context.Request.Query["access_token"]; if (!string.IsNullOrEmpty(queryStringToken)) { accessToken = queryStringToken; } if (!string.IsNullOrEmpty(accessToken) && context.HttpContext.Request.Path.StartsWithSegments("/hubs")) { context.Token = accessToken; } return Task.CompletedTask; } }; });
为什么这样改?
当你配置options.Authority后,AddJwtBearer会自动访问{Authority}/.well-known/openid-configuration/jwks端点,拉取Identity Server当前使用的所有签名密钥,确保和令牌的kid完全匹配,彻底解决"signature key was not found"的错误。
二、解决令牌过期休眠后协商失败的问题
移除自定义HttpClient后,你丢失了令牌过期时自动刷新并重试的逻辑。我们可以修复原来的自定义HttpClient,让它兼容Azure SignalR的协商流程,同时避免无限重试和并发刷新的问题:
export class CustomSignalRHttpClientService extends signalR.DefaultHttpClient { private token: string = ""; private refreshInProgress = false; private refreshPromise: Promise<string> | null = null; constructor(private authService: AuthorizeService) { super(console); // 订阅令牌更新 authService.accessToken$.subscribe(token => { this.token = token; }); } public async send(request: signalR.HttpRequest): Promise<signalR.HttpResponse> { // 给请求添加当前令牌 request.headers = { ...request.headers, Authorization: `Bearer ${this.token}` }; try { return await super.send(request); } catch (er) { // 仅处理401且未重试过的请求 if (er instanceof signalR.HttpError && er.statusCode === 401 && !request.headers['X-Retry-Attempt']) { let newToken: string; // 处理并发刷新请求,避免多次调用刷新接口 if (!this.refreshInProgress) { this.refreshInProgress = true; try { newToken = await this.authService.getAccessToken().toPromise(); this.token = newToken; } finally { this.refreshInProgress = false; this.refreshPromise = null; } } else { // 等待正在进行的刷新请求完成 newToken = await this.refreshPromise!; } // 更新请求头,添加重试标记防止无限循环 request.headers = { ...request.headers, Authorization: `Bearer ${newToken}`, 'X-Retry-Attempt': '1' }; // 重试请求 return await super.send(request); } // 非401错误或已重试,直接抛出 throw er; } } }
关键优化点:
- 添加了
X-Retry-Attempt头,确保每个请求只重试一次,避免无限循环 - 处理了并发刷新场景,防止多个请求同时触发令牌刷新
- 简化了令牌更新的逻辑,确保请求始终使用最新的有效令牌
额外注意事项
- 生产环境中务必将
RequireHttpsMetadata设置为true,避免安全风险 - 可以通过访问
{Authority}/.well-known/openid-configuration/jwks端点,确认返回的密钥包含令牌中的kid值,验证密钥匹配性 - 确保Identity Server 4的配置中开启了JWKS端点(默认是开启状态)
内容的提问来源于stack exchange,提问作者Weissvonnix
相关产品推荐
相关产品推荐

