feat(session): session policy declared in host code, checked against the Authentik provider
AuthentikSessionPolicy states an idle timeout, an absolute maximum and the access-token lifetime. The config refuses numbers that cannot be enforced and an instance whose OAuth2 authentication does not carry them. The new authentik.session_policy health check reads the app's OAuth2 provider (validity settings and grant types only) and reports DEGRADED when it differs, DOWN when the service account cannot view it. Nothing is written to Authentik. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
1 parent
a3fa59efe7
commit
848378d3bc
10 files changed
+949
-3
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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,43 @@ 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(3600) // ends after an hour without a request
|
||||
.withAbsoluteMaximumSeconds(43200) // ends twelve hours after sign-in, however busy
|
||||
.withAccessTokenSeconds(600); // default; how often an active session is renewed
|
||||
|
||||
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.
|
||||
- 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 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
|
||||
|
||||
@@ -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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -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<String, BigDecimal> 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<String> problems()
|
||||
{
|
||||
List<String> 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<String> 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<String> differencesFrom(OAuth2AuthenticationMetaData metaData)
|
||||
{
|
||||
List<String> 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<String> differencesFrom(JSONObject provider)
|
||||
{
|
||||
List<String> 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<String> 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 + "}");
|
||||
}
|
||||
}
|
||||
@@ -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<JSONObject> getOAuth2ProviderValidity(String clientId) throws QException
|
||||
{
|
||||
runtime().getGuard().checkHealth();
|
||||
List<JSONObject> 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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ 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;
|
||||
@@ -69,13 +70,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<String> names = Boolean.TRUE.equals(config.getTokensEnabled())
|
||||
? List.of("api", "api_token", "mapper", "token_broker") : List.of("api", "api_token", "mapper");
|
||||
List<String> 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 +99,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 +243,40 @@ 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());
|
||||
|
||||
List<JSONObject> providers = util.getOAuth2ProviderValidity(config.getAppClientId());
|
||||
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<String> 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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -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<String> errors(AuthentikQBitConfig config, QInstance instance)
|
||||
{
|
||||
List<String> errors = new ArrayList<>();
|
||||
config.validate(instance, errors);
|
||||
return (errors);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* 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.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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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
|
||||
** 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 provider is invisible; 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()));
|
||||
|
||||
assertThat(client.getOAuth2ProviderValidity(CLIENT_ID)).as("no grant: the service account sees no provider").isEmpty();
|
||||
|
||||
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<JSONObject> 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);
|
||||
}
|
||||
assertThat(client.getOAuth2ProviderValidity(CLIENT_ID)).as("grant removed").isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -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,93 @@ 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 + "\"}");
|
||||
assertThat(run("session_policy").getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
|
||||
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.
|
||||
*******************************************************************************/
|
||||
|
||||
Reference in new issue
Block a user