fix(session): Authentik answers 403 when the account may view no provider
maven / build (push) Successful in 11m39s
build / maven (push) Successful in 11m39s

Seen in the contract build on Authentik 2026.8.3: GET /providers/oauth2/ is
refused (403), not empty, for a service account with no provider permission.
The health check reports that as DOWN (unverified) with the grant to add, and
the contract test expects the refusal before and after the temporary grant.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
james.maesandClaude Fable 5.2 committed 2026-11-10 11:40:03 -06:00
1 parent 848378d3bc
commit 5bf86725eb
4 files changed
+30 -7

No files matched your search

+1 -1
View File
@@ -202,7 +202,7 @@ config.withAppClientId("my-app").withSessionPolicy(policy);
timeout under twice the access token, a maximum not longer than the idle timeout or over 90 days.
- The health check needs the service account to view the app's provider and nothing else: grant
`authentik_providers_oauth2.view_oauth2provider` as an object permission on that one provider (never
model-wide: the provider API returns client secrets). Without it the check is DOWN ("unverified");
model-wide: the provider API returns client secrets). Without it Authentik answers 403 and the check is DOWN ("unverified");
a provider that differs is DEGRADED and the message names each setting in seconds.
## In-app help
@@ -32,6 +32,7 @@ import com.kingsrook.qbits.authentik.client.AuthentikTokenBroker;
import com.kingsrook.qbits.authentik.drift.AuthentikDriftReport;
import com.kingsrook.qbits.authentik.drift.DriftFinding;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.health.HealthCategory;
import com.kingsrook.qqq.backend.core.health.HealthCheckKind;
import com.kingsrook.qqq.backend.core.health.HealthCheckMetaData;
@@ -257,7 +258,19 @@ public final class AuthentikHealthChecks
.withValue("absoluteMaximumSeconds", policy.getAbsoluteMaximumSeconds())
.withValue("renewEverySeconds", policy.getAccessTokenSeconds());
List<JSONObject> providers = util.getOAuth2ProviderValidity(config.getAppClientId());
///////////////////////////////////////////////////////////////////////////////////
// Authentik answers 403 to an account that may view no provider at all, and an //
// empty list to one that may view others but not this one: both are unverified //
///////////////////////////////////////////////////////////////////////////////////
List<JSONObject> providers;
try
{
providers = util.getOAuth2ProviderValidity(config.getAppClientId());
}
catch(QPermissionDeniedException e)
{
providers = List.of();
}
if(providers.size() != 1)
{
return (HealthCheckOutcome.down(providers.isEmpty()
@@ -25,6 +25,7 @@ import com.kingsrook.qbits.authentik.AuthentikTestSupport;
import com.kingsrook.qbits.authentik.RequiresDockerCondition;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.module.api.model.metadata.APIBackendMetaData;
import org.json.JSONArray;
@@ -34,12 +35,13 @@ import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** The session-policy comparison against a real Authentik: what the provider
** API returns for the validity settings, and that the service account reads
** exactly its own app's provider with an object permission and nothing
** exactly its own app's provider with an object permission and is refused
** without one (the least-privilege grant the health check documents).
*******************************************************************************/
@ExtendWith(RequiresDockerCondition.class)
@@ -64,7 +66,7 @@ class SessionPolicyContractIT
/*******************************************************************************
** Without the grant the provider is invisible; with it, the three validity
** Without the grant the read is refused; with it, the three validity
** settings and the grant types come back and compare in seconds.
*******************************************************************************/
@Test
@@ -80,7 +82,11 @@ class SessionPolicyContractIT
AuthentikActionUtil client = new AuthentikActionUtil();
client.setBackendMetaData((APIBackendMetaData) instance.getBackend(config.getApiBackendName()));
assertThat(client.getOAuth2ProviderValidity(CLIENT_ID)).as("no grant: the service account sees no provider").isEmpty();
///////////////////////////////////////////////////////////////////////////////
// seen on 2026.8.3: an account that may view no provider at all gets a 403, //
// not an empty list (the health check reports both as "unverified") //
///////////////////////////////////////////////////////////////////////////////
assertThatThrownBy(() -> client.getOAuth2ProviderValidity(CLIENT_ID)).as("no grant: refused").isInstanceOf(QPermissionDeniedException.class);
String role = admin.first("/rbac/roles/?name=" + AuthentikTestStack.SERVICE_ACCOUNT).getString("pk");
String pk = String.valueOf(admin.first("/providers/oauth2/?client_id=" + CLIENT_ID).get("pk"));
@@ -111,6 +117,6 @@ class SessionPolicyContractIT
{
assertThat(admin.patch("/rbac/permissions/assigned_by_roles/" + role + "/unassign/", grant).status()).isEqualTo(204);
}
assertThat(client.getOAuth2ProviderValidity(CLIENT_ID)).as("grant removed").isEmpty();
assertThatThrownBy(() -> client.getOAuth2ProviderValidity(CLIENT_ID)).as("grant removed: refused again").isInstanceOf(QPermissionDeniedException.class);
}
}
@@ -197,7 +197,11 @@ class AuthentikHealthChecksTest
page(provider, provider);
assertThat(run("session_policy").getStatus()).isEqualTo(HealthStatus.DOWN);
FixtureActionUtil.handler = request -> FixtureActionUtil.response(403, "{\"detail\":\"" + SECRET + "\"}");
assertThat(run("session_policy").getStatus()).isEqualTo(HealthStatus.DOWN);
var forbidden = run("session_policy");
assertThat(forbidden.getStatus()).isEqualTo(HealthStatus.DOWN);
assertThat(forbidden.getMessage()).contains("view_oauth2provider");
FixtureActionUtil.handler = request -> FixtureActionUtil.response(500, "{}");
assertThat(run("session_policy").getMessage()).isEqualTo("Authentik health check failed");
assertThat(FixtureActionUtil.REQUESTS).isNotEmpty().allSatisfy(request ->
{