WIP: feat(tokens): Preferences section "Access tokens" (phase 6d, draft until #1162 RC) #25

Draft
KofTwentyTwo wants to merge 1 commits from feature/token-preferences into develop
pull from: feature/token-preferences
8 changed files with 771 additions and 4 deletions

No files matched your search

+11
View File
@@ -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)
+2 -1
View File
@@ -245,7 +245,8 @@
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.2</qqq.snapshot.version>
<!-- STAND-IN(core=QRun-IO/qqq#1162, removeBy=4.1.0-RC.7): local build of feature/user-preferences; switch to the RC that ships #1162 -->
<qqq.snapshot.version>4.1.0-PREFS-SNAPSHOT</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -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<AuthentikQBit
output.addSelfToInstance(qInstance);
addPossibleValueSources(qInstance);
if(qBitConfig.hasFeature(AuthentikFeature.TOKENS))
{
AccessTokenPreferences.register(qInstance, qBitConfig);
}
AuthentikRelations.apply(qInstance, qBitConfig);
for(QProcessMetaData process : AuthentikProcesses.processes(qBitConfig))
{
@@ -130,6 +130,10 @@ public class AuthentikTokens
boolean own = Objects.equals(operatorPk, ownerPk);
boolean service = "service_account".equals(owner.optString("type"));
boolean manager = AuthentikGuard.has(QContext.getQSession(), IdentityPermission.TOKENS_MANAGE);
if(isPreferences(request) && (!own || service || token != null && !"app_password".equals(token.optString("intent"))))
{
return (plan.refuse("Preferences only manages your own personal app-password tokens."));
}
if(service && action == AdminAction.CREATE_TOKEN)
{
return (plan.refuse("Service accounts have one app password. Use Rotate Token or Create Service Account."));
@@ -169,7 +173,7 @@ public class AuthentikTokens
}
if(action == AdminAction.CREATE_TOKEN || action == AdminAction.ROTATE_TOKEN)
{
if(!TokenKeyDelivery.get().available())
if(!isPreferences(request) && !TokenKeyDelivery.get().available())
{
return (plan.refuse(DELIVERY_UNAVAILABLE));
}
@@ -402,7 +406,7 @@ public class AuthentikTokens
{
AdminRequest request = plan.getRequest();
if((request.action() == AdminAction.CREATE_TOKEN || request.action() == AdminAction.ROTATE_TOKEN || request.action() == AdminAction.CREATE_SERVICE_ACCOUNT)
&& !TokenKeyDelivery.get().available())
&& (!isPreferences(request) || request.action() == AdminAction.CREATE_SERVICE_ACCOUNT) && !TokenKeyDelivery.get().available())
{
throw (new QUserFacingException(DELIVERY_UNAVAILABLE));
}
@@ -416,6 +420,10 @@ public class AuthentikTokens
boolean service = "service_account".equals(owner.optString("type"));
boolean own = Objects.equals(admin.operator().pk(), owner.getInt("pk"));
boolean manager = AuthentikGuard.has(QContext.getQSession(), IdentityPermission.TOKENS_MANAGE);
if(isPreferences(request) && (!own || service || before != null && !"app_password".equals(before.optString("intent"))))
{
throw (new QUserFacingException("Preferences only manages your own personal app-password tokens."));
}
if((!own || service) && !manager || !own && !service && request.action() != AdminAction.REVOKE_TOKEN)
{
throw (new QUserFacingException("Token ownership changed; review again."));
@@ -606,7 +614,7 @@ public class AuthentikTokens
/*******************************************************************************
** Scope summary contains metadata only.
*******************************************************************************/
private String scopeSummary(String identifier) throws QException
public String scopeSummary(String identifier) throws QException
{
QRecord row = scopes.find(identifier);
List<String> 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.
*******************************************************************************/
@@ -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));
}
}
@@ -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<AuthentikQBitConfig> 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<UserPreferenceRow> 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<String, Serializable> 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);
}
}
@@ -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.
*******************************************************************************/
@@ -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<String> 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<QRecord> 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<String, ?> 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<String, ?> 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<UserPreferenceRow> 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);
}
}
}
}