Duende BFF调用Identity Server时异常使用client_credentials报未授权错误
环境配置如下:
- 身份提供方:采用Duende Identity Server 6版本,已注册授权类型为
GrantTypes.Code的公共客户端test_client,配置代码如下:
new Client { ClientId = "test_client", RequireClientSecret = false, AllowOfflineAccess = true, ClientName = "Scope", AllowedGrantTypes = GrantTypes.Code, AllowedScopes = new List<string> { "openid", }, AllowedCorsOrigins = new List<string> { "https://localhost:5001", "https://localhost:5011", }, RedirectUris = new List<string> { "https://localhost:5011/signin-oidc" } }
- 受保护API服务:配置了智能认证策略,根据请求是否携带
Bearer前缀的Authorization头,自动切换JWT Bearer认证或OIDC Cookie认证,配置代码如下:
services.Configure<CookiePolicyOptions>(options => { options.CheckConsentNeeded = context => true; options.MinimumSameSitePolicy = SameSiteMode.Strict; }) .AddAuthentication(sharedOptions => { sharedOptions.DefaultScheme = "smart"; sharedOptions.DefaultChallengeScheme = "smart"; }) .AddPolicyScheme("smart", "Authorization Bearer or OIDC", options => { options.ForwardDefaultSelector = context => { var authHeader = context.Request.Headers["Authorization"].FirstOrDefault(); if (authHeader?.StartsWith("Bearer ") == true) { return JwtBearerDefaults.AuthenticationScheme; } return "oidc"; }; }) .AddJwtBearer(jwtOptions => { jwtOptions.Authority = configuration["Authentication:Authority"]; jwtOptions.Audience = configuration["Authentication:Audience"]; jwtOptions.SaveToken = true; }) .AddCookie("Cookies") .AddOpenIdConnect("oidc", options => { options.SignInScheme = "Cookies"; options.Authority = configuration["Authentication:Authority"]; options.ClientId = configuration["Authentication:ClientId"]; options.ResponseType = "code"; options.Prompt = "login"; options.GetClaimsFromUserInfoEndpoint = true; options.Scope.Add("openid"); options.SaveTokens = true; }); services.AddAuthorization(options => { options.DefaultPolicy = new AuthorizationPolicyBuilder() .RequireAuthenticatedUser() .AddAuthenticationSchemes("smart").Build(); });
- BFF前端服务:集成Duende BFF组件,配置了基于Cookie的OIDC授权码流(Code Flow + PKCE)登录认证,同时注册了用于调用API的命名HttpClient,配置代码如下:
services .AddBff() .AddRemoteApis(); services.AddAuthentication(options => { options.DefaultScheme = "cookie"; options.DefaultChallengeScheme = "oidc"; options.DefaultSignOutScheme = "oidc"; }) .AddCookie("cookie", options => { options.Cookie.Name = "__Host-blazor"; options.Cookie.SameSite = SameSiteMode.Strict; }) .AddOpenIdConnect("oidc", options => { options.Authority = configuration["Authentication:Authority"]; // 采用授权码流+PKCE的客户端 options.ClientId = configuration["Authentication:ClientId"]; options.ResponseType = "code"; options.ResponseMode = "query"; options.MapInboundClaims = false; options.GetClaimsFromUserInfoEndpoint = true; options.SaveTokens = true; // 申请权限范围与刷新令牌 options.Scope.Clear(); options.Scope.Add("openid"); options.Scope.Add("offline_access"); }); //services.AddAccessTokenManagement(); services.AddClientAccessTokenHttpClient(AuthorizedClient, configureClient: client => { // API服务地址 client.BaseAddress = new Uri(configuration["ApiConfig:BaseAddress"]); });
所有API调用均通过IHttpClientFactory创建命名HttpClient实例发起,调用前从当前HttpContext中获取已登录用户的访问令牌,附加到请求的Authorization头后发送;接口要求用户登录后才可访问,上下文中存在有效用户令牌。
调用实现代码如下:
public BaseHttpClient(IHttpClientFactory httpClientFactory, IHttpContextAccessor httpContextAccessor, ILogger logger) { this.httpClient = httpClientFactory.CreateClient(AuthorizedClient); this.httpContextAccessor = httpContextAccessor; this.logger = logger; } protected async Task<HttpResponseMessage> SendAuthenticatedAsync(HttpRequestMessage request) { try { var token = await this.httpContextAccessor.HttpContext.GetUserAccessTokenAsync(); this.httpClient.SetBearerToken(token); var responseMessage = await this.httpClient.SendAsync(request); return responseMessage; } catch (Exception e) { this.logger?.Error(e, "Exception at sending the authenticated client"); return new HttpResponseMessage { StatusCode = HttpStatusCode.BadRequest }; } }
BFF到API的业务调用可正常执行成功,但日志存在异常报错:
- Duende BFF日志显示:发起API请求时检测到
default客户端的访问令牌缓存缺失,自动构造令牌请求调用Identity Server的/connect/token端点,最终收到400响应,返回unauthorized_client错误,对应日志如下:
[19:05:39 DBG] AuthenticationScheme: cookie was successfully authenticated. [19:05:39 DBG] AuthenticationScheme: cookie was successfully authenticated. [19:05:39 INF] Start processing HTTP request POST https://localhost:5001/api/v1/property [19:05:39 INF] Start processing HTTP request POST https://localhost:5001/api/v1/property [19:05:39 DBG] Cache miss for access token for client: default [19:05:39 DBG] Cache miss for access token for client: default [19:05:39 DBG] Requesting client access token for client: default [19:05:39 DBG] Requesting client access token for client: default [19:05:39 DBG] Constructing token client configuration from OpenID Connect handler. [19:05:39 DBG] Constructing token client configuration from OpenID Connect handler. [19:05:39 DBG] Returning token client configuration for client: default [19:05:39 DBG] Returning token client configuration for client: default [19:05:39 INF] Start processing HTTP request POST https://localhost:5443/connect/token [19:05:39 INF] Start processing HTTP request POST https://localhost:5443/connect/token [19:05:39 INF] Sending HTTP request POST https://localhost:5443/connect/token [19:05:39 INF] Sending HTTP request POST https://localhost:5443/connect/token [19:05:39 INF] Received HTTP response headers after 160.5943ms - 400 [19:05:39 INF] Received HTTP response headers after 160.5943ms - 400 [19:05:39 INF] End processing HTTP request after 168.1572ms - 400 [19:05:39 INF] End processing HTTP request after 168.1572ms - 400 [19:05:39 ERR] Error requesting access token for client default. Error = unauthorized_client. Error description = null [19:05:39 ERR] Error requesting access token for client default. Error = unauthorized_client. Error description = null [19:05:39 INF] Sending HTTP request POST https://localhost:5001/api/v1/property [19:05:39 INF] Sending HTTP request POST https://localhost:5001/api/v1/property [19:05:39 INF] Received HTTP response headers after 110.934ms - 400 [19:05:39 INF] Received HTTP response headers after 110.934ms - 400 [19:05:39 INF] End processing HTTP request after 295.8003ms - 400 [19:05:39 INF] End processing HTTP request after 295.8003ms - 400 [19:05:39 INF] Executing StatusCodeResult, setting HTTP status code 200 [19:05:39 INF] Executing StatusCodeResult, setting HTTP status code 200
- Identity Server日志显示:收到
test_client发起的令牌请求,授权类型为client_credentials,但该客户端仅配置了Code授权类型,未允许客户端凭证流,因此拒绝请求返回未授权错误,对应日志如下:
[19:05:39 VRB] Calling into client configuration validator: Duende.IdentityServer.Validation.DefaultClientConfigurationValidator [19:05:39 DBG] client configuration validation for client test_client succeeded. [19:05:39 DBG] Public Client - skipping secret validation success [19:05:39 DBG] Client validation success [19:05:39 INF] {"ClientId": "test_client", "AuthenticationMethod": "NoSecret", "Category": "Authentication", "Name": "Client Authentication Success", "EventType": "Success", "Id": 1010, "Message": null, "ActivityId": "0HMI8JMB87769:00000004", "TimeStamp": "2022-06-07T16:05:39.0000000Z", "ProcessId": 21368, "LocalIpAddress": "::1:5443", "RemoteIpAddress": "::1", "$type": "ClientAuthenticationSuccessEvent"} [19:05:39 VRB] Calling into token request validator: Duende.IdentityServer.Validation.TokenRequestValidator [19:05:39 DBG] Start token request validation [19:05:39 DBG] Start client credentials token request validation [19:05:39 ERR] Client not authorized for client credentials flow, check the AllowedGrantTypes setting{"clientId": "test_client"}, details: {"ClientId": "test_client", "ClientName": "Scope", "GrantType": "client_credentials", "Scopes": null, "AuthorizationCode": "********", "RefreshToken": "********", "UserName": null, "AuthenticationContextReferenceClasses": null, "Tenant": null, "IdP": null, "Raw": {"grant_type": "client_credentials", "client_id": "test_client"}, "$type": "TokenRequestValidationLog"} [19:05:39 INF] {"ClientId": "test_client", "ClientName": "Scope", "RedirectUri": null, "Endpoint": "Token", "SubjectId": null, "Scopes": null, "GrantType": "client_credentials", "Error": "unauthorized_client", "ErrorDescription": null, "Category": "Token", "Name": "Token Issued Failure", "EventType": "Failure", "Id": 2001, "Message": null, "ActivityId": "0HMI8JMB87769:00000004", "TimeStamp": "2022-06-07T16:05:39.0000000Z", "ProcessId": 21368, "LocalIpAddress": "::1:5443", "RemoteIpAddress": "::1", "$type": "TokenIssuedFailureEvent"} [19:05:39 VRB] Invoking result: Duende.IdentityServer.Endpoints.Results.TokenErrorResult [19:05:39 DBG] Connection id "0HMI8JMB87769" completed keep alive response. [19:05:39 DBG] 'ConfigurationDbContext' disposed.
错误触发的核心原因是HttpClient注册方法选型错误:AddClientAccessTokenHttpClient是AccessTokenManagement库提供的扩展方法,默认行为是自动为客户端申请client_credentials类型的客户端访问令牌,不需要关联用户上下文,适用于服务间无用户身份的后台调用场景。
当前业务场景是传递当前登录用户的用户访问令牌调用API,不适合使用该注册方法。虽然发送请求前手动调用SetBearerToken附加了用户令牌,但是该HttpClient绑定的默认令牌管理逻辑会在请求发送前优先执行自身的令牌获取逻辑:发现默认客户端的client_credentials令牌缓存为空,就自动发起客户端凭证流请求。由于IdentityServer中test_client没有开启client_credentials授权,因此返回400错误。这个自动获取令牌的动作报错后,手动设置的Bearer令牌仍然会被附加到请求上,所以最终业务API调用能成功,只是多了一次失败的令牌请求和对应错误日志。
根据实际业务场景选择对应修复方式即可:
- 纯用户态调用场景:如果所有API调用都需要传递当前登录用户身份,把
AddClientAccessTokenHttpClient替换为专门用于传递用户令牌的AddUserAccessTokenHttpClient扩展方法,替换后不会再自动发起client_credentials令牌请求。示例代码:
// 替换原有AddClientAccessTokenHttpClient注册 services.AddUserAccessTokenHttpClient(AuthorizedClient, configureClient: client => { client.BaseAddress = new Uri(configuration["ApiConfig:BaseAddress"]); });
替换后不需要再手动调用GetUserAccessTokenAsync和SetBearerToken,HttpClient会自动从当前请求上下文提取用户访问令牌附加到请求头,减少冗余代码。
- 混合调用场景:如果同时存在用户上下文调用和无用户的服务间调用,需要做逻辑隔离:单独注册一个带ClientSecret的机密客户端,在IdentityServer中为该客户端开启
client_credentials授权类型、分配对应API权限,专门用于后台服务间调用;用户态的API调用单独注册HttpClient,使用AddUserAccessTokenHttpClient绑定用户令牌逻辑,两类HttpClient完全分开,避免混用触发不必要的令牌请求。 - 不推荐的临时规避方案:如果暂时不想调整HttpClient注册逻辑,可以在IdentityServer的test_client配置中加入
GrantTypes.ClientCredentials允许客户端凭证流,但test_client是公共客户端(RequireClientSecret = false),这种方式存在严重安全风险,禁止在生产环境使用。
内容的提问来源于stack exchange,提问作者newLoop

