fix(health): token_broker probe uses token exchange (stops 400s every 5 min) #37

Merged
KofTwentyTwo merged 1 commits from feature/broker-probe-token-exchange into develop 2026-10-16 13:48:26 +00:00
6 changed files with 104 additions and 13 deletions

No files matched your search

+1
View File
@@ -4,6 +4,7 @@ All notable changes to this project are documented here. Format: [Keep a Changel
## [Unreleased]
- Fix: the `token_broker` health check probes with the grant the broker uses (token exchange) and sends no subject token, so nothing is issued. `invalid_request` (credentials passed) is UP, `invalid_client` is DOWN (bad secret), and `invalid_grant` is DOWN (provider does not allow token exchange). It used to probe `client_credentials`, which the broker provider does not allow, so Authentik logged a 400 every 5 minutes ("Invalid grant_type for provider").
- Replace expired token-delivery and credential-scope reflection bridges with the RC.7 APIs; retain conservative permission narrowing and explicit unavailable-delivery tests.
- Ship the expanded, host-neutral QBIT help bundle using QQQ 4.1.0-RC.7 registration and validation, with coverage for optional features and live references. Preserve inline app/table help for hosts without help storage; document HOST bundle overrides.
@@ -1563,7 +1563,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
{
// Never echo an untrusted broker error body.
}
throw (new QUserFacingException("Authentik refused to act as you (" + error + ")"));
throw (new BrokerRefusedException(error));
}
throw (mapError(request, path, status, response.getContent(), token));
}
@@ -62,20 +62,40 @@ public class AuthentikTokenBroker
/*******************************************************************************
** Request client credentials for health, then discard the bearer immediately.
** Prove the broker's client credentials with the grant it actually uses
** (token exchange), without a subject token, so nothing is issued and no
** user data is sent. Authentik checks the grant type, then the client
** secret, then the subject: invalid_request (missing subject) means both
** passed; invalid_client means a bad secret; invalid_grant means the
** provider does not allow token exchange.
*******************************************************************************/
public void probeClientCredentials(AuthentikActionUtil util) throws QException
public void probeCredentials(AuthentikActionUtil util) throws QException
{
AuthentikQBitConfig config = util.getRuntime().getConfig();
HttpPost request = new HttpPost(config.getTokenEndpointUrl());
request.setEntity(new UrlEncodedFormEntity(List.of(
new BasicNameValuePair("grant_type", "client_credentials"),
new BasicNameValuePair("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"),
new BasicNameValuePair("client_id", config.getTokenBrokerClientId()),
new BasicNameValuePair("client_secret", config.getTokenBrokerClientSecret()),
new BasicNameValuePair("scope", "goauthentik.io/api")), StandardCharsets.UTF_8));
if(util.requestBrokerCredentials(request).optString("access_token", "").isBlank())
new BasicNameValuePair("client_secret", config.getTokenBrokerClientSecret())), StandardCharsets.UTF_8));
String code;
try
{
throw (new QUserFacingException("Authentik broker returned no access token"));
util.requestBrokerCredentials(request);
code = "issued";
}
catch(BrokerRefusedException e)
{
code = e.getErrorCode();
}
switch(code)
{
case "invalid_request" ->
{
return;
}
case "invalid_client" -> throw (new QUserFacingException("Authentik rejected the token broker's client credentials"));
case "invalid_grant", "unsupported_grant_type" -> throw (new QUserFacingException("The token broker's Authentik provider does not allow token exchange"));
default -> throw (new QUserFacingException("Authentik answered the token broker probe unexpectedly (" + code + ")"));
}
}
}
@@ -0,0 +1,51 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.client;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
/*******************************************************************************
** Authentik's token endpoint refused a broker request. Carries only the
** standard OAuth2 error code (never the untrusted response body).
*******************************************************************************/
public class BrokerRefusedException extends QUserFacingException
{
private final String errorCode;
/*******************************************************************************
** Constructor.
*******************************************************************************/
public BrokerRefusedException(String errorCode)
{
super("Authentik refused to act as you (" + errorCode + ")");
this.errorCode = errorCode;
}
/*******************************************************************************
** The OAuth2 error code, or exchange_failed when it was not a standard one.
*******************************************************************************/
public String getErrorCode()
{
return (errorCode);
}
}
@@ -188,7 +188,7 @@ public final class AuthentikHealthChecks
{
return (HealthCheckOutcome.unknown("not configured"));
}
new AuthentikTokenBroker().probeClientCredentials(util);
new AuthentikTokenBroker().probeCredentials(util);
return (HealthCheckOutcome.up("Token broker authenticated"));
}
}
@@ -200,19 +200,28 @@ class AuthentikHealthChecksTest
/*******************************************************************************
** A broker check exercises the grant without exposing the returned bearer.
** The broker check uses the grant the broker uses (token exchange) with no
** subject: invalid_request proves the credentials; nothing is ever issued.
*******************************************************************************/
@Test
void brokerStates() throws Exception
{
respond(new JSONObject().put("access_token", SECRET));
refuse("invalid_request");
assertThat(run("token_broker").getStatus()).isEqualTo(HealthStatus.UP);
HttpPost request = (HttpPost) FixtureActionUtil.REQUESTS.getLast();
assertThat(EntityUtils.toString(request.getEntity())).contains("grant_type=client_credentials", "client_id=", "client_secret=");
String body = EntityUtils.toString(request.getEntity());
assertThat(body).contains("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange", "client_id=", "client_secret=");
assertThat(body).doesNotContain("client_credentials", "subject_token");
assertThat(request.getFirstHeader("Authorization")).isNull();
assertThat(request.getURI().toString()).isEqualTo(config.getTokenEndpointUrl());
respond(new JSONObject());
refuse("invalid_client");
assertThat(run("token_broker").getStatus()).isEqualTo(HealthStatus.DOWN);
refuse("invalid_grant");
assertThat(run("token_broker").getStatus()).isEqualTo(HealthStatus.DOWN);
respond(new JSONObject().put("access_token", SECRET));
assertThat(run("token_broker").getStatus()).isEqualTo(HealthStatus.DOWN);
config.withTokenBrokerClientSecret(null);
FixtureActionUtil.REQUESTS.clear();
var outcome = run("token_broker");
@@ -223,6 +232,16 @@ class AuthentikHealthChecksTest
/*******************************************************************************
** Serve a token-endpoint refusal with a standard OAuth2 error code.
*******************************************************************************/
private void refuse(String errorCode)
{
FixtureActionUtil.handler = request -> FixtureActionUtil.response(400, new JSONObject().put("error", errorCode).toString());
}
/*******************************************************************************
** Both response bodies and thrown errors may echo every configured secret.
*******************************************************************************/