Compare commits

...
1 Commits
Author SHA1 Message Date
james.maesandClaude Opus 5.5 d91b81499f feat(tokens): Preferences section "Access tokens" (DESIGN.md section 13, phase 6d)
Self-service through the core Preferences contract (QRun-IO/qqq#1162):
section access-tokens, shown in the user menu, lists the signed-in
user's own app passwords (metadata only) with Create token (section),
Rotate, Extend and Revoke (row) actions. Each action runs through
AuthentikAdmin, so the guardrails, the broker exchange as the user and
the audit apply exactly as for the record actions. The key is returned
once as a core OneTimeSecret with usage text and is never stored.

STAND-IN(core=QRun-IO/qqq#1162, removeBy=4.1.0-RC.7): builds against
4.1.0-PREFS-SNAPSHOT until an RC ships #1162 (draft until then).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:10:53 -05:00
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);
}
}
}
}