diff --git a/CHANGELOG.md b/CHANGELOG.md index 5be75eb..788b308 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikActionUtil.java b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikActionUtil.java index f77b189..77f66e4 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikActionUtil.java +++ b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikActionUtil.java @@ -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)); } diff --git a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikTokenBroker.java b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikTokenBroker.java index ee33b54..1fb93cf 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikTokenBroker.java +++ b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikTokenBroker.java @@ -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 + ")")); } } } diff --git a/src/main/java/com/kingsrook/qbits/authentik/client/BrokerRefusedException.java b/src/main/java/com/kingsrook/qbits/authentik/client/BrokerRefusedException.java new file mode 100644 index 0000000..3ed1d90 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/client/BrokerRefusedException.java @@ -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); + } +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/health/AuthentikHealthChecks.java b/src/main/java/com/kingsrook/qbits/authentik/health/AuthentikHealthChecks.java index a403ef1..20046d7 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/health/AuthentikHealthChecks.java +++ b/src/main/java/com/kingsrook/qbits/authentik/health/AuthentikHealthChecks.java @@ -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")); } } diff --git a/src/test/java/com/kingsrook/qbits/authentik/health/AuthentikHealthChecksTest.java b/src/test/java/com/kingsrook/qbits/authentik/health/AuthentikHealthChecksTest.java index 8bdd776..0c7b82d 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/health/AuthentikHealthChecksTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/health/AuthentikHealthChecksTest.java @@ -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. *******************************************************************************/