diff --git a/docs/STATUS.md b/docs/STATUS.md
index becda1a..c79bc67 100644
--- a/docs/STATUS.md
+++ b/docs/STATUS.md
@@ -12,6 +12,17 @@ The Authentik qbit's progress through DESIGN.md section 11, with links. Updated
| 3. Full user lifecycle | done, except the optional snapshot export |
| 4. RBAC role management | post-v1, not started |
| 5. Policies and full CRUD (DESIGN.md section 12) | 5a policy model (#14), 5b policy editor (#15), 5c user and group CRUD additions; the Next UI widget is a separate qqq-frontend-next PR |
+| 6d. Personal access token Preferences | implemented on `feature/token-preferences`; draft until a QQQ RC contains QRun-IO/qqq#1162 |
+
+### Phase 6d: Access tokens Preferences
+
+The `TOKENS` feature registers the `access-tokens` section and user-menu shortcut without an app or permission gate. The provider resolves the current Authentik operator and reads only their app passwords, with read-only metadata and optional scope inputs. Create, rotate, extend and revoke use `AuthentikAdmin`, including live protection/ownership checks, broker exchange and audit. Preferences uses core's one-time secret response independently of process delivery; keys never enter section values or local storage.
+
+This branch builds against the locally installed `4.1.0-PREFS-SNAPSHOT`. Keep any PR draft until the RC shipping #1162 is available, then replace the marked POM stand-in. Last-used tracking is not implemented in this branch, so the section omits it. Next UI renders the section and user-menu entry through the core Preferences contract.
+
+`AccessTokenPreferencesTest` exercises the core dispatcher with FakeAuthentik and an in-memory QInstance, including lifecycle, scoped rotation, ownership, protected-user/broker refusals, read-only validation, and searches of local tables, audit, reloaded section values, events and captured logs for leaked keys.
+
+Validation (2026-10-13): `./mvnw.cmd -q -o verify -DskipITs` passed on Windows: 253 tests, no failures/errors/skips; JaCoCo 91.48% instruction and 100% class coverage. Contract ITs were intentionally excluded.
### Built in this line (0.1)
diff --git a/pom.xml b/pom.xml
index 3702407..a63a1a1 100644
--- a/pom.xml
+++ b/pom.xml
@@ -245,7 +245,8 @@
true
- 4.1.0-RC.2
+
+ 4.1.0-PREFS-SNAPSHOT
diff --git a/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitProducer.java b/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitProducer.java
index 841755d..794272b 100644
--- a/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitProducer.java
+++ b/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitProducer.java
@@ -25,6 +25,7 @@ import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
import com.kingsrook.qbits.authentik.app.AuthentikRelations;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
import com.kingsrook.qbits.authentik.metadata.AuthentikAppMetaDataProducer;
+import com.kingsrook.qbits.authentik.preferences.AccessTokenPreferences;
import com.kingsrook.qbits.authentik.processes.AuthentikProcesses;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
@@ -185,6 +186,10 @@ public class AuthentikQBitProducer implements QBitMetaDataProducer parts = new ArrayList<>();
@@ -622,6 +630,16 @@ public class AuthentikTokens
+ /*******************************************************************************
+ ** Preferences has core's response-only secret channel, independent of processes.
+ *******************************************************************************/
+ private static boolean isPreferences(AdminRequest request)
+ {
+ return ("preferences".equals(request.str("deliveryChannel")));
+ }
+
+
+
/*******************************************************************************
** Build the isolated one-time object, including its response-only usage text.
*******************************************************************************/
diff --git a/src/main/java/com/kingsrook/qbits/authentik/preferences/AccessTokenPreferences.java b/src/main/java/com/kingsrook/qbits/authentik/preferences/AccessTokenPreferences.java
new file mode 100644
index 0000000..64a1120
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/authentik/preferences/AccessTokenPreferences.java
@@ -0,0 +1,106 @@
+/*
+ * 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.preferences;
+
+
+import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
+import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
+import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
+import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceActionMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceActionScope;
+import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceListMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceSectionMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferencesMetaData;
+
+
+/*******************************************************************************
+ ** Self-service token metadata for core's generic Preferences clients.
+ *******************************************************************************/
+public final class AccessTokenPreferences
+{
+ public static final String SECTION = "access-tokens";
+ public static final String LIST = "tokens";
+
+
+
+ /*******************************************************************************
+ ** Register without an app or permission gate: every signed-in user may enter.
+ *******************************************************************************/
+ public static void register(QInstance instance, AuthentikQBitConfig config)
+ {
+ UserPreferenceListMetaData tokens = new UserPreferenceListMetaData().withName(LIST).withLabel("Tokens").withRowLabel("Token")
+ .withField(readOnly("description", "Description", QFieldType.STRING))
+ .withField(readOnly("identifier", "Identifier", QFieldType.STRING))
+ .withField(readOnly("intent", "Intent", QFieldType.STRING))
+ .withField(readOnly("expires", "Expires", QFieldType.DATE_TIME))
+ .withField(readOnly("scope", "Scope", QFieldType.STRING));
+ UserPreferenceActionMetaData create = new UserPreferenceActionMetaData().withName("create").withLabel("Create token")
+ .withScope(UserPreferenceActionScope.SECTION).withOrder(10)
+ .withInputField(new QFieldMetaData("description", QFieldType.STRING).withLabel("Description").withIsRequired(true))
+ .withInputField(expiry().withDefaultValue(30));
+ if(Boolean.TRUE.equals(config.getTokenScopesEnabled()))
+ {
+ create.withInputField(new QFieldMetaData("readOnly", QFieldType.BOOLEAN).withLabel("Read only"))
+ .withInputField(new QFieldMetaData("apps", QFieldType.TEXT).withLabel("Apps"))
+ .withInputField(new QFieldMetaData("tables", QFieldType.TEXT).withLabel("Tables"));
+ }
+ UserPreferencesMetaData.ofOrWithNew(instance).withSection(new UserPreferenceSectionMetaData()
+ .withName(SECTION).withLabel("Access tokens").withIcon(new QIcon("key"))
+ .withDescription("Personal access tokens for scripts and integrations. Keys are shown once.")
+ .withOrder(30).withShowInUserMenu(true).withProvider(new QCodeReference(AccessTokenPreferencesProvider.class))
+ .withList(tokens).withAction(create)
+ .withAction(rowAction("rotate", "Rotate token", 20).withIsDestructive(true)
+ .withConfirmationText("Rotate this token? The old key stops working now."))
+ .withAction(rowAction("extend", "Extend expiry", 30).withInputField(expiry()))
+ .withAction(rowAction("revoke", "Revoke token", 40).withIsDestructive(true)
+ .withConfirmationText("Revoke this token? Anything using it stops working now.")));
+ }
+
+
+
+ /*******************************************************************************
+ ** Token attributes can only be changed by the audited actions.
+ *******************************************************************************/
+ private static QFieldMetaData readOnly(String name, String label, QFieldType type)
+ {
+ return (new QFieldMetaData(name, type).withLabel(label).withIsEditable(false));
+ }
+
+
+
+ /*******************************************************************************
+ ** Both creation and extension require an explicit bounded lifetime.
+ *******************************************************************************/
+ private static QFieldMetaData expiry()
+ {
+ return (new QFieldMetaData("expiresInDays", QFieldType.INTEGER).withLabel("Expires in days").withIsRequired(true));
+ }
+
+
+
+ /*******************************************************************************
+ ** All row actions refer to the provider's own-token list.
+ *******************************************************************************/
+ private static UserPreferenceActionMetaData rowAction(String name, String label, int order)
+ {
+ return (new UserPreferenceActionMetaData().withName(name).withLabel(label).withOrder(order)
+ .withScope(UserPreferenceActionScope.ROW).withListName(LIST));
+ }
+}
diff --git a/src/main/java/com/kingsrook/qbits/authentik/preferences/AccessTokenPreferencesProvider.java b/src/main/java/com/kingsrook/qbits/authentik/preferences/AccessTokenPreferencesProvider.java
new file mode 100644
index 0000000..e9d0269
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/authentik/preferences/AccessTokenPreferencesProvider.java
@@ -0,0 +1,174 @@
+/*
+ * 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.preferences;
+
+
+import java.io.Serializable;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import com.kingsrook.qbits.authentik.AuthentikFeature;
+import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
+import com.kingsrook.qbits.authentik.admin.AdminAction;
+import com.kingsrook.qbits.authentik.admin.AdminRequest;
+import com.kingsrook.qbits.authentik.admin.AuthentikAdmin;
+import com.kingsrook.qbits.authentik.admin.AuthentikTokens;
+import com.kingsrook.qbits.authentik.admin.OneTimeKey;
+import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
+import com.kingsrook.qbits.authentik.client.OAuth2SubjectTokenSource;
+import com.kingsrook.qqq.backend.core.context.QContext;
+import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
+import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceActionRequest;
+import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceActionResult;
+import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceChange;
+import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceProviderInterface;
+import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceRequestContext;
+import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceRow;
+import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceValues;
+import com.kingsrook.qqq.backend.core.model.secrets.OneTimeSecret;
+import org.json.JSONObject;
+
+
+/*******************************************************************************
+ ** Live personal app-password metadata and audited actions. Keys exist only in
+ ** the immediate action response, never in stored or reloaded preference values.
+ *******************************************************************************/
+public class AccessTokenPreferencesProvider implements UserPreferenceProviderInterface
+{
+ /*******************************************************************************
+ ** Resolve the configured qbit in this instance, including custom backend names.
+ ** Refuse ambiguity rather than silently selecting another identity directory.
+ *******************************************************************************/
+ private AuthentikAdmin admin(UserPreferenceRequestContext context) throws QException
+ {
+ if(QContext.getQSession() != context.getSession())
+ {
+ throw (new QUserFacingException("The preference session changed; sign in again."));
+ }
+ List configs = QContext.getQInstance().getQBits().values().stream()
+ .map(qbit -> qbit.getConfig()).filter(AuthentikQBitConfig.class::isInstance).map(AuthentikQBitConfig.class::cast)
+ .filter(config -> config.hasFeature(AuthentikFeature.TOKENS)).toList();
+ if(configs.size() != 1)
+ {
+ throw (new QUserFacingException("Access token preferences need one configured Authentik directory."));
+ }
+ AuthentikAdmin admin = AuthentikAdmin.forBackend(configs.get(0).getApiBackendName());
+ admin.getUtil().getRuntime().getGuard().checkTokenPreferencesRead();
+ return (admin);
+ }
+
+
+
+ /*******************************************************************************
+ ** Read all pages with server-side owner and intent constraints. A mismatched
+ ** response fails closed; metadata is explicitly allowlisted, never copied whole.
+ *******************************************************************************/
+ @Override
+ public UserPreferenceValues load(UserPreferenceRequestContext context) throws QException
+ {
+ AuthentikAdmin admin = admin(context);
+ AuthentikActionUtil util = admin.getUtil();
+ util.getRuntime().invalidateCache();
+ Integer pk = admin.operator().pk();
+ if(pk == null)
+ {
+ throw (new QUserFacingException(OAuth2SubjectTokenSource.UNAVAILABLE));
+ }
+ JSONObject owner = util.getApiJson("/core/users/" + pk + "/");
+ String username = owner.getString("username");
+ List rows = new ArrayList<>();
+ AuthentikTokens tokens = new AuthentikTokens(admin);
+ for(JSONObject token : util.getAllPages("/core/tokens/?user__username=" + URLEncoder.encode(username, StandardCharsets.UTF_8)
+ + "&intent=app_password", Integer.MAX_VALUE))
+ {
+ Object user = token.opt("user");
+ int ownerPk = user instanceof JSONObject object ? object.optInt("pk", -1) : token.optInt("user", -1);
+ if(ownerPk != pk || !"app_password".equals(token.optString("intent")))
+ {
+ throw (new QUserFacingException("Authentik returned tokens outside your personal token list."));
+ }
+ String identifier = token.getString("identifier");
+ Map values = new LinkedHashMap<>();
+ values.put("description", token.optString("description", ""));
+ values.put("identifier", identifier);
+ values.put("intent", "app_password");
+ values.put("expires", token.isNull("expires") ? null : Instant.parse(token.getString("expires")));
+ values.put("scope", tokens.scopeSummary(identifier));
+ rows.add(new UserPreferenceRow().withKey(identifier).withLabel(token.optString("description", identifier)).withValues(values));
+ }
+ return (new UserPreferenceValues().withLists(Map.of(AccessTokenPreferences.LIST, rows)));
+ }
+
+
+
+ /*******************************************************************************
+ ** Scalar and row edits are unavailable; lifecycle actions own every mutation.
+ *******************************************************************************/
+ @Override
+ public void store(UserPreferenceRequestContext context, UserPreferenceChange change) throws QException
+ {
+ throw (new QBadRequestException("Use the access token actions to make changes."));
+ }
+
+
+
+ /*******************************************************************************
+ ** Use the same live guards, broker and audit as record actions. The delivery
+ ** channel is server-selected and never accepted as a client input field.
+ *******************************************************************************/
+ @Override
+ public UserPreferenceActionResult runAction(UserPreferenceRequestContext context, UserPreferenceActionRequest request) throws QException
+ {
+ AdminAction action = switch(request.getActionName())
+ {
+ case "create" -> AdminAction.CREATE_TOKEN;
+ case "rotate" -> AdminAction.ROTATE_TOKEN;
+ case "extend" -> AdminAction.EXTEND_TOKEN;
+ case "revoke" -> AdminAction.REVOKE_TOKEN;
+ default -> throw new QBadRequestException("This action isn't available.");
+ };
+ AdminRequest change = AdminRequest.of(action, request.getRowKey() == null ? "" : request.getRowKey(), "Preferences: " + request.getActionName());
+ for(String field : List.of("description", "expiresInDays", "readOnly", "apps", "tables"))
+ {
+ if(request.getValues().containsKey(field))
+ {
+ change.with(field, request.getValues().get(field));
+ }
+ }
+ change.with("deliveryChannel", "preferences");
+ AuthentikAdmin.AdminResult result = admin(context).execute(change);
+ if(!result.ok())
+ {
+ throw (new QUserFacingException(result.message()));
+ }
+ UserPreferenceActionResult response = new UserPreferenceActionResult().withMessage(result.message());
+ OneTimeKey key = result.takeSecret();
+ if(key != null)
+ {
+ response.withFocusRowKey(key.label()).withSecrets(List.of(new OneTimeSecret().withLabel("Access token")
+ .withValue(key.value()).withMasked(key.masked()).withUsageText(key.usageText())));
+ }
+ result.delivered();
+ return (response);
+ }
+}
diff --git a/src/main/java/com/kingsrook/qbits/authentik/security/AuthentikGuard.java b/src/main/java/com/kingsrook/qbits/authentik/security/AuthentikGuard.java
index bca5b01..c26b01c 100644
--- a/src/main/java/com/kingsrook/qbits/authentik/security/AuthentikGuard.java
+++ b/src/main/java/com/kingsrook/qbits/authentik/security/AuthentikGuard.java
@@ -155,6 +155,22 @@ public class AuthentikGuard
+ /*******************************************************************************
+ ** Self-service metadata reads need a signed-in person, not table permissions.
+ ** The Preferences provider must additionally constrain every read to that owner.
+ *******************************************************************************/
+ public void checkTokenPreferencesRead() throws QPermissionDeniedException
+ {
+ QSession session = QContext.getQSession();
+ requireUser(session);
+ if(session instanceof QSystemUserSession || !config.hasFeature(AuthentikFeature.READ) || !config.hasFeature(AuthentikFeature.TOKENS))
+ {
+ throw (new QPermissionDeniedException("Access token preferences are not available."));
+ }
+ }
+
+
+
/*******************************************************************************
** Whether the current session may see and filter sensitive fields.
*******************************************************************************/
diff --git a/src/test/java/com/kingsrook/qbits/authentik/preferences/AccessTokenPreferencesTest.java b/src/test/java/com/kingsrook/qbits/authentik/preferences/AccessTokenPreferencesTest.java
new file mode 100644
index 0000000..aa63401
--- /dev/null
+++ b/src/test/java/com/kingsrook/qbits/authentik/preferences/AccessTokenPreferencesTest.java
@@ -0,0 +1,436 @@
+/*
+ * 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.preferences;
+
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Map;
+import com.kingsrook.qbits.authentik.AuthentikFeature;
+import com.kingsrook.qbits.authentik.admin.AdminAction;
+import com.kingsrook.qbits.authentik.admin.AdminPlan;
+import com.kingsrook.qbits.authentik.admin.AdminRequest;
+import com.kingsrook.qbits.authentik.admin.AdminTestSupport;
+import com.kingsrook.qbits.authentik.admin.TokenScopeStore;
+import com.kingsrook.qbits.authentik.client.FixtureActionUtil;
+import com.kingsrook.qbits.authentik.processes.TokenKeyDelivery;
+import com.kingsrook.qbits.authentik.security.IdentityPermission;
+import com.kingsrook.qqq.backend.core.actions.preferences.UserPreferencesAction;
+import com.kingsrook.qqq.backend.core.actions.preferences.UserPreferencesKeyValueStoreMetaDataProvider;
+import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
+import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
+import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
+import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
+import com.kingsrook.qqq.backend.core.model.data.QRecord;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
+import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceActionMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceActionResult;
+import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceActionScope;
+import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceRow;
+import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceSectionMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceSectionResult;
+import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferencesMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
+import com.kingsrook.qqq.backend.core.model.secrets.OneTimeSecret;
+import com.kingsrook.qqq.backend.core.utils.JsonUtils;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.core.LogEvent;
+import org.apache.logging.log4j.core.Logger;
+import org.apache.logging.log4j.core.appender.AbstractAppender;
+import org.apache.logging.log4j.core.config.Property;
+import org.json.JSONArray;
+import org.json.JSONObject;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+
+/*******************************************************************************
+ ** Exercise the real Preferences dispatcher against FakeAuthentik and local tables.
+ *******************************************************************************/
+class AccessTokenPreferencesTest
+{
+ private AdminTestSupport t;
+ private final UserPreferencesAction actions = new UserPreferencesAction();
+ private final List logs = new ArrayList<>();
+ private AbstractAppender appender;
+
+
+
+ /*******************************************************************************
+ ** No table permissions or process delivery adapter: core owns the response.
+ *******************************************************************************/
+ @BeforeEach
+ void setUp() throws QException
+ {
+ t = AdminTestSupport.create();
+ t.config.withTokenBrokerClientId("test-broker").withTokenBrokerClientSecret("test-broker-secret")
+ .withSubjectTokenSource(session -> "subject:3").withAppClientId("test-app")
+ .withAppPublicUrl("https://app.example.test").withAuthentikPublicUrl("https://auth.example.test");
+ t.authentik.users.get(3).getJSONObject("attributes").put("goauthentik.io/user/token-maximum-lifetime", "days=90");
+ t.loginAs("sam@example.test");
+ new UserPreferencesKeyValueStoreMetaDataProvider().defineAll(t.qInstance, AdminTestSupport.LOCAL, null);
+ TokenKeyDelivery.set(null);
+ appender = new AbstractAppender("preferences-token-capture", null, null, false, Property.EMPTY_ARRAY)
+ {
+ /*******************************************************************************
+ ** Retain messages and throwable text for the secret-leak assertion.
+ *******************************************************************************/
+ @Override
+ public void append(LogEvent event)
+ {
+ logs.add(event.getMessage().getFormattedMessage());
+ if(event.getThrownProxy() != null)
+ {
+ logs.add(event.getThrownProxy().getExtendedStackTraceAsString());
+ }
+ }
+ };
+ appender.start();
+ ((Logger) LogManager.getRootLogger()).addAppender(appender);
+ }
+
+
+
+ /*******************************************************************************
+ ** Reset global fixture state after every test.
+ *******************************************************************************/
+ @AfterEach
+ void tearDown()
+ {
+ ((Logger) LogManager.getRootLogger()).removeAppender(appender);
+ appender.stop();
+ TokenKeyDelivery.set(null);
+ FixtureActionUtil.reset();
+ }
+
+
+
+ /*******************************************************************************
+ ** Registration is independent of Identity app access; all cells are read-only.
+ *******************************************************************************/
+ @Test
+ void metadataAndFeatureGate() throws QException
+ {
+ UserPreferenceSectionMetaData section = actions.getSection("access-tokens").getSection();
+ assertThat(section.getLabel()).isEqualTo("Access tokens");
+ assertThat(section.getIcon().getName()).isEqualTo("key");
+ assertThat(section.getDescription()).isEqualTo("Personal access tokens for scripts and integrations. Keys are shown once.");
+ assertThat(section.getOrder()).isEqualTo(30);
+ assertThat(section.getShowInUserMenu()).isTrue();
+ assertThat(section.getAppName()).isNull();
+ assertThat(section.getPermissionName()).isNull();
+ assertThat(section.getFields()).isEmpty();
+ assertThat(section.getLists()).hasSize(1);
+ assertThat(section.getLists().get(0).getRowLabel()).isEqualTo("Token");
+ assertThat(section.getLists().get(0).getFields()).extracting(QFieldMetaData::getName)
+ .containsExactly("description", "identifier", "intent", "expires", "scope");
+ assertThat(section.getLists().get(0).getFields()).allMatch(field -> !field.getIsEditable());
+ assertThat(section.getLists().get(0).getFields().get(3).getType()).isEqualTo(QFieldType.DATE_TIME);
+ UserPreferenceActionMetaData create = section.getActions().get(0);
+ assertThat(create.getScope()).isEqualTo(UserPreferenceActionScope.SECTION);
+ assertThat(create.getListName()).isNull();
+ assertThat(create.getInputFields()).extracting(QFieldMetaData::getName).containsExactly("description", "expiresInDays");
+ assertThat(create.getInputFields()).allMatch(QFieldMetaData::getIsRequired);
+ assertThat(create.getInputFields().get(1).getDefaultValue()).isEqualTo(30);
+ assertThat(section.getActions().get(1).getConfirmationText()).isEqualTo("Rotate this token? The old key stops working now.");
+ assertThat(section.getActions().get(1).getIsDestructive()).isTrue();
+ assertThat(section.getActions().get(3).getConfirmationText()).isEqualTo("Revoke this token? Anything using it stops working now.");
+ assertThat(section.getActions().get(3).getIsDestructive()).isTrue();
+ assertThat(actions.listSections()).hasSize(1);
+ t = AdminTestSupport.create(EnumSet.of(AuthentikFeature.READ));
+ assertThat(actions.listSections()).isEmpty();
+ assertThatThrownBy(() -> actions.getSection("access-tokens")).isInstanceOf(QNotFoundException.class);
+ }
+
+
+
+ /*******************************************************************************
+ ** The service-account list request is owner/intent filtered even for managers.
+ *******************************************************************************/
+ @Test
+ void loadOnlyOwnAppPasswordsAndRejectForeignRows() throws QException
+ {
+ seed("mine", 3, "app_password");
+ seed("my-api", 3, "api");
+ seed("someone-else", 7, "app_password");
+ for(IdentityPermission[] permissions : List.of(new IdentityPermission[0], new IdentityPermission[] { IdentityPermission.TOKENS_MANAGE }))
+ {
+ t.loginAs("sam@example.test", permissions);
+ assertThat(rows()).extracting(UserPreferenceRow::getKey).containsExactly("mine");
+ assertThatThrownBy(() -> rowAction("revoke", "someone-else", Map.of())).isInstanceOf(QBadRequestException.class);
+ assertThatThrownBy(() -> rowAction("rotate", "my-api", Map.of())).isInstanceOf(QBadRequestException.class);
+ }
+ assertThat(FixtureActionUtil.REQUESTS.stream().filter(r -> r.getURI().getPath().endsWith("/core/tokens/")))
+ .allSatisfy(r ->
+ {
+ assertThat(r.getURI().getQuery()).contains("user__username=sam", "intent=app_password");
+ assertThat(r.getFirstHeader("Authorization").getValue()).isEqualTo("Bearer " + t.config.getResolvedApiToken());
+ });
+ assertThat(t.changes()).isEmpty();
+ t.loginAs("dormant@example.test");
+ assertThat(rows()).extracting(UserPreferenceRow::getKey).containsExactly("someone-else");
+ }
+
+
+
+ /*******************************************************************************
+ ** Create/rotate deliver one key each; extend/revoke never do. Reloads are safe.
+ *******************************************************************************/
+ @Test
+ void lifecycleAndNoPersistedSecrets() throws QException
+ {
+ assertThat(TokenKeyDelivery.get().available()).isFalse();
+ UserPreferenceActionResult created = create(Map.of("description", "Build script", "expiresInDays", 30));
+ assertThat(created.getSecrets()).hasSize(1);
+ OneTimeSecret first = created.getSecrets().get(0);
+ String oldId = created.getFocusRowKey();
+ assertThat(first.getLabel()).isEqualTo("Access token");
+ assertThat(first.getMasked()).endsWith(first.getValue().substring(first.getValue().length() - 4));
+ assertThat(first.getUsageText()).contains("https://auth.example.test/application/o/token/", "username=sam", "test-app",
+ "https://app.example.test/qqq/v1/metaData", "Expires:");
+ assertThat(rows()).extracting(UserPreferenceRow::getKey).containsExactly(oldId);
+ assertThat(rows().get(0).getValues()).containsEntry("description", "Build script").containsEntry("scope", "Owner permissions");
+ assertNoKey(first, created);
+
+ UserPreferenceActionResult rotated = rowAction("rotate", oldId, Map.of());
+ assertThat(rotated.getSecrets()).hasSize(1);
+ OneTimeSecret next = rotated.getSecrets().get(0);
+ String newId = rotated.getFocusRowKey();
+ assertThat(newId).isNotEqualTo(oldId);
+ assertThat(next.getValue()).isNotEqualTo(first.getValue());
+ assertThat(t.authentik.tokenKeys).doesNotContainKey(oldId).containsEntry(newId, next.getValue());
+ assertThat(rows()).extracting(UserPreferenceRow::getKey).containsExactly(newId);
+ assertThat(rows().get(0).getValues().get("description")).isEqualTo("Build script");
+ assertNoKey(next, rotated);
+
+ UserPreferenceActionResult extended = rowAction("extend", newId, Map.of("expiresInDays", 60));
+ assertThat(extended.getSecrets()).isEmpty();
+ assertThat((Instant) rows().get(0).getValues().get("expires")).isAfter(Instant.now().plusSeconds(59 * 86400));
+ assertThat(t.authentik.tokens.get("sam").get(0).getInt("user")).isEqualTo(3);
+ assertThat(t.authentik.tokens.get("sam").get(0).getString("intent")).isEqualTo("app_password");
+ assertNoKey(next, extended);
+
+ UserPreferenceActionResult revoked = rowAction("revoke", newId, Map.of());
+ assertThat(revoked.getSecrets()).isEmpty();
+ assertThat(rows()).isEmpty();
+ assertThat(t.authentik.tokenKeys).doesNotContainKey(newId);
+ assertThat(FixtureActionUtil.REQUESTS.stream().filter(r -> r.getURI().getPath().contains("/core/tokens/")
+ && (!"GET".equals(r.getMethod()) || r.getURI().getPath().endsWith("/view_key/"))))
+ .allSatisfy(r -> assertThat(r.getFirstHeader("Authorization").getValue()).isEqualTo("Bearer user-bearer:3"));
+ assertNoKey(first, revoked);
+ assertNoKey(next, revoked);
+ assertThat(new QueryAction().execute(new QueryInput().withTableName("user_preference")).getRecords()).isEmpty();
+ List audits = new QueryAction().execute(new QueryInput().withTableName("authentik_admin_action")).getRecords();
+ assertThat(audits).extracting(r -> r.getValueString("reason"))
+ .containsExactly("Preferences: create", "Preferences: rotate", "Preferences: extend", "Preferences: revoke");
+ assertThat(audits).allSatisfy(r ->
+ {
+ assertThat(r.getValueString("operator")).isEqualTo("sam@example.test");
+ assertThat(r.getValueString("status")).isEqualTo("SUCCEEDED");
+ });
+ }
+
+
+
+ /*******************************************************************************
+ ** Scope inputs are conditional and their metadata survives rotation.
+ *******************************************************************************/
+ @Test
+ void scopesAreOptIn() throws QException
+ {
+ assertThatThrownBy(() -> create(Map.of("description", "Scoped", "expiresInDays", 30, "readOnly", true)))
+ .isInstanceOf(QBadRequestException.class);
+ t.config.withTokenScopesEnabled(true);
+ UserPreferencesMetaData.of(t.qInstance).getSections().clear();
+ AccessTokenPreferences.register(t.qInstance, t.config);
+ assertThat(actions.getSection("access-tokens").getSection().getActions().get(0).getInputFields()).extracting(QFieldMetaData::getName)
+ .containsExactly("description", "expiresInDays", "readOnly", "apps", "tables");
+ UserPreferenceActionResult created = create(Map.of("description", "Scoped", "expiresInDays", 30, "readOnly", true,
+ "apps", "content", "tables", "post"));
+ assertThat(rows().get(0).getValues().get("scope")).isEqualTo("readOnly: true; apps: content; tables: post");
+ UserPreferenceActionResult rotated = rowAction("rotate", created.getFocusRowKey(), Map.of());
+ assertThat(new TokenScopeStore(t.config).find(rotated.getFocusRowKey()).getValueBoolean("readOnly")).isTrue();
+ assertThat(new TokenScopeStore(t.config).find(created.getFocusRowKey())).isNull();
+ assertNoKey(created.getSecrets().get(0), created);
+ assertNoKey(rotated.getSecrets().get(0), rotated);
+ }
+
+
+
+ /*******************************************************************************
+ ** Domain refusals remain audited, before any token mutation or broker exchange.
+ *******************************************************************************/
+ @Test
+ void protectedUserAndMissingBrokerRefused() throws QException
+ {
+ t.authentik.groupByName("identity-admins").getJSONArray("users").put(3);
+ assertThatThrownBy(() -> create(Map.of("description", "Refused", "expiresInDays", 30)))
+ .isInstanceOf(QUserFacingException.class).hasMessageContaining("Protected");
+ t.authentik.groupByName("identity-admins").getJSONArray("users").remove(2);
+ t.config.withTokenBrokerClientId("").withTokenBrokerClientSecret("");
+ assertThatThrownBy(() -> create(Map.of("description", "Refused", "expiresInDays", 30)))
+ .isInstanceOf(QUserFacingException.class).hasMessageContaining("token broker");
+ assertThat(t.changes()).isEmpty();
+ assertThat(t.authentik.tokenKeys).isEmpty();
+ assertThat(FixtureActionUtil.REQUESTS).noneMatch(r -> r.getURI().getPath().endsWith("/application/o/token/"));
+ assertThat(new QueryAction().execute(new QueryInput().withTableName("authentik_admin_action")).getRecords())
+ .hasSize(2).allMatch(r -> "REFUSED".equals(r.getValueString("status")));
+ }
+
+
+
+ /*******************************************************************************
+ ** Core rejects arbitrary values and row edits; omitted required inputs reach
+ ** the existing domain validation rather than silently acquiring defaults.
+ *******************************************************************************/
+ @Test
+ void validationAndReadOnlyRows() throws QException
+ {
+ seed("mine", 3, "app_password");
+ assertThatThrownBy(() -> create(Map.of("description", "Missing expiry"))).hasMessageContaining("Expiry is required");
+ assertThatThrownBy(() -> create(Map.of("expiresInDays", 30))).hasMessageContaining("description is required");
+ assertThatThrownBy(() -> rowAction("extend", "mine", Map.of())).hasMessageContaining("Expiry is required");
+ assertThatThrownBy(() -> create(Map.of("description", "Too long", "expiresInDays", 91))).hasMessageContaining("between 1 and 90");
+ assertThatThrownBy(() -> create(Map.of("description", "Injected", "expiresInDays", 30, "deliveryChannel", "preferences")))
+ .isInstanceOf(QBadRequestException.class);
+ assertThatThrownBy(() -> actions.patchSection("access-tokens", Map.of("lists", Map.of("tokens",
+ List.of(Map.of("key", "mine", "values", Map.of("description", "Changed")))))))
+ .isInstanceOf(QBadRequestException.class).hasMessageContaining("can't be changed");
+ assertThatThrownBy(() -> actions.patchSection("access-tokens", Map.of()))
+ .isInstanceOf(QBadRequestException.class).hasMessageContaining("Use the access token actions");
+ assertThat(t.changes()).isEmpty();
+ }
+
+
+
+ /*******************************************************************************
+ ** Even a manager cannot use Preferences after the target moves to a person
+ ** or service account, or changes intent, after the core row check.
+ *******************************************************************************/
+ @Test
+ void managerCannotCrossOwnershipOrIntentBoundary() throws QException
+ {
+ t.loginAs("sam@example.test", IdentityPermission.TOKENS_MANAGE);
+ JSONObject token = seed("mine", 3, "app_password");
+ for(int owner : List.of(7, 6))
+ {
+ token.put("user", 3);
+ AdminRequest request = AdminRequest.of(AdminAction.REVOKE_TOKEN, "mine", "Preferences: revoke").with("deliveryChannel", "preferences");
+ AdminPlan plan = t.admin().plan(request);
+ assertThat(plan.isRunnable()).isTrue();
+ token.put("user", owner);
+ assertThat(t.admin().execute(plan, null).ok()).isFalse();
+ }
+ token.put("user", 3).put("intent", "api");
+ assertThat(t.admin().execute(AdminRequest.of(AdminAction.REVOKE_TOKEN, "mine", "Preferences: revoke")
+ .with("deliveryChannel", "preferences")).ok()).isFalse();
+ assertThat(t.changes()).isEmpty();
+ }
+
+
+
+ /*******************************************************************************
+ ** A faulty upstream owner filter must never disclose another user's metadata.
+ *******************************************************************************/
+ @Test
+ void refusesUnexpectedOwnerAndUnresolvedIdentity() throws QException
+ {
+ JSONObject foreign = seed("foreign", 7, "app_password");
+ var original = FixtureActionUtil.handler;
+ FixtureActionUtil.handler = request -> request.getURI().getPath().endsWith("/core/tokens/")
+ ? FixtureActionUtil.response(200, new JSONObject().put("pagination", new JSONObject().put("total_pages", 1))
+ .put("results", new JSONArray().put(foreign)).toString()) : original.apply(request);
+ assertThatThrownBy(this::rows).isInstanceOf(QUserFacingException.class).hasMessageContaining("outside your personal token list");
+ t.loginAs("unknown@example.test");
+ assertThatThrownBy(this::rows).isInstanceOf(QUserFacingException.class);
+ }
+
+
+
+ /*******************************************************************************
+ ** Drive a section-level action exactly as the UI API does.
+ *******************************************************************************/
+ private UserPreferenceActionResult create(Map values) throws QException
+ {
+ return (actions.runAction("access-tokens", "create", Map.of("values", values)));
+ }
+
+
+
+ /*******************************************************************************
+ ** Drive a row action through core's loaded-row ownership check.
+ *******************************************************************************/
+ private UserPreferenceActionResult rowAction(String action, String key, Map values) throws QException
+ {
+ return (actions.runAction("access-tokens", action, Map.of("rowKey", key, "values", values)));
+ }
+
+
+
+ /*******************************************************************************
+ ** Reload instead of reusing a previous action's response.
+ *******************************************************************************/
+ private List rows() throws QException
+ {
+ return (actions.getSection("access-tokens").getValues().getLists().get("tokens"));
+ }
+
+
+
+ /*******************************************************************************
+ ** Seed the upstream fake, never the local preference or token-scope store.
+ *******************************************************************************/
+ private JSONObject seed(String id, int owner, String intent)
+ {
+ return (t.authentik.seedToken(id, owner, intent, Instant.now().plusSeconds(86400).toString()));
+ }
+
+
+
+ /*******************************************************************************
+ ** Inspect every local table (including audit and KV preferences), reloaded
+ ** values, response section, events and captured messages/throwables.
+ *******************************************************************************/
+ private void assertNoKey(OneTimeSecret secret, UserPreferenceActionResult result) throws QException
+ {
+ String key = secret.getValue();
+ assertThat(secret.toString()).doesNotContain(key);
+ assertThat(result.getMessage()).doesNotContain(key);
+ assertThat(JsonUtils.toJson(result.getSection())).doesNotContain(key);
+ UserPreferenceSectionResult reload = actions.getSection("access-tokens");
+ assertThat(JsonUtils.toJson(reload)).doesNotContain(key);
+ assertThat(reload.getValues().getValues()).isEmpty();
+ assertThat(String.join("\n", logs)).doesNotContain(key);
+ assertThat(t.authentik.events.toString()).doesNotContain(key);
+ for(QTableMetaData table : t.qInstance.getTables().values())
+ {
+ if(AdminTestSupport.LOCAL.equals(table.getBackendName()))
+ {
+ assertThat(JsonUtils.toJson(new QueryAction().execute(new QueryInput().withTableName(table.getName())).getRecords()))
+ .as(table.getName()).doesNotContain(key);
+ }
+ }
+ }
+}