diff --git a/CHANGELOG.md b/CHANGELOG.md index e5dba39..c7b7461 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] +- **Session policy** (README "Session policy"): `AuthentikSessionPolicy` states an idle timeout, an absolute maximum and the access-token lifetime in host code (`AuthentikQBitConfig.withSessionPolicy`, with `appClientId`). Numbers that cannot be enforced, missing refresh tokens or `offline_access`, and a `maxSessionLifetimeSeconds` that differs are configuration errors; `applyTo(OAuth2AuthenticationMetaData)` sets the maximum. The new `authentik.session_policy` health check (hourly, only when a policy is stated) reads the app's OAuth2 provider (`GET /providers/oauth2/?client_id=`, validity settings and grant types only) and is DEGRADED when `access_token_validity`, `refresh_token_validity` or `refresh_token_threshold` differ, DOWN when the service account cannot view the provider. Nothing is written to Authentik. The service-account role needs `view_oauth2provider` as an object permission on the app's provider only. `SessionPolicyContractIT` proves the read and the grant on Authentik 2026.8.3. No schema change; hosts that state no policy are unaffected. - **Policy format 1.1.0** (docs/POLICY-FORMAT.md): `Deny` statements, prefix resources (`utils_*`), named permissions (`Permission: [...]`, dot-separated segments of `[A-Za-z0-9_-]`) and unknown statement keys now reject the document. Result = union of Allow minus union of Deny across all of a user's groups; a Deny anywhere wins, with no ordering. `Deny` with `Action ["*"]` covers every action including features. Every Allow expands against one live universe (core available permissions, the qbit catalog including hidden tables and processes, host names, identity names); a standard action a resource lacks is dropped. **Breaking:** the default `PolicyVersion` is `1.1.0` (hosts keeping `1.0.0` documents set `policyVersion("1.0.0")`); `PolicyParser.parse`/`parseStrict` return typed `PolicyRules` instead of a set of names (there is no plain-name view of a Deny or a prefix); `GroupGraph.Node.policyPermissions()` is now `policy()`; **one invalid policy among a user's groups voids all of them** (previously only the bad group was dropped); the example claim blueprint voids the claim on any invalid document under the configured attribute, a differently-specced one included (skipping other specs is an explicit opt-in). Editor: removing or narrowing a Deny, removing a member from a group that carries one, and removing a group from a parent that carries one are grants under the subset rule; saving rejects a prefix, named permission or Deny that matches nothing; canonicalization never drops a Deny. `PolicyPermissions.registerHostPermissions(fn)` declares host names without any Authentik runtime; the catalog and universe are cached per `QInstance` (`PolicyCatalog.clearCache()`). One shared rule (`SessionPermissionRules`) for sign-in, automated sessions and the refreshing mapper: a Deny subtracts policy grants, the mirror's stored permissions, static fallbacks, the host source and earlier session permissions, also while the cut-over guard blocks (only bootstrap identity permissions are exempt); an invalid policy or a missing group/parent leaves break-glass only even while blocked; an unreadable graph/mirror with no claim leaves only the configured outage fallbacks; a guard error keeps the last known real state (never OFF, original time, 15 minute bound); the refreshing mapper reads Deny rules from the mirrored group rows on the same connection and refuses an inconsistent or truncated snapshot; the mirror stores a valid policy document unredacted (an invalid one is redacted like any attribute); bootstrap operators keep their identity permissions even when their own group is void or denies them; a token with no groups claim and no policy claim is unavailable, an empty groups array is known-empty; a missing parent voids; documents with an unknown top-level key or no `Statement` are rejected; the group fingerprint covers the configured policy attribute; the refreshing mapper reads Deny rules from the mirror (no API call). Review fixes: operators are credited with a floor permission only if their own policies do not deny it; the configured policy attribute is reserved against Edit Group/User attributes; un-voiding an invalid policy is a release; a Deny a person keeps deliberately no longer trips the cut-over guard; a Deny of a prefix or named resource also covers its dotted host names; empty Resource/Action/Permission arrays and malformed host names are rejected; Save Policy requires `expectedFingerprint`; `PolicyResult` / `resultFromClaims` / `resolveResult` replace the deprecated plain-set APIs. Golden vectors: `src/test/resources/policy-vectors-1.1.0.json`. - Fix: native **Create Group** now obeys the two-person gate. With `twoPersonApproval=true`, creating a group whose permissions expand to any `authentik.*` permission queues an `authentik_approval_request` for a second person instead of persisting the grant immediately; the same identity-permission expansion and second-person condition as Edit Group apply, nothing is written before approval, self-approval is refused and a decided request cannot be approved again. Previously a new group with `authentik.users.read` was created without approval. - **Breaking (security):** `AuthentikMaintenance.applyPolicy` runs as the host's maintenance service principal (QQQ service identities, qqq `docs/SERVICE-IDENTITY.md`; config `maintenancePrincipal`, default `maintenance`). The self-made `MaintenanceSession`, which granted itself `policies.manage` and the document's grants, is gone; under OAuth2 it was rejected anyway (its audit insert failed). The principal's Authentik service account must hold `policies.manage` and every grant it applies, and the subset rule still holds. The audit operator is `service:{principal}`; the job name (`run-once:`/`maintenance:`) prefixes the reason. Hosts declare the principal: `ServiceIdentitiesMetaData.declare(qInstance, new QServicePrincipalMetaData().withName("maintenance"))`. Needs the QQQ release with service identities (qqq#1255). diff --git a/README.md b/README.md index 5fe090a..6641263 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,7 @@ version, expiry-day, and finding-code details. | `authentik.api_token` | DEPENDENCY | 21600 seconds | | `authentik.mapper` | DEPENDENCY | 21600 seconds | | `authentik.token_broker` | DEPENDENCY | 300 seconds | +| `authentik.session_policy` (only with a `sessionPolicy`) | DEPENDENCY | 3600 seconds | The broker check uses a client-credentials grant and discards the returned bearer; it reports UNKNOWN when broker credentials are not configured. Token metadata must @@ -167,6 +168,46 @@ The Identity home links its central status to System Health. The configuration These registrations use the core health SPI released in QQQ 4.1.0-RC.8; no SNAPSHOT override or enforcer skip is needed. +## Session policy (idle timeout and absolute maximum) + +A host states in code how long a sign-in lasts. The qbit validates the numbers, checks that the +instance's OAuth2 authentication carries them, and compares them with the Authentik provider. It +never writes them to Authentik: the provider's own configuration (a blueprint) states the matching +token validities, and the `authentik.session_policy` health check reports any difference. + +```java +AuthentikSessionPolicy policy = new AuthentikSessionPolicy() + .withIdleTimeoutSeconds(900) // ends after 15 minutes without a request + .withAbsoluteMaximumSeconds(43200) // ends twelve hours after sign-in, however busy + .withAccessTokenSeconds(300); // how often an active session is renewed (default 600) + +policy.applyTo(oauth2MetaData); // sets maxSessionLifetimeSeconds; needs refresh tokens and offline_access +config.withAppClientId("my-app").withSessionPolicy(policy); +``` + +| Policy | Enforced by | Authentik provider setting it must equal | +| --- | --- | --- | +| `accessTokenSeconds` | Authentik | `access_token_validity` | +| `idleTimeoutSeconds` | Authentik (refresh token expiry, restarted at every renewal) | `refresh_token_validity`, with `refresh_token_threshold: seconds=0` | +| `absoluteMaximumSeconds` | QQQ core (`maxSessionLifetimeSeconds`) | none | + +- Renewal is QQQ core's server-side refresh token (`docs/misc/OAuth2RefreshTokens.adoc` in qqq): the + host needs the refresh-token table, an encryption key, `refreshTokensEnabled` and the + `offline_access` scope. `applyTo` and the config validation refuse an instance without the last two. +- Precision is one access token: a session ends after between `idle - accessToken` and `idle` without + a request, and at most `absoluteMaximum + accessToken` after sign-in. +- This idle timeout counts requests, not user actions, so by itself it is not an automatic logoff after user + inactivity (HIPAA 164.312(a)(2)(iii), HITRUST session time-out): the host needs QQQ core's user inactivity + timeout for that, and this refresh-token validity is then the outer bound. +- Any request counts as use. A page that polls or a device that sends heartbeats is not idle; only the + absolute maximum ends such a session. +- Numbers that cannot be enforced are configuration errors: `accessTokenSeconds` under 120, an idle + 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 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 The qbit ships `src/main/resources/help/qbit-authentik.yaml` and registers it with diff --git a/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitConfig.java b/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitConfig.java index 711b523..62516a3 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitConfig.java @@ -28,6 +28,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.kingsrook.qbits.authentik.policy.PolicyFormat; import com.kingsrook.qqq.backend.core.instances.QMetaDataVariableInterpreter; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.authentication.OAuth2AuthenticationMetaData; import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig; import com.kingsrook.qqq.backend.core.utils.StringUtils; @@ -71,6 +72,7 @@ public class AuthentikQBitConfig implements QBitConfig private String appPublicUrl = null; private String authentikPublicUrl = null; private String appClientId = null; + private AuthentikSessionPolicy sessionPolicy = null; private Integer tokenUnusedDays = 60; private String serviceAccountTokenLifetime = "days=365"; private com.kingsrook.qbits.authentik.client.AuthentikSubjectTokenSource subjectTokenSource = new com.kingsrook.qbits.authentik.client.OAuth2SubjectTokenSource(); @@ -282,6 +284,28 @@ public class AuthentikQBitConfig implements QBitConfig { errors.add("throttleRequestsPerSecond must be at least 1"); } + + //////////////////////////////////////////////////////////////////////////////////// + // a stated session policy must be one the instance really enforces: the numbers // + // make sense, the app's provider can be found, and the OAuth2 authentication // + // carries the absolute maximum and can renew (refresh tokens, offline_access). // + //////////////////////////////////////////////////////////////////////////////////// + if(sessionPolicy != null) + { + errors.addAll(sessionPolicy.problems()); + if(!StringUtils.hasContent(getAppClientId())) + { + errors.add("sessionPolicy needs appClientId (the OAuth2 client id of the app's Authentik provider)"); + } + if(qInstance != null && qInstance.getAuthentication() instanceof OAuth2AuthenticationMetaData oauth2) + { + errors.addAll(sessionPolicy.differencesFrom(oauth2)); + } + else + { + errors.add("sessionPolicy needs the instance's OAuth2 authentication to be set before the qbit is produced"); + } + } } @@ -545,6 +569,7 @@ public class AuthentikQBitConfig implements QBitConfig + ", appPublicUrl=" + appPublicUrl + ", authentikPublicUrl=" + authentikPublicUrl + ", appClientId=" + appClientId + + ", sessionPolicy=" + sessionPolicy + ", tokenUnusedDays=" + tokenUnusedDays + ", serviceAccountTokenLifetime=" + serviceAccountTokenLifetime + ", features=" + features + ", readOnly=" + readOnly + ", apiToken=" + (StringUtils.hasContent(apiToken) ? "******" : "(unset)") + "}"); @@ -1897,6 +1922,38 @@ public class AuthentikQBitConfig implements QBitConfig + /******************************************************************************* + ** Getter for sessionPolicy: how long a sign-in lasts (null = not stated; the + ** qbit then neither validates nor health-checks session lifetime). + *******************************************************************************/ + public AuthentikSessionPolicy getSessionPolicy() + { + return (this.sessionPolicy); + } + + + + /******************************************************************************* + ** Setter for sessionPolicy. + *******************************************************************************/ + public void setSessionPolicy(AuthentikSessionPolicy sessionPolicy) + { + this.sessionPolicy = sessionPolicy; + } + + + + /******************************************************************************* + ** Fluent setter for sessionPolicy. + *******************************************************************************/ + public AuthentikQBitConfig withSessionPolicy(AuthentikSessionPolicy sessionPolicy) + { + this.sessionPolicy = sessionPolicy; + return (this); + } + + + /******************************************************************************* ** Getter for tokenUnusedDays. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/authentik/AuthentikSessionPolicy.java b/src/main/java/com/kingsrook/qbits/authentik/AuthentikSessionPolicy.java new file mode 100644 index 0000000..ff2628b --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/AuthentikSessionPolicy.java @@ -0,0 +1,346 @@ +/* + * 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; + + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import com.kingsrook.qqq.backend.core.model.metadata.authentication.OAuth2AuthenticationMetaData; +import org.json.JSONArray; +import org.json.JSONObject; + + +/******************************************************************************* + ** How long a sign-in lasts, declared by the host application in code. + ** + ** Three numbers, all in seconds: + ** - accessTokenSeconds: how long one provider access token lasts. An active + ** session is renewed (QQQ core's server-side refresh token) each time it + ** runs out, so this is also the precision of the other two. + ** - idleTimeoutSeconds: a session nobody uses for this long ends. It is the + ** provider's refresh-token validity, which restarts at every renewal. A + ** session ends after between (idle - access token) and idle of no requests, + ** never later. + ** - absoluteMaximumSeconds: no renewal this long after sign-in, however busy + ** the session is; it then ends with its current access token, so at most + ** (maximum + access token) after sign-in. + ** + ** The policy is not written to Authentik. The provider states the matching + ** token validities in its own configuration (a blueprint), and the + ** authentik.session_policy health check compares the two (differencesFrom). + ** A request counts as use whoever made it: a page that polls or a device that + ** sends heartbeats is not idle. + *******************************************************************************/ +public class AuthentikSessionPolicy +{ + public static final int DEFAULT_ACCESS_TOKEN_SECONDS = 600; + public static final int MIN_ACCESS_TOKEN_SECONDS = 120; + public static final int MAX_ABSOLUTE_SECONDS = 90 * 86400; + + private static final String OFFLINE_ACCESS = "offline_access"; + + private static final Map UNIT_SECONDS = Map.of( + "microseconds", new BigDecimal("0.000001"), + "milliseconds", new BigDecimal("0.001"), + "seconds", BigDecimal.ONE, + "minutes", BigDecimal.valueOf(60), + "hours", BigDecimal.valueOf(3600), + "days", BigDecimal.valueOf(86400), + "weeks", BigDecimal.valueOf(604800)); + + private Integer idleTimeoutSeconds; + private Integer absoluteMaximumSeconds; + private Integer accessTokenSeconds = DEFAULT_ACCESS_TOKEN_SECONDS; + + + + /******************************************************************************* + ** What is wrong with these numbers; empty when the policy can be enforced. + ** Messages name settings and never repeat a secret (there is none here). + *******************************************************************************/ + public List problems() + { + List problems = new ArrayList<>(); + if(idleTimeoutSeconds == null || absoluteMaximumSeconds == null || accessTokenSeconds == null) + { + problems.add("sessionPolicy needs idleTimeoutSeconds, absoluteMaximumSeconds and accessTokenSeconds"); + return (problems); + } + if(accessTokenSeconds < MIN_ACCESS_TOKEN_SECONDS) + { + problems.add("sessionPolicy.accessTokenSeconds must be at least " + MIN_ACCESS_TOKEN_SECONDS + " (QQQ renews a token in its last 60 seconds)"); + } + if((long) idleTimeoutSeconds < 2L * accessTokenSeconds) + { + problems.add("sessionPolicy.idleTimeoutSeconds must be at least twice accessTokenSeconds (a session can end one access token early)"); + } + if(absoluteMaximumSeconds <= idleTimeoutSeconds) + { + problems.add("sessionPolicy.absoluteMaximumSeconds must be longer than idleTimeoutSeconds"); + } + if(absoluteMaximumSeconds > MAX_ABSOLUTE_SECONDS) + { + problems.add("sessionPolicy.absoluteMaximumSeconds must be at most " + MAX_ABSOLUTE_SECONDS + " (90 days)"); + } + return (problems); + } + + + + /******************************************************************************* + ** Put the absolute maximum on the host's OAuth2 meta-data. Refuses meta-data + ** that cannot keep a session alive: refresh tokens off, or no offline_access + ** scope (Authentik issues a refresh token only for that scope). + *******************************************************************************/ + public void applyTo(OAuth2AuthenticationMetaData metaData) + { + List problems = problems(); + if(metaData == null) + { + problems.add("sessionPolicy needs OAuth2 authentication meta-data"); + } + else + { + metaData.setMaxSessionLifetimeSeconds(absoluteMaximumSeconds); + problems.addAll(differencesFrom(metaData)); + } + if(!problems.isEmpty()) + { + throw (new IllegalStateException(String.join("; ", problems))); + } + } + + + + /******************************************************************************* + ** Where the host's OAuth2 meta-data does not carry this policy. + *******************************************************************************/ + public List differencesFrom(OAuth2AuthenticationMetaData metaData) + { + List differences = new ArrayList<>(); + if(!Boolean.TRUE.equals(metaData.getRefreshTokensEnabled())) + { + differences.add("sessionPolicy needs refreshTokensEnabled on the OAuth2 authentication"); + } + String scopes = metaData.getScopes(); + if(scopes == null || !Arrays.asList(scopes.trim().split("\\s+")).contains(OFFLINE_ACCESS)) + { + differences.add("sessionPolicy needs the offline_access scope on the OAuth2 authentication"); + } + if(absoluteMaximumSeconds != null && !absoluteMaximumSeconds.equals(metaData.getMaxSessionLifetimeSeconds())) + { + differences.add("sessionPolicy.absoluteMaximumSeconds is not the OAuth2 authentication's maxSessionLifetimeSeconds (call applyTo)"); + } + return (differences); + } + + + + /******************************************************************************* + ** Where an Authentik OAuth2 provider (its API object) does not match this + ** policy; empty when it does. Only the validity settings and the grant types + ** are read; the texts carry durations, never another field of the provider. + *******************************************************************************/ + public List differencesFrom(JSONObject provider) + { + List differences = new ArrayList<>(); + compare(differences, provider, "access_token_validity", accessTokenSeconds, "access token lifetime"); + compare(differences, provider, "refresh_token_validity", idleTimeoutSeconds, "idle timeout"); + compare(differences, provider, "refresh_token_threshold", 0, "renewal of the refresh token at every use"); + + JSONArray grantTypes = provider.optJSONArray("grant_types"); + if(grantTypes != null && !grantTypes.toList().contains("refresh_token")) + { + differences.add("grant_types lacks refresh_token"); + } + return (differences); + } + + + + /******************************************************************************* + ** One validity setting against the seconds the policy needs. + *******************************************************************************/ + private static void compare(List differences, JSONObject provider, String field, Integer wanted, String meaning) + { + Long actual = parseValiditySeconds(provider.optString(field, null)); + if(actual == null) + { + differences.add(field + " is missing or unreadable (" + meaning + " needs " + wanted + " s)"); + } + else if(wanted == null || actual != wanted.longValue()) + { + differences.add(field + " is " + actual + " s; the " + meaning + " needs " + wanted + " s"); + } + } + + + + /******************************************************************************* + ** Whole seconds of an Authentik duration ("hours=1;minutes=2;seconds=3"), read + ** the way Authentik reads it (unknown units are skipped); null when it has no + ** usable part, is negative or is not a whole number of seconds. + *******************************************************************************/ + public static Long parseValiditySeconds(String expression) + { + if(expression == null) + { + return (null); + } + BigDecimal total = BigDecimal.ZERO; + boolean any = false; + for(String pair : expression.split(";")) + { + String[] parts = pair.split("="); + if(parts.length != 2) + { + return (null); + } + BigDecimal unit = UNIT_SECONDS.get(parts[0].trim().toLowerCase(Locale.ROOT)); + if(unit == null) + { + continue; + } + try + { + total = total.add(new BigDecimal(parts[1].trim()).multiply(unit)); + any = true; + } + catch(NumberFormatException e) + { + return (null); + } + } + try + { + return (any && total.signum() >= 0 ? (Long) total.longValueExact() : null); + } + catch(ArithmeticException e) + { + return (null); + } + } + + + + /******************************************************************************* + ** Getter for idleTimeoutSeconds. + *******************************************************************************/ + public Integer getIdleTimeoutSeconds() + { + return (this.idleTimeoutSeconds); + } + + + + /******************************************************************************* + ** Setter for idleTimeoutSeconds. + *******************************************************************************/ + public void setIdleTimeoutSeconds(Integer idleTimeoutSeconds) + { + this.idleTimeoutSeconds = idleTimeoutSeconds; + } + + + + /******************************************************************************* + ** Fluent setter for idleTimeoutSeconds. + *******************************************************************************/ + public AuthentikSessionPolicy withIdleTimeoutSeconds(Integer idleTimeoutSeconds) + { + this.idleTimeoutSeconds = idleTimeoutSeconds; + return (this); + } + + + + /******************************************************************************* + ** Getter for absoluteMaximumSeconds. + *******************************************************************************/ + public Integer getAbsoluteMaximumSeconds() + { + return (this.absoluteMaximumSeconds); + } + + + + /******************************************************************************* + ** Setter for absoluteMaximumSeconds. + *******************************************************************************/ + public void setAbsoluteMaximumSeconds(Integer absoluteMaximumSeconds) + { + this.absoluteMaximumSeconds = absoluteMaximumSeconds; + } + + + + /******************************************************************************* + ** Fluent setter for absoluteMaximumSeconds. + *******************************************************************************/ + public AuthentikSessionPolicy withAbsoluteMaximumSeconds(Integer absoluteMaximumSeconds) + { + this.absoluteMaximumSeconds = absoluteMaximumSeconds; + return (this); + } + + + + /******************************************************************************* + ** Getter for accessTokenSeconds. + *******************************************************************************/ + public Integer getAccessTokenSeconds() + { + return (this.accessTokenSeconds); + } + + + + /******************************************************************************* + ** Setter for accessTokenSeconds. + *******************************************************************************/ + public void setAccessTokenSeconds(Integer accessTokenSeconds) + { + this.accessTokenSeconds = accessTokenSeconds; + } + + + + /******************************************************************************* + ** Fluent setter for accessTokenSeconds. + *******************************************************************************/ + public AuthentikSessionPolicy withAccessTokenSeconds(Integer accessTokenSeconds) + { + this.accessTokenSeconds = accessTokenSeconds; + return (this); + } + + + + /******************************************************************************* + ** The three numbers, for logs and config dumps (no secret is held here). + *******************************************************************************/ + @Override + public String toString() + { + return ("AuthentikSessionPolicy{idleTimeoutSeconds=" + idleTimeoutSeconds + ", absoluteMaximumSeconds=" + absoluteMaximumSeconds + + ", accessTokenSeconds=" + accessTokenSeconds + "}"); + } +} 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 1fd2642..cd66c78 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikActionUtil.java +++ b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikActionUtil.java @@ -217,6 +217,34 @@ public class AuthentikActionUtil extends BaseAPIActionUtil + /******************************************************************************* + ** The validity settings and grant types of the OAuth2 providers with this + ** client id (one, when the service account may view the app's provider; none + ** otherwise). Every other field of the provider, its client secret included, + ** is dropped here: it is never returned to a caller, and response bodies are + ** not logged. Health probes (the only caller) also bypass the response cache. + *******************************************************************************/ + public List getOAuth2ProviderValidity(String clientId) throws QException + { + runtime().getGuard().checkHealth(); + List rs = new ArrayList<>(); + for(JSONObject provider : getAllPages("/providers/oauth2/?client_id=" + java.net.URLEncoder.encode(clientId, StandardCharsets.UTF_8), 2)) + { + JSONObject validity = new JSONObject(); + for(String field : List.of("access_token_validity", "refresh_token_validity", "refresh_token_threshold", "grant_types")) + { + if(provider.has(field) && !provider.isNull(field)) + { + validity.put(field, provider.get(field)); + } + } + rs.add(validity); + } + return (rs); + } + + + /******************************************************************************* ** Broker credentials use the same secret-safe request loop as token exchange. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikApiSurface.java b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikApiSurface.java index f25f881..1c0ac42 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikApiSurface.java +++ b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikApiSurface.java @@ -82,6 +82,7 @@ public final class AuthentikApiSurface rs.add(new Operation("get", "/events/events/", Set.of("username", "action", "ordering", "page", "page_size"))); rs.add(new Operation("get", "/events/events/stats/", Set.of("username", "action", "count_steps"))); rs.add(new Operation("get", "/authenticators/admin/all/", Set.of("user"))); + rs.add(new Operation("get", "/providers/oauth2/", Set.of("client_id", "page", "page_size"))); return (rs); } 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 47bee06..f0cc03a 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/health/AuthentikHealthChecks.java +++ b/src/main/java/com/kingsrook/qbits/authentik/health/AuthentikHealthChecks.java @@ -25,12 +25,14 @@ import java.util.Locale; import java.util.TreeMap; import java.util.TreeSet; import com.kingsrook.qbits.authentik.AuthentikQBitConfig; +import com.kingsrook.qbits.authentik.AuthentikSessionPolicy; import com.kingsrook.qbits.authentik.client.AuthentikActionUtil; import com.kingsrook.qbits.authentik.client.AuthentikJson; 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; @@ -69,13 +71,24 @@ public final class AuthentikHealthChecks // the token broker is only used when tokens are on; with tokens off a registered check // // could only ever report UNKNOWN "not configured", and core cannot remove a registered check. // ///////////////////////////////////////////////////////////////////////////////////////////////// - List names = Boolean.TRUE.equals(config.getTokensEnabled()) - ? List.of("api", "api_token", "mapper", "token_broker") : List.of("api", "api_token", "mapper"); + List names = new ArrayList<>(List.of("api", "api_token", "mapper")); + if(Boolean.TRUE.equals(config.getTokensEnabled())) + { + names.add("token_broker"); + } + /////////////////////////////////////////////////////////////////////////////////// + // only a host that states a session policy has something to compare the // + // provider with; hourly, because a blueprint apply can change the provider. // + /////////////////////////////////////////////////////////////////////////////////// + if(config.getSessionPolicy() != null) + { + names.add("session_policy"); + } for(String name : names) { registry.withCheck(new HealthCheckMetaData().withName("authentik." + name).withLabel("Authentik " + name) .withComponent("authentik").withCategory(HealthCategory.IDENTITY).withKind(HealthCheckKind.DEPENDENCY) - .withIntervalSeconds(name.equals("api") || name.equals("token_broker") ? 300 : 21600) + .withIntervalSeconds(name.equals("api") || name.equals("token_broker") ? 300 : name.equals("session_policy") ? 3600 : 21600) .withTimeoutMs(10000).withCheckInstance(input -> { try @@ -87,6 +100,7 @@ public final class AuthentikHealthChecks case "api" -> outcome = api(util, config); case "api_token" -> outcome = apiToken(util, Instant.now()); case "mapper" -> outcome = mapper(util, config); + case "session_policy" -> outcome = sessionPolicy(util, config); default -> outcome = tokenBroker(util, config); } return (Instant.now().isBefore(input.getDeadline()) && !Thread.currentThread().isInterrupted() @@ -230,6 +244,52 @@ public final class AuthentikHealthChecks + /******************************************************************************* + ** The session policy the host states in code against what the app's Authentik + ** provider really does. Read only: a difference is reported, never corrected + ** here, because the provider is owned by its own configuration (a blueprint). + ** Not being able to read the provider is DOWN: the policy is then unverified. + *******************************************************************************/ + static HealthCheckOutcome sessionPolicy(AuthentikActionUtil util, AuthentikQBitConfig config) throws QException + { + AuthentikSessionPolicy policy = config.getSessionPolicy(); + HealthDetails details = new HealthDetails() + .withValue("idleTimeoutSeconds", policy.getIdleTimeoutSeconds()) + .withValue("absoluteMaximumSeconds", policy.getAbsoluteMaximumSeconds()) + .withValue("renewEverySeconds", policy.getAccessTokenSeconds()); + + /////////////////////////////////////////////////////////////////////////////////// + // 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 providers; + try + { + providers = util.getOAuth2ProviderValidity(config.getAppClientId()); + } + catch(QPermissionDeniedException e) + { + providers = List.of(); + } + if(providers.size() != 1) + { + return (HealthCheckOutcome.down(providers.isEmpty() + ? "Session policy unverified: the service account cannot view the app's OAuth2 provider (grant view_oauth2provider on that provider)" + : "Session policy unverified: more than one OAuth2 provider has the app's client id").withDetails(details)); + } + + List differences = policy.differencesFrom(providers.getFirst()); + if(differences.isEmpty()) + { + return (HealthCheckOutcome.up("Authentik provider matches the session policy").withDetails(details)); + } + String summary = truncate(String.join("; ", differences), 500); + return (HealthCheckOutcome.degraded(truncate("Authentik provider differs from the session policy: " + summary, 500)) + .withDetails(details.withValue("differences", summary))); + } + + + /******************************************************************************* ** A group name fit for a health message: printable and bounded. *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/authentik/AuthentikSessionPolicyTest.java b/src/test/java/com/kingsrook/qbits/authentik/AuthentikSessionPolicyTest.java new file mode 100644 index 0000000..a10d259 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/authentik/AuthentikSessionPolicyTest.java @@ -0,0 +1,221 @@ +/* + * 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; + + +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.authentication.OAuth2AuthenticationMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +/******************************************************************************* + ** The session policy fails closed on numbers it cannot enforce, and reports + ** every way a provider or the host's OAuth2 meta-data disagrees with it. + *******************************************************************************/ +class AuthentikSessionPolicyTest +{ + + /******************************************************************************* + ** One hour idle, twelve hours at most, ten-minute access tokens. + *******************************************************************************/ + private static AuthentikSessionPolicy policy() + { + return (new AuthentikSessionPolicy().withIdleTimeoutSeconds(3600).withAbsoluteMaximumSeconds(43200)); + } + + + + /******************************************************************************* + ** OAuth2 meta-data that can renew a session. + *******************************************************************************/ + private static OAuth2AuthenticationMetaData oauth2() + { + return (new OAuth2AuthenticationMetaData().withScopes("openid profile email offline_access").withRefreshTokensEnabled(true)); + } + + + + /******************************************************************************* + ** A provider that matches policy(). + *******************************************************************************/ + private static JSONObject provider() + { + return (new JSONObject().put("access_token_validity", "minutes=10").put("refresh_token_validity", "hours=1") + .put("refresh_token_threshold", "seconds=0").put("grant_types", new JSONArray(List.of("authorization_code", "refresh_token")))); + } + + + + /******************************************************************************* + ** The access token lifetime has a default; the two limits must be stated. + *******************************************************************************/ + @Test + void defaultsAndRequiredNumbers() + { + assertThat(policy().getAccessTokenSeconds()).isEqualTo(600); + assertThat(policy().problems()).isEmpty(); + assertThat(new AuthentikSessionPolicy().problems()).singleElement().asString().contains("idleTimeoutSeconds", "absoluteMaximumSeconds"); + assertThat(policy().withAccessTokenSeconds(null).problems()).hasSize(1); + assertThat(policy().toString()).contains("idleTimeoutSeconds=3600", "absoluteMaximumSeconds=43200", "accessTokenSeconds=600"); + } + + + + /******************************************************************************* + ** Zero, negative, inverted and absurd numbers are all refused. + *******************************************************************************/ + @Test + void nonsenseNumbersAreRefused() + { + assertThat(policy().withAccessTokenSeconds(119).problems()).anyMatch(p -> p.contains("accessTokenSeconds must be at least 120")); + assertThat(policy().withAccessTokenSeconds(0).problems()).isNotEmpty(); + assertThat(policy().withIdleTimeoutSeconds(-1).problems()).isNotEmpty(); + assertThat(policy().withIdleTimeoutSeconds(1199).problems()).anyMatch(p -> p.contains("at least twice accessTokenSeconds")); + assertThat(policy().withIdleTimeoutSeconds(1200).problems()).isEmpty(); + assertThat(policy().withAbsoluteMaximumSeconds(3600).problems()).anyMatch(p -> p.contains("longer than idleTimeoutSeconds")); + assertThat(policy().withAbsoluteMaximumSeconds(0).problems()).isNotEmpty(); + assertThat(policy().withAbsoluteMaximumSeconds(90 * 86400).problems()).isEmpty(); + assertThat(policy().withAbsoluteMaximumSeconds(90 * 86400 + 1).problems()).anyMatch(p -> p.contains("90 days")); + //////////////////////////////////////////////////////////////////////// + // milliseconds given where seconds are meant: twelve hours as 43.2M // + //////////////////////////////////////////////////////////////////////// + assertThat(policy().withAbsoluteMaximumSeconds(43_200_000).problems()).isNotEmpty(); + assertThat(policy().withIdleTimeoutSeconds(Integer.MAX_VALUE).withAccessTokenSeconds(Integer.MAX_VALUE).problems()).isNotEmpty(); + } + + + + /******************************************************************************* + ** applyTo sets the absolute maximum and refuses meta-data that cannot renew. + *******************************************************************************/ + @Test + void applyToOAuth2MetaData() + { + OAuth2AuthenticationMetaData metaData = oauth2(); + policy().applyTo(metaData); + assertThat(metaData.getMaxSessionLifetimeSeconds()).isEqualTo(43200); + assertThat(policy().differencesFrom(metaData)).isEmpty(); + + assertThatThrownBy(() -> policy().applyTo(oauth2().withScopes("openid profile email"))).isInstanceOf(IllegalStateException.class).hasMessageContaining("offline_access"); + assertThatThrownBy(() -> policy().applyTo(oauth2().withScopes(null))).hasMessageContaining("offline_access"); + assertThatThrownBy(() -> policy().applyTo(oauth2().withScopes("openid not_offline_access"))).hasMessageContaining("offline_access"); + assertThatThrownBy(() -> policy().applyTo(oauth2().withRefreshTokensEnabled(false))).hasMessageContaining("refreshTokensEnabled"); + assertThatThrownBy(() -> policy().applyTo(null)).hasMessageContaining("OAuth2 authentication meta-data"); + assertThatThrownBy(() -> policy().withAbsoluteMaximumSeconds(60).applyTo(oauth2())).hasMessageContaining("absoluteMaximumSeconds"); + assertThat(policy().differencesFrom(oauth2().withMaxSessionLifetimeSeconds(1))).singleElement().asString().contains("maxSessionLifetimeSeconds"); + } + + + + /******************************************************************************* + ** A stated policy the instance does not carry is a configuration error. + *******************************************************************************/ + @Test + void configValidationFailsClosed() + { + QInstance oauth = new QInstance(); + OAuth2AuthenticationMetaData metaData = oauth2(); + oauth.withInstanceDefaultAuthentication(metaData); + policy().applyTo(metaData); + assertThat(errors(AuthentikTestSupport.unitConfig().withAppClientId("my-app").withSessionPolicy(policy()), oauth)).isEmpty(); + assertThat(errors(AuthentikTestSupport.unitConfig().withSessionPolicy(policy()), oauth)).anyMatch(e -> e.contains("appClientId")); + assertThat(errors(AuthentikTestSupport.unitConfig().withAppClientId("my-app").withSessionPolicy(policy().withIdleTimeoutSeconds(1)), oauth)) + .anyMatch(e -> e.contains("idleTimeoutSeconds")); + + metaData.setMaxSessionLifetimeSeconds(null); + assertThat(errors(AuthentikTestSupport.unitConfig().withAppClientId("my-app").withSessionPolicy(policy()), oauth)).anyMatch(e -> e.contains("maxSessionLifetimeSeconds")); + + QInstance mock = new QInstance(); + mock.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK)); + assertThat(errors(AuthentikTestSupport.unitConfig().withAppClientId("my-app").withSessionPolicy(policy()), mock)).anyMatch(e -> e.contains("OAuth2 authentication")); + assertThat(errors(AuthentikTestSupport.unitConfig().withAppClientId("my-app").withSessionPolicy(policy()), null)).anyMatch(e -> e.contains("OAuth2 authentication")); + assertThat(errors(AuthentikTestSupport.unitConfig(), mock)).isEmpty(); + assertThat(AuthentikTestSupport.unitConfig().withAppClientId("my-app").withSessionPolicy(policy()).toString()).contains("sessionPolicy=AuthentikSessionPolicy{"); + } + + + + /******************************************************************************* + ** Every provider setting the policy depends on is compared, in seconds. + *******************************************************************************/ + @Test + void providerDifferences() + { + assertThat(policy().differencesFrom(provider())).isEmpty(); + assertThat(policy().differencesFrom(provider().put("access_token_validity", "seconds=600").put("refresh_token_validity", "minutes=60"))).isEmpty(); + + assertThat(policy().differencesFrom(provider().put("access_token_validity", "hours=8"))) + .containsExactly("access_token_validity is 28800 s; the access token lifetime needs 600 s"); + assertThat(policy().differencesFrom(provider().put("refresh_token_validity", "days=30"))) + .containsExactly("refresh_token_validity is 2592000 s; the idle timeout needs 3600 s"); + assertThat(policy().differencesFrom(provider().put("refresh_token_threshold", "hours=1"))) + .containsExactly("refresh_token_threshold is 3600 s; the renewal of the refresh token at every use needs 0 s"); + assertThat(policy().differencesFrom(provider().put("grant_types", new JSONArray(List.of("authorization_code"))))).containsExactly("grant_types lacks refresh_token"); + + JSONObject bare = provider(); + bare.remove("refresh_token_threshold"); + bare.remove("grant_types"); + assertThat(policy().differencesFrom(bare)).singleElement().asString().startsWith("refresh_token_threshold is missing or unreadable"); + assertThat(policy().differencesFrom(new JSONObject())).hasSize(3); + assertThat(policy().differencesFrom(provider().put("access_token_validity", "S3NT1NEL"))).singleElement().asString().doesNotContain("S3NT1NEL"); + } + + + + /******************************************************************************* + ** Durations are read the way Authentik reads them. + *******************************************************************************/ + @Test + void parseValiditySeconds() + { + assertThat(AuthentikSessionPolicy.parseValiditySeconds("hours=1;minutes=2;seconds=3")).isEqualTo(3723L); + assertThat(AuthentikSessionPolicy.parseValiditySeconds("days=30")).isEqualTo(2592000L); + assertThat(AuthentikSessionPolicy.parseValiditySeconds("weeks=1")).isEqualTo(604800L); + assertThat(AuthentikSessionPolicy.parseValiditySeconds("seconds=0")).isEqualTo(0L); + assertThat(AuthentikSessionPolicy.parseValiditySeconds("seconds=600.0")).isEqualTo(600L); + assertThat(AuthentikSessionPolicy.parseValiditySeconds("Minutes= 10 ")).isEqualTo(600L); + assertThat(AuthentikSessionPolicy.parseValiditySeconds("milliseconds=1000")).isEqualTo(1L); + assertThat(AuthentikSessionPolicy.parseValiditySeconds("fortnights=1;minutes=5")).isEqualTo(300L); + assertThat(AuthentikSessionPolicy.parseValiditySeconds("fortnights=1")).isNull(); + assertThat(AuthentikSessionPolicy.parseValiditySeconds("seconds=0.5")).isNull(); + assertThat(AuthentikSessionPolicy.parseValiditySeconds("minutes=-5")).isNull(); + assertThat(AuthentikSessionPolicy.parseValiditySeconds("minutes=ten")).isNull(); + assertThat(AuthentikSessionPolicy.parseValiditySeconds("minutes")).isNull(); + assertThat(AuthentikSessionPolicy.parseValiditySeconds("")).isNull(); + assertThat(AuthentikSessionPolicy.parseValiditySeconds(null)).isNull(); + } + + + + /******************************************************************************* + ** Config errors against a given instance. + *******************************************************************************/ + private static List errors(AuthentikQBitConfig config, QInstance instance) + { + List errors = new ArrayList<>(); + config.validate(instance, errors); + return (errors); + } +} diff --git a/src/test/java/com/kingsrook/qbits/authentik/contract/SessionPolicyContractIT.java b/src/test/java/com/kingsrook/qbits/authentik/contract/SessionPolicyContractIT.java new file mode 100644 index 0000000..cc770bc --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/authentik/contract/SessionPolicyContractIT.java @@ -0,0 +1,122 @@ +/* + * 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.contract; + + +import java.util.List; +import java.util.Set; +import com.kingsrook.qbits.authentik.AuthentikQBitConfig; +import com.kingsrook.qbits.authentik.AuthentikSessionPolicy; +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; +import org.json.JSONObject; +import org.junit.jupiter.api.AfterEach; +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 is refused + ** without one (the least-privilege grant the health check documents). + *******************************************************************************/ +@ExtendWith(RequiresDockerCondition.class) +@Order(65) +class SessionPolicyContractIT +{ + private static final String CLIENT_ID = "kof22-website-prod"; + private static final String PERMISSION = "authentik_providers_oauth2.view_oauth2provider"; + private static final String MODEL = "authentik_providers_oauth2.oauth2provider"; + + + + /******************************************************************************* + ** Leave no thread context behind. + *******************************************************************************/ + @AfterEach + void afterEach() + { + QContext.clear(); + } + + + + /******************************************************************************* + ** Without the grant the read is refused; with it, the three validity + ** settings and the grant types come back and compare in seconds. + *******************************************************************************/ + @Test + void providerValidityIsReadWithAnObjectPermissionOnly() throws Exception + { + AuthentikTestStack stack = AuthentikTestStack.get(); + TestApi admin = stack.admin(); + + AuthentikQBitConfig config = new AuthentikQBitConfig().withBaseUrl(stack.baseUrl()).withAllowInsecureHttp(true) + .withApiToken(stack.serviceAccountToken()).withAppKey("kof22-admin").withListCacheTtlSeconds(0); + QInstance instance = AuthentikTestSupport.produce(config); + AuthentikTestSupport.loginWithPermissions(instance, Set.of("health.read", "health.write")); + AuthentikActionUtil client = new AuthentikActionUtil(); + client.setBackendMetaData((APIBackendMetaData) instance.getBackend(config.getApiBackendName())); + + /////////////////////////////////////////////////////////////////////////////// + // 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")); + JSONObject grant = new JSONObject().put("permissions", new JSONArray().put(PERMISSION)).put("model", MODEL).put("object_pk", pk); + assertThat(admin.post("/rbac/permissions/assigned_by_roles/" + role + "/assign/", grant).status()).isEqualTo(200); + try + { + List providers = client.getOAuth2ProviderValidity(CLIENT_ID); + assertThat(providers).as("object permission on one provider: exactly that provider").hasSize(1); + JSONObject provider = providers.getFirst(); + assertThat(provider.keySet()).containsExactlyInAnyOrder("access_token_validity", "refresh_token_validity", "refresh_token_threshold", "grant_types"); + + /////////////////////////////////////////////////////////////////////////////// + // the fixture provider states no validity, so these are Authentik's defaults // + /////////////////////////////////////////////////////////////////////////////// + assertThat(AuthentikSessionPolicy.parseValiditySeconds(provider.getString("access_token_validity"))).isEqualTo(3600L); + assertThat(AuthentikSessionPolicy.parseValiditySeconds(provider.getString("refresh_token_validity"))).isEqualTo(30L * 86400); + assertThat(AuthentikSessionPolicy.parseValiditySeconds(provider.getString("refresh_token_threshold"))).isEqualTo(0L); + + AuthentikSessionPolicy matching = new AuthentikSessionPolicy().withAccessTokenSeconds(3600).withIdleTimeoutSeconds(30 * 86400).withAbsoluteMaximumSeconds(60 * 86400); + assertThat(matching.differencesFrom(provider)).isEmpty(); + assertThat(new AuthentikSessionPolicy().withIdleTimeoutSeconds(3600).withAbsoluteMaximumSeconds(43200).differencesFrom(provider)) + .containsExactly("access_token_validity is 3600 s; the access token lifetime needs 600 s", "refresh_token_validity is 2592000 s; the idle timeout needs 3600 s"); + + assertThat(client.getOAuth2ProviderValidity("no-such-client")).isEmpty(); + } + finally + { + assertThat(admin.patch("/rbac/permissions/assigned_by_roles/" + role + "/unassign/", grant).status()).isEqualTo(204); + } + assertThatThrownBy(() -> client.getOAuth2ProviderValidity(CLIENT_ID)).as("grant removed: refused again").isInstanceOf(QPermissionDeniedException.class); + } +} 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 dda38d0..12cfb4d 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/health/AuthentikHealthChecksTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/health/AuthentikHealthChecksTest.java @@ -22,6 +22,7 @@ import java.util.List; import java.util.Set; import com.kingsrook.qbits.authentik.AuthentikQBitConfig; import com.kingsrook.qbits.authentik.AuthentikQBitProducer; +import com.kingsrook.qbits.authentik.AuthentikSessionPolicy; import com.kingsrook.qbits.authentik.AuthentikTestSupport; import com.kingsrook.qbits.authentik.client.AuthentikActionUtil; import com.kingsrook.qbits.authentik.client.FixtureActionUtil; @@ -35,7 +36,10 @@ import com.kingsrook.qqq.backend.core.health.HealthCheckKind; import com.kingsrook.qqq.backend.core.health.HealthCheckOutcome; import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData; import com.kingsrook.qqq.backend.core.health.HealthStatus; +import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.authentication.OAuth2AuthenticationMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData; import com.kingsrook.qqq.backend.module.api.actions.QHttpResponse; import org.apache.http.client.methods.HttpPost; import org.apache.http.client.methods.HttpRequestBase; @@ -144,6 +148,97 @@ class AuthentikHealthChecksTest + /******************************************************************************* + ** No session policy, no check; a stated one adds an hourly comparison. + *******************************************************************************/ + @Test + void sessionPolicyRegistration() throws QException + { + assertThat(HealthChecksMetaData.ofOrWithNew(instance).resolveAll(instance)).extracting(c -> c.getName()).doesNotContain("authentik.session_policy"); + useSessionPolicy(); + var check = HealthChecksMetaData.ofOrWithNew(instance).resolveAll(instance).stream().filter(c -> c.getName().equals("authentik.session_policy")).findFirst().orElseThrow(); + assertThat(check.getIntervalSeconds()).isEqualTo(3600); + assertThat(check.getKind()).isEqualTo(HealthCheckKind.DEPENDENCY); + assertThat(FixtureActionUtil.REQUESTS).isEmpty(); + } + + + + /******************************************************************************* + ** The provider is compared with the policy; it is read, never written, and + ** nothing but durations leaves the provider object (not its client secret). + *******************************************************************************/ + @Test + void sessionPolicyStates() throws QException + { + useSessionPolicy(); + JSONObject provider = new JSONObject().put("client_id", "my-app").put("client_secret", SECRET).put("name", SECRET) + .put("access_token_validity", "minutes=10").put("refresh_token_validity", "hours=1").put("refresh_token_threshold", "seconds=0") + .put("grant_types", new JSONArray(List.of("authorization_code", "refresh_token"))); + page(provider); + var up = run("session_policy"); + assertThat(up.getStatus()).isEqualTo(HealthStatus.UP); + assertThat(up.getDetails().getValues()).containsOnlyKeys("idleTimeoutSeconds", "absoluteMaximumSeconds", "renewEverySeconds") + .containsEntry("idleTimeoutSeconds", 3600).containsEntry("absoluteMaximumSeconds", 43200).containsEntry("renewEverySeconds", 600); + + page(provider.put("access_token_validity", "hours=8").put("refresh_token_validity", "days=30")); + var degraded = run("session_policy"); + assertThat(degraded.getStatus()).isEqualTo(HealthStatus.DEGRADED); + assertThat(degraded.getMessage()).contains("access_token_validity is 28800 s", "refresh_token_validity is 2592000 s"); + assertThat(degraded.getDetails().getValues()).containsKey("differences"); + + page(provider.put("refresh_token_validity", SECRET)); + assertThat(run("session_policy").getStatus()).isEqualTo(HealthStatus.DEGRADED); + + page(); + var unreadable = run("session_policy"); + assertThat(unreadable.getStatus()).isEqualTo(HealthStatus.DOWN); + assertThat(unreadable.getMessage()).contains("view_oauth2provider"); + page(provider, provider); + assertThat(run("session_policy").getStatus()).isEqualTo(HealthStatus.DOWN); + FixtureActionUtil.handler = request -> FixtureActionUtil.response(403, "{\"detail\":\"" + SECRET + "\"}"); + 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 -> + { + assertThat(request.getMethod()).isEqualTo("GET"); + assertThat(request.getURI().getPath()).endsWith("/providers/oauth2/"); + assertThat(request.getURI().getQuery()).contains("client_id=my-app"); + }); + } + + + + /******************************************************************************* + ** Re-produce the instance on OAuth2 authentication that carries the policy. + *******************************************************************************/ + private void useSessionPolicy() throws QException + { + AuthentikSessionPolicy policy = new AuthentikSessionPolicy().withIdleTimeoutSeconds(3600).withAbsoluteMaximumSeconds(43200); + OAuth2AuthenticationMetaData oauth2 = new OAuth2AuthenticationMetaData().withScopes("openid offline_access").withRefreshTokensEnabled(true); + oauth2.setName("oauth2"); + policy.applyTo(oauth2); + config.withAppClientId("my-app").withSessionPolicy(policy); + instance = new QInstance(); + instance.withInstanceDefaultAuthentication(oauth2); + new AuthentikQBitProducer().withQBitConfig(config).produce(instance); + AuthentikTestSupport.useActionUtil(instance, config, HealthActionUtil.class); + FixtureActionUtil.reset(); + FixtureActionUtil.handler = request -> FixtureActionUtil.response(200, FixtureActionUtil.EMPTY_PAGE); + ///////////////////////////////////////////////////////////////////////////////// + // the config was validated against the OAuth2 meta-data above; the probes run // + // under MOCK authentication, as every other test of this class does // + ///////////////////////////////////////////////////////////////////////////////// + instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK)); + AuthentikTestSupport.loginWithPermissions(instance, Set.of("health.read", "health.write")); + } + + + /******************************************************************************* ** Version responses are allowlisted before entering details. *******************************************************************************/