diff --git a/container/features/src/main/resources/features.xml b/container/features/src/main/resources/features.xml
index 49ef95d72b71..c765aa1e57a8 100644
--- a/container/features/src/main/resources/features.xml
+++ b/container/features/src/main/resources/features.xml
@@ -1212,6 +1212,12 @@
mvn:org.opennms.features.api-layer/org.opennms.features.api-layer.core/${project.version}mvn:org.opennms.features/ui-extension/${project.version}
+
+
+
+
+ mvn:org.opennms.features/org.opennms.features.api-tokens.shell/${project.version}
+ opennms-daoopennms-core-daemon
diff --git a/container/karaf/pom.xml b/container/karaf/pom.xml
index 2b522672f24a..c110724928e8 100644
--- a/container/karaf/pom.xml
+++ b/container/karaf/pom.xml
@@ -80,6 +80,7 @@
opennms-http-whiteboard
+ opennms-api-tokens
@@ -285,6 +286,12 @@
pomprovided
+
+ org.opennms.features
+ org.opennms.features.api-tokens.shell
+ ${project.version}
+ provided
+ junitjunit
diff --git a/container/karaf/src/main/filtered-resources/etc/custom.properties b/container/karaf/src/main/filtered-resources/etc/custom.properties
index a5f758b8296a..7cc7b5cbad8e 100644
--- a/container/karaf/src/main/filtered-resources/etc/custom.properties
+++ b/container/karaf/src/main/filtered-resources/etc/custom.properties
@@ -1326,6 +1326,7 @@ org.osgi.framework.system.packages.extra=org.apache.karaf.branding,\
org.opennms.core.mate.api;version=${opennms.osgi.version},\
org.opennms.core.utils.url;version=${opennms.osgi.version},\
org.opennms.features.activemq.broker.api;version=${opennms.osgi.version},\
+ org.opennms.features.apitokens;version=${opennms.osgi.version},\
org.opennms.features.deviceconfig.persistence.api;version=${opennms.osgi.version},\
org.opennms.features.usageanalytics.api;version=${opennms.osgi.version},\
org.opennms.features.dhcpd;version=${opennms.osgi.version},\
diff --git a/container/karaf/src/main/filtered-resources/etc/org.apache.karaf.features.cfg b/container/karaf/src/main/filtered-resources/etc/org.apache.karaf.features.cfg
index f9d5ca9f3090..07f0c2dbe0e2 100644
--- a/container/karaf/src/main/filtered-resources/etc/org.apache.karaf.features.cfg
+++ b/container/karaf/src/main/filtered-resources/etc/org.apache.karaf.features.cfg
@@ -130,6 +130,7 @@ featuresBoot = ( \
opennms-config-management, \
opennms-scv-rest, \
scv-shell, \
+ opennms-api-tokens, \
opennms-karaf-health
# Ensure that the 'opennms-karaf-health' feature is installed *last*
diff --git a/core/schema/src/main/liquibase/35.0.0/changelog.xml b/core/schema/src/main/liquibase/35.0.0/changelog.xml
index 491221bd5c4b..9c707495b6f5 100644
--- a/core/schema/src/main/liquibase/35.0.0/changelog.xml
+++ b/core/schema/src/main/liquibase/35.0.0/changelog.xml
@@ -189,4 +189,42 @@
constraintName="uk_eventconf_sources_name"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/modules/development/nav.adoc b/docs/modules/development/nav.adoc
index a04ccefb9c27..9c0d3dbf0d12 100644
--- a/docs/modules/development/nav.adoc
+++ b/docs/modules/development/nav.adoc
@@ -23,6 +23,7 @@
* xref:rest/rest-api.adoc[]
** xref:rest/osgi.adoc[]
** xref:rest/implemented.adoc[Interfaces]
+*** xref:rest/api-tokens.adoc[]
*** xref:rest/acknowledgements.adoc[]
*** xref:rest/alarm_statistics.adoc[]
*** xref:rest/alarms.adoc[]
diff --git a/docs/modules/development/pages/rest/api-tokens.adoc b/docs/modules/development/pages/rest/api-tokens.adoc
new file mode 100644
index 000000000000..1fdfd458cc33
--- /dev/null
+++ b/docs/modules/development/pages/rest/api-tokens.adoc
@@ -0,0 +1,121 @@
+
+= API Tokens
+
+API tokens provide long-lived bearer token authentication for programmatic REST API access.
+Tokens are generated once, displayed once at creation, and stored as SHA-256 hashes.
+Token authentication inherits the user's full role set, matching existing basic auth behavior.
+
+== Authentication
+
+Include the token in the `Authorization` header:
+
+[source,bash]
+----
+curl -H "Authorization: Bearer onms_" http://localhost:8980/opennms/api/v2/apiTokens
+----
+
+== GETs (reading data)
+
+[caption=]
+.API Tokens GET functions
+[cols="1,3"]
+|===
+| Resource | Description
+
+| /api/v2/apiTokens
+| List the authenticated user's tokens.
+Returns id, description, createdAt, expiresAt, and lastUsedAt for each token.
+The token hash is never included in responses.
+
+| /api/v2/apiTokens?username=\{user}
+| List another user's tokens (requires ROLE_ADMIN).
+|===
+
+== POSTs (creating data)
+
+[caption=]
+.API Tokens POST functions
+[cols="1,3"]
+|===
+| Resource | Description
+
+| /api/v2/apiTokens
+| Create a token for the authenticated user.
+Accepts JSON body with optional `description` (string, max 256 chars) and `expiresInDays` (integer).
+Returns 201 with the plaintext token, which is shown only once.
+
+| /api/v2/apiTokens?username=\{user}
+| Create a token for another user (requires ROLE_ADMIN).
+The admin must transmit the plaintext token to the target user out-of-band.
+|===
+
+.Example create request
+[source,json]
+----
+{
+ "description": "grafana integration",
+ "expiresInDays": 90
+}
+----
+
+.Example create response
+[source,json]
+----
+{
+ "id": 1,
+ "token": "onms_a1b2c3d4...",
+ "description": "grafana integration",
+ "createdAt": "2026-03-22T10:00:00.000Z",
+ "expiresAt": "2026-06-20T10:00:00.000Z"
+}
+----
+
+== DELETEs (removing data)
+
+[caption=]
+.API Tokens DELETE functions
+[cols="1,3"]
+|===
+| Resource | Description
+
+| /api/v2/apiTokens/\{id}
+| Revoke a specific token.
+Users can revoke their own tokens; admins can revoke any token.
+Returns 204 on success, 404 if not found or not authorized.
+
+| /api/v2/apiTokens?username=\{user}
+| Revoke all tokens for a user.
+Users can revoke their own; admins can revoke any user's tokens (requires ROLE_ADMIN for other users).
+Returns 204 on success.
+|===
+
+== Karaf Shell Commands
+
+[cols="1,3"]
+|===
+| Command | Description
+
+| `opennms:api-token-generate [-d description] [-e days] `
+| Generate a new token. Prints the plaintext token once.
+
+| `opennms:api-token-list `
+| List all tokens for a user.
+
+| `opennms:api-token-revoke `
+| Revoke a specific token by ID.
+
+| `opennms:api-token-revoke-all `
+| Revoke all tokens for a user.
+|===
+
+NOTE: In the Karaf shell, options (`-d`, `-e`) must come before the positional argument.
+
+== Configuration
+
+See xref:reference:configuration/system-properties.adoc[System Properties] for configurable limits:
+
+* `org.opennms.api.tokens.max-expiry-days` (default: 365)
+* `org.opennms.api.tokens.default-expiry-days` (default: 365)
+* `org.opennms.api.tokens.max-tokens-per-user` (default: 50)
+
+These can be set in `$\{OPENNMS_HOME}/etc/opennms.properties.d/api-tokens.properties`.
diff --git a/docs/modules/development/pages/rest/rest-api.adoc b/docs/modules/development/pages/rest/rest-api.adoc
index 8ebc7ad5ee00..c9e5958548cf 100644
--- a/docs/modules/development/pages/rest/rest-api.adoc
+++ b/docs/modules/development/pages/rest/rest-api.adoc
@@ -14,6 +14,14 @@ For instance, `http://localhost:8980/opennms/rest/alarms/` will give you the cur
Use HTTP basic authentication to provide a valid username and password.
By default, you will not receive a challenge, so you must configure your REST client library to send basic authentication proactively.
+Alternatively, you can use xref:rest/api-tokens.adoc[API tokens] for programmatic access.
+API tokens are long-lived bearer tokens that avoid exposing your password:
+
+[source,bash]
+----
+curl -H "Authorization: Bearer onms_" http://localhost:8980/opennms/api/v2/info
+----
+
== Data format
Jersey enables {page-component-title} to create REST calls using either XML or JSON.
diff --git a/docs/modules/reference/pages/configuration/system-properties.adoc b/docs/modules/reference/pages/configuration/system-properties.adoc
index fb3366f0da59..9587b62ddf6b 100644
--- a/docs/modules/reference/pages/configuration/system-properties.adoc
+++ b/docs/modules/reference/pages/configuration/system-properties.adoc
@@ -367,6 +367,18 @@ Many of the properties here are documented with much greater detail in their rel
| false
| Enable or disable exporting performance data to an external system over a TCP port
+| `org.opennms.api.tokens.default-expiry-days`
+| 365
+| Default expiry period in days for newly created API tokens when no expiry is specified
+
+| `org.opennms.api.tokens.max-expiry-days`
+| 365
+| Maximum allowed expiry period in days for API tokens. Set to `0` to disable token creation entirely.
+
+| `org.opennms.api.tokens.max-tokens-per-user`
+| 50
+| Maximum number of active API tokens each user is allowed to have
+
| `org.opennms.security.disableLoginSuccessEvent`
| false
| Enable or disable sending successful login events on a successful login to the webui
diff --git a/features/api-tokens/api/pom.xml b/features/api-tokens/api/pom.xml
new file mode 100644
index 000000000000..a0bdc03cec01
--- /dev/null
+++ b/features/api-tokens/api/pom.xml
@@ -0,0 +1,51 @@
+
+
+
+ org.opennms.features
+ org.opennms.features.api-tokens
+ 35.0.5-SNAPSHOT
+
+ 4.0.0
+ org.opennms.features.api-tokens.api
+ bundle
+ OpenNMS :: Features :: API Tokens :: API
+
+
+
+ org.apache.felix
+ maven-bundle-plugin
+ true
+
+
+ JavaSE-17
+ ${project.groupId}.${project.artifactId}
+ ${project.version}
+
+
+
+
+
+
+
+ org.opennms
+ opennms-dao-api
+
+
+ com.fasterxml.jackson.core
+ jackson-annotations
+ provided
+
+
+ org.hibernate.javax.persistence
+ hibernate-jpa-2.0-api
+ provided
+
+
+ org.opennms.dependencies
+ jaxb-dependencies
+ pom
+ provided
+
+
+
diff --git a/features/api-tokens/api/src/main/java/org/opennms/features/apitokens/ApiToken.java b/features/api-tokens/api/src/main/java/org/opennms/features/apitokens/ApiToken.java
new file mode 100644
index 000000000000..74446005bfa5
--- /dev/null
+++ b/features/api-tokens/api/src/main/java/org/opennms/features/apitokens/ApiToken.java
@@ -0,0 +1,100 @@
+/*
+ * Licensed to The OpenNMS Group, Inc (TOG) under one or more
+ * contributor license agreements. See the LICENSE.md file
+ * distributed with this work for additional information
+ * regarding copyright ownership.
+ *
+ * TOG licenses this file to You under the GNU Affero General
+ * Public License Version 3 (the "License") or (at your option)
+ * any later version. You may not use this file except in
+ * compliance with the License. You may obtain a copy of the
+ * License at:
+ *
+ * https://www.gnu.org/licenses/agpl-3.0.txt
+ *
+ * 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 org.opennms.features.apitokens;
+
+import java.io.Serializable;
+import java.util.Date;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.GeneratedValue;
+import javax.persistence.GenerationType;
+import javax.persistence.Id;
+import javax.persistence.SequenceGenerator;
+import javax.persistence.Table;
+import javax.persistence.Temporal;
+import javax.persistence.TemporalType;
+import javax.xml.bind.annotation.XmlElement;
+import javax.xml.bind.annotation.XmlRootElement;
+
+@XmlRootElement(name = "apiToken")
+@Entity
+@Table(name = "api_tokens")
+public class ApiToken implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "apiTokensSeq")
+ @SequenceGenerator(name = "apiTokensSeq", sequenceName = "api_tokens_id_seq", allocationSize = 1)
+ private Integer id;
+
+ @com.fasterxml.jackson.annotation.JsonIgnore
+ @javax.xml.bind.annotation.XmlTransient
+ @Column(name = "token_hash", nullable = false, unique = true, length = 64)
+ private String tokenHash;
+
+ @XmlElement
+ @Column(name = "username", nullable = false, length = 256)
+ private String username;
+
+ @XmlElement
+ @Column(name = "description", length = 256)
+ private String description;
+
+ @XmlElement
+ @Column(name = "created_at", nullable = false)
+ @Temporal(TemporalType.TIMESTAMP)
+ private Date createdAt;
+
+ @XmlElement
+ @Column(name = "expires_at", nullable = false)
+ @Temporal(TemporalType.TIMESTAMP)
+ private Date expiresAt;
+
+ @XmlElement
+ @Column(name = "last_used_at")
+ @Temporal(TemporalType.TIMESTAMP)
+ private Date lastUsedAt;
+
+ public ApiToken() {}
+
+ public Integer getId() { return id; }
+ public void setId(Integer id) { this.id = id; }
+
+ public String getTokenHash() { return tokenHash; }
+ public void setTokenHash(String tokenHash) { this.tokenHash = tokenHash; }
+
+ public String getUsername() { return username; }
+ public void setUsername(String username) { this.username = username; }
+
+ public String getDescription() { return description; }
+ public void setDescription(String description) { this.description = description; }
+
+ public Date getCreatedAt() { return createdAt; }
+ public void setCreatedAt(Date createdAt) { this.createdAt = createdAt; }
+
+ public Date getExpiresAt() { return expiresAt; }
+ public void setExpiresAt(Date expiresAt) { this.expiresAt = expiresAt; }
+
+ public Date getLastUsedAt() { return lastUsedAt; }
+ public void setLastUsedAt(Date lastUsedAt) { this.lastUsedAt = lastUsedAt; }
+}
diff --git a/features/api-tokens/api/src/main/java/org/opennms/features/apitokens/ApiTokenCreateRequest.java b/features/api-tokens/api/src/main/java/org/opennms/features/apitokens/ApiTokenCreateRequest.java
new file mode 100644
index 000000000000..c2db214be4b3
--- /dev/null
+++ b/features/api-tokens/api/src/main/java/org/opennms/features/apitokens/ApiTokenCreateRequest.java
@@ -0,0 +1,36 @@
+/*
+ * Licensed to The OpenNMS Group, Inc (TOG) under one or more
+ * contributor license agreements. See the LICENSE.md file
+ * distributed with this work for additional information
+ * regarding copyright ownership.
+ *
+ * TOG licenses this file to You under the GNU Affero General
+ * Public License Version 3 (the "License") or (at your option)
+ * any later version. You may not use this file except in
+ * compliance with the License. You may obtain a copy of the
+ * License at:
+ *
+ * https://www.gnu.org/licenses/agpl-3.0.txt
+ *
+ * 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 org.opennms.features.apitokens;
+
+import javax.xml.bind.annotation.XmlRootElement;
+
+@XmlRootElement
+public class ApiTokenCreateRequest {
+ private String description;
+ private Integer expiresInDays;
+
+ public String getDescription() { return description; }
+ public void setDescription(String description) { this.description = description; }
+
+ public Integer getExpiresInDays() { return expiresInDays; }
+ public void setExpiresInDays(Integer expiresInDays) { this.expiresInDays = expiresInDays; }
+}
diff --git a/features/api-tokens/api/src/main/java/org/opennms/features/apitokens/ApiTokenCreateResponse.java b/features/api-tokens/api/src/main/java/org/opennms/features/apitokens/ApiTokenCreateResponse.java
new file mode 100644
index 000000000000..6f7a2b48ffee
--- /dev/null
+++ b/features/api-tokens/api/src/main/java/org/opennms/features/apitokens/ApiTokenCreateResponse.java
@@ -0,0 +1,50 @@
+/*
+ * Licensed to The OpenNMS Group, Inc (TOG) under one or more
+ * contributor license agreements. See the LICENSE.md file
+ * distributed with this work for additional information
+ * regarding copyright ownership.
+ *
+ * TOG licenses this file to You under the GNU Affero General
+ * Public License Version 3 (the "License") or (at your option)
+ * any later version. You may not use this file except in
+ * compliance with the License. You may obtain a copy of the
+ * License at:
+ *
+ * https://www.gnu.org/licenses/agpl-3.0.txt
+ *
+ * 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 org.opennms.features.apitokens;
+
+import java.util.Date;
+
+import javax.xml.bind.annotation.XmlRootElement;
+
+@XmlRootElement
+public class ApiTokenCreateResponse {
+ private Integer id;
+ private String token;
+ private String description;
+ private Date createdAt;
+ private Date expiresAt;
+
+ public Integer getId() { return id; }
+ public void setId(Integer id) { this.id = id; }
+
+ public String getToken() { return token; }
+ public void setToken(String token) { this.token = token; }
+
+ public String getDescription() { return description; }
+ public void setDescription(String description) { this.description = description; }
+
+ public Date getCreatedAt() { return createdAt; }
+ public void setCreatedAt(Date createdAt) { this.createdAt = createdAt; }
+
+ public Date getExpiresAt() { return expiresAt; }
+ public void setExpiresAt(Date expiresAt) { this.expiresAt = expiresAt; }
+}
diff --git a/features/api-tokens/api/src/main/java/org/opennms/features/apitokens/ApiTokenDao.java b/features/api-tokens/api/src/main/java/org/opennms/features/apitokens/ApiTokenDao.java
new file mode 100644
index 000000000000..1bb0fa2b1935
--- /dev/null
+++ b/features/api-tokens/api/src/main/java/org/opennms/features/apitokens/ApiTokenDao.java
@@ -0,0 +1,34 @@
+/*
+ * Licensed to The OpenNMS Group, Inc (TOG) under one or more
+ * contributor license agreements. See the LICENSE.md file
+ * distributed with this work for additional information
+ * regarding copyright ownership.
+ *
+ * TOG licenses this file to You under the GNU Affero General
+ * Public License Version 3 (the "License") or (at your option)
+ * any later version. You may not use this file except in
+ * compliance with the License. You may obtain a copy of the
+ * License at:
+ *
+ * https://www.gnu.org/licenses/agpl-3.0.txt
+ *
+ * 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 org.opennms.features.apitokens;
+
+import java.util.List;
+
+import org.opennms.netmgt.dao.api.OnmsDao;
+
+public interface ApiTokenDao extends OnmsDao {
+ ApiToken findByTokenHash(String tokenHash);
+ List findByUsername(String username);
+ int countByUsername(String username);
+ void deleteByUsername(String username);
+ String findUsernameByTokenId(Integer tokenId);
+}
diff --git a/features/api-tokens/api/src/main/java/org/opennms/features/apitokens/ApiTokenService.java b/features/api-tokens/api/src/main/java/org/opennms/features/apitokens/ApiTokenService.java
new file mode 100644
index 000000000000..4f6e099e35cc
--- /dev/null
+++ b/features/api-tokens/api/src/main/java/org/opennms/features/apitokens/ApiTokenService.java
@@ -0,0 +1,37 @@
+/*
+ * Licensed to The OpenNMS Group, Inc (TOG) under one or more
+ * contributor license agreements. See the LICENSE.md file
+ * distributed with this work for additional information
+ * regarding copyright ownership.
+ *
+ * TOG licenses this file to You under the GNU Affero General
+ * Public License Version 3 (the "License") or (at your option)
+ * any later version. You may not use this file except in
+ * compliance with the License. You may obtain a copy of the
+ * License at:
+ *
+ * https://www.gnu.org/licenses/agpl-3.0.txt
+ *
+ * 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 org.opennms.features.apitokens;
+
+import java.util.List;
+
+/**
+ * Service for managing API tokens. The {@code authenticate} method is called
+ * by the Spring Security filter on every request bearing a token.
+ */
+public interface ApiTokenService {
+ String authenticate(String plaintextToken);
+ ApiTokenCreateResponse createToken(String username, String description, Integer expiresInDays);
+ List listTokens(String username);
+ boolean revokeToken(Integer tokenId);
+ int revokeAllTokens(String username);
+ String getTokenOwner(Integer tokenId);
+}
diff --git a/features/api-tokens/impl/pom.xml b/features/api-tokens/impl/pom.xml
new file mode 100644
index 000000000000..1e55a7bc2da0
--- /dev/null
+++ b/features/api-tokens/impl/pom.xml
@@ -0,0 +1,64 @@
+
+
+
+ org.opennms.features
+ org.opennms.features.api-tokens
+ 35.0.5-SNAPSHOT
+
+ 4.0.0
+ org.opennms.features.api-tokens.impl
+ bundle
+ OpenNMS :: Features :: API Tokens :: Impl
+
+
+
+ org.apache.felix
+ maven-bundle-plugin
+ true
+
+
+ JavaSE-17
+ ${project.groupId}.${project.artifactId}
+ ${project.version}
+
+
+
+
+
+
+
+ org.opennms.features
+ org.opennms.features.api-tokens.api
+ ${project.version}
+
+
+ org.opennms
+ opennms-dao-api
+
+
+ org.opennms
+ opennms-dao
+
+
+ org.opennms.dependencies
+ spring-dependencies
+ pom
+
+
+ org.opennms.core
+ org.opennms.core.sysprops
+ ${project.version}
+
+
+ junit
+ junit
+ test
+
+
+ org.mockito
+ mockito-inline
+ test
+
+
+
diff --git a/features/api-tokens/impl/src/main/java/org/opennms/features/apitokens/impl/ApiTokenDaoHibernate.java b/features/api-tokens/impl/src/main/java/org/opennms/features/apitokens/impl/ApiTokenDaoHibernate.java
new file mode 100644
index 000000000000..94a26a7b7056
--- /dev/null
+++ b/features/api-tokens/impl/src/main/java/org/opennms/features/apitokens/impl/ApiTokenDaoHibernate.java
@@ -0,0 +1,61 @@
+/*
+ * Licensed to The OpenNMS Group, Inc (TOG) under one or more
+ * contributor license agreements. See the LICENSE.md file
+ * distributed with this work for additional information
+ * regarding copyright ownership.
+ *
+ * TOG licenses this file to You under the GNU Affero General
+ * Public License Version 3 (the "License") or (at your option)
+ * any later version. You may not use this file except in
+ * compliance with the License. You may obtain a copy of the
+ * License at:
+ *
+ * https://www.gnu.org/licenses/agpl-3.0.txt
+ *
+ * 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 org.opennms.features.apitokens.impl;
+
+import java.util.List;
+
+import org.opennms.features.apitokens.ApiToken;
+import org.opennms.features.apitokens.ApiTokenDao;
+import org.opennms.netmgt.dao.hibernate.AbstractDaoHibernate;
+
+public class ApiTokenDaoHibernate extends AbstractDaoHibernate implements ApiTokenDao {
+
+ public ApiTokenDaoHibernate() {
+ super(ApiToken.class);
+ }
+
+ @Override
+ public ApiToken findByTokenHash(String tokenHash) {
+ return findUnique("from ApiToken where tokenHash = ?", tokenHash);
+ }
+
+ @Override
+ public List findByUsername(String username) {
+ return find("from ApiToken where username = ? order by createdAt desc", username);
+ }
+
+ @Override
+ public int countByUsername(String username) {
+ return queryInt("select count(*) from ApiToken where username = ?", username);
+ }
+
+ @Override
+ public void deleteByUsername(String username) {
+ getHibernateTemplate().bulkUpdate("delete from ApiToken where username = ?", username);
+ }
+
+ @Override
+ public String findUsernameByTokenId(Integer tokenId) {
+ ApiToken token = get(tokenId);
+ return token != null ? token.getUsername() : null;
+ }
+}
diff --git a/features/api-tokens/impl/src/main/java/org/opennms/features/apitokens/impl/ApiTokenServiceImpl.java b/features/api-tokens/impl/src/main/java/org/opennms/features/apitokens/impl/ApiTokenServiceImpl.java
new file mode 100644
index 000000000000..8d88769da816
--- /dev/null
+++ b/features/api-tokens/impl/src/main/java/org/opennms/features/apitokens/impl/ApiTokenServiceImpl.java
@@ -0,0 +1,208 @@
+/*
+ * Licensed to The OpenNMS Group, Inc (TOG) under one or more
+ * contributor license agreements. See the LICENSE.md file
+ * distributed with this work for additional information
+ * regarding copyright ownership.
+ *
+ * TOG licenses this file to You under the GNU Affero General
+ * Public License Version 3 (the "License") or (at your option)
+ * any later version. You may not use this file except in
+ * compliance with the License. You may obtain a copy of the
+ * License at:
+ *
+ * https://www.gnu.org/licenses/agpl-3.0.txt
+ *
+ * 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 org.opennms.features.apitokens.impl;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+
+import org.opennms.features.apitokens.ApiToken;
+import org.opennms.features.apitokens.ApiTokenCreateResponse;
+import org.opennms.features.apitokens.ApiTokenDao;
+import org.opennms.features.apitokens.ApiTokenService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.transaction.annotation.Transactional;
+
+public class ApiTokenServiceImpl implements ApiTokenService {
+ private static final Logger LOG = LoggerFactory.getLogger(ApiTokenServiceImpl.class);
+ private static final String TOKEN_PREFIX = "onms_";
+ private static final long LAST_USED_DEBOUNCE_MS = 5 * 60 * 1000; // 5 minutes
+ private static final int MAX_DEBOUNCE_ENTRIES = 10_000;
+ private static final int MAX_CREATION_LOCK_ENTRIES = 10_000;
+
+ private final SecureRandom secureRandom = new SecureRandom();
+ private final Map lastUsedUpdateTimes = new ConcurrentHashMap<>();
+ private final ConcurrentHashMap creationLocks = new ConcurrentHashMap<>();
+
+ private ApiTokenDao apiTokenDao;
+ private int maxExpiryDays = 365;
+ private int defaultExpiryDays = 365;
+ private int maxTokensPerUser = 50;
+
+ @Override
+ @Transactional
+ public String authenticate(String plaintextToken) {
+ if (plaintextToken == null || !plaintextToken.startsWith(TOKEN_PREFIX)) {
+ return null;
+ }
+ String hash = sha256Hex(plaintextToken);
+ ApiToken token = apiTokenDao.findByTokenHash(hash);
+ if (token == null) {
+ return null;
+ }
+ if (token.getExpiresAt().before(new Date())) {
+ return null;
+ }
+ // Debounced last_used_at update
+ Long lastUpdate = lastUsedUpdateTimes.get(token.getId());
+ long now = System.currentTimeMillis();
+ if (lastUpdate == null || (now - lastUpdate) > LAST_USED_DEBOUNCE_MS) {
+ token.setLastUsedAt(new Date());
+ apiTokenDao.saveOrUpdate(token);
+ lastUsedUpdateTimes.put(token.getId(), now);
+ if (lastUsedUpdateTimes.size() > MAX_DEBOUNCE_ENTRIES) {
+ lastUsedUpdateTimes.clear();
+ }
+ }
+ return token.getUsername();
+ }
+
+ @Override
+ @Transactional
+ public ApiTokenCreateResponse createToken(String username, String description, Integer expiresInDays) {
+ if (maxExpiryDays == 0) {
+ throw new IllegalStateException("API token creation is disabled");
+ }
+ int days = expiresInDays != null ? expiresInDays : defaultExpiryDays;
+ if (days > maxExpiryDays) {
+ throw new IllegalArgumentException("Requested expiry exceeds the allowed maximum");
+ }
+ if (days <= 0) {
+ throw new IllegalArgumentException("Expiry must be positive");
+ }
+ if (description != null && description.length() > 256) {
+ throw new IllegalArgumentException("Description must be 256 characters or less");
+ }
+
+ // Per-user lock to prevent TOCTOU race on maxTokensPerUser
+ Lock lock = creationLocks.computeIfAbsent(username, k -> new ReentrantLock());
+ if (creationLocks.size() > MAX_CREATION_LOCK_ENTRIES) {
+ creationLocks.clear();
+ }
+ lock.lock();
+ try {
+ int count = apiTokenDao.countByUsername(username);
+ if (count >= maxTokensPerUser) {
+ throw new IllegalStateException("Maximum number of tokens reached for user");
+ }
+
+ // Generate token
+ byte[] randomBytes = new byte[32];
+ secureRandom.nextBytes(randomBytes);
+ String plaintextToken = TOKEN_PREFIX + bytesToHex(randomBytes);
+ String hash = sha256Hex(plaintextToken);
+
+ Instant now = Instant.now();
+ ApiToken token = new ApiToken();
+ token.setTokenHash(hash);
+ token.setUsername(username);
+ token.setDescription(description);
+ token.setCreatedAt(Date.from(now));
+ token.setExpiresAt(Date.from(now.plus(days, ChronoUnit.DAYS)));
+
+ apiTokenDao.save(token);
+ apiTokenDao.flush();
+
+ LOG.info("API token created for user {} (id={}, expires={})", username, token.getId(), token.getExpiresAt());
+
+ ApiTokenCreateResponse response = new ApiTokenCreateResponse();
+ response.setId(token.getId());
+ response.setToken(plaintextToken);
+ response.setDescription(description);
+ response.setCreatedAt(token.getCreatedAt());
+ response.setExpiresAt(token.getExpiresAt());
+ return response;
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public List listTokens(String username) {
+ return apiTokenDao.findByUsername(username);
+ }
+
+ @Override
+ @Transactional
+ public boolean revokeToken(Integer tokenId) {
+ ApiToken token = apiTokenDao.get(tokenId);
+ if (token == null) {
+ return false;
+ }
+ LOG.info("API token revoked for user {} (id={})", token.getUsername(), tokenId);
+ apiTokenDao.delete(token);
+ lastUsedUpdateTimes.remove(tokenId);
+ return true;
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public String getTokenOwner(Integer tokenId) {
+ return apiTokenDao.findUsernameByTokenId(tokenId);
+ }
+
+ @Override
+ @Transactional
+ public int revokeAllTokens(String username) {
+ int count = apiTokenDao.countByUsername(username);
+ if (count > 0) {
+ apiTokenDao.deleteByUsername(username);
+ lastUsedUpdateTimes.clear();
+ LOG.info("All {} API tokens revoked for user {}", count, username);
+ }
+ return count;
+ }
+
+ static String sha256Hex(String input) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ byte[] hashBytes = digest.digest(input.getBytes(StandardCharsets.UTF_8));
+ return bytesToHex(hashBytes);
+ } catch (NoSuchAlgorithmException e) {
+ throw new RuntimeException("SHA-256 not available", e);
+ }
+ }
+
+ private static String bytesToHex(byte[] bytes) {
+ StringBuilder sb = new StringBuilder(bytes.length * 2);
+ for (byte b : bytes) {
+ sb.append(String.format("%02x", b));
+ }
+ return sb.toString();
+ }
+
+ public void setApiTokenDao(ApiTokenDao apiTokenDao) { this.apiTokenDao = apiTokenDao; }
+ public void setMaxExpiryDays(int maxExpiryDays) { this.maxExpiryDays = maxExpiryDays; }
+ public void setDefaultExpiryDays(int defaultExpiryDays) { this.defaultExpiryDays = defaultExpiryDays; }
+ public void setMaxTokensPerUser(int maxTokensPerUser) { this.maxTokensPerUser = maxTokensPerUser; }
+}
diff --git a/features/api-tokens/impl/src/main/resources/META-INF/opennms/component-dao.xml b/features/api-tokens/impl/src/main/resources/META-INF/opennms/component-dao.xml
new file mode 100644
index 000000000000..fe41c32d7454
--- /dev/null
+++ b/features/api-tokens/impl/src/main/resources/META-INF/opennms/component-dao.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/features/api-tokens/impl/src/test/java/org/opennms/features/apitokens/impl/ApiTokenServiceImplTest.java b/features/api-tokens/impl/src/test/java/org/opennms/features/apitokens/impl/ApiTokenServiceImplTest.java
new file mode 100644
index 000000000000..959c5f5a21e2
--- /dev/null
+++ b/features/api-tokens/impl/src/test/java/org/opennms/features/apitokens/impl/ApiTokenServiceImplTest.java
@@ -0,0 +1,140 @@
+/*
+ * Licensed to The OpenNMS Group, Inc (TOG) under one or more
+ * contributor license agreements. See the LICENSE.md file
+ * distributed with this work for additional information
+ * regarding copyright ownership.
+ *
+ * TOG licenses this file to You under the GNU Affero General
+ * Public License Version 3 (the "License") or (at your option)
+ * any later version. You may not use this file except in
+ * compliance with the License. You may obtain a copy of the
+ * License at:
+ *
+ * https://www.gnu.org/licenses/agpl-3.0.txt
+ *
+ * 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 org.opennms.features.apitokens.impl;
+
+import static org.junit.Assert.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.opennms.features.apitokens.ApiToken;
+import org.opennms.features.apitokens.ApiTokenCreateResponse;
+import org.opennms.features.apitokens.ApiTokenDao;
+
+import java.util.Date;
+
+public class ApiTokenServiceImplTest {
+ private ApiTokenServiceImpl service;
+ private ApiTokenDao mockDao;
+
+ @Before
+ public void setUp() {
+ mockDao = mock(ApiTokenDao.class);
+ service = new ApiTokenServiceImpl();
+ service.setApiTokenDao(mockDao);
+ service.setMaxExpiryDays(365);
+ service.setDefaultExpiryDays(90);
+ service.setMaxTokensPerUser(50);
+ }
+
+ @Test
+ public void testCreateTokenReturnsPlaintext() {
+ when(mockDao.countByUsername("admin")).thenReturn(0);
+ when(mockDao.save(any(ApiToken.class))).thenReturn(1);
+
+ ApiTokenCreateResponse response = service.createToken("admin", "test token", 30);
+
+ assertNotNull(response.getToken());
+ assertTrue(response.getToken().startsWith("onms_"));
+ assertEquals("test token", response.getDescription());
+ assertNotNull(response.getCreatedAt());
+ assertNotNull(response.getExpiresAt());
+ verify(mockDao).save(any(ApiToken.class));
+ }
+
+ @Test
+ public void testCreateTokenUsesDefaultExpiry() {
+ when(mockDao.countByUsername("admin")).thenReturn(0);
+ when(mockDao.save(any(ApiToken.class))).thenReturn(1);
+
+ ApiTokenCreateResponse response = service.createToken("admin", "test", null);
+
+ long diffMs = response.getExpiresAt().getTime() - response.getCreatedAt().getTime();
+ long diffDays = diffMs / (1000 * 60 * 60 * 24);
+ assertEquals(90, diffDays);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testCreateTokenRejectsExcessiveExpiry() {
+ when(mockDao.countByUsername("admin")).thenReturn(0);
+ service.createToken("admin", "test", 999);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testCreateTokenRejectsLongDescription() {
+ when(mockDao.countByUsername("admin")).thenReturn(0);
+ String longDesc = "a".repeat(257);
+ service.createToken("admin", longDesc, 30);
+ }
+
+ @Test(expected = IllegalStateException.class)
+ public void testCreateTokenRejectsWhenMaxTokensReached() {
+ when(mockDao.countByUsername("admin")).thenReturn(50);
+ service.createToken("admin", "test", 30);
+ }
+
+ @Test
+ public void testAuthenticateValidToken() {
+ String plaintext = "onms_abcdef1234567890abcdef1234567890abcdef1234567890abcdef12345678";
+ String hash = ApiTokenServiceImpl.sha256Hex(plaintext);
+
+ ApiToken token = new ApiToken();
+ token.setId(1);
+ token.setTokenHash(hash);
+ token.setUsername("admin");
+ token.setExpiresAt(new Date(System.currentTimeMillis() + 86400000));
+
+ when(mockDao.findByTokenHash(hash)).thenReturn(token);
+
+ String result = service.authenticate(plaintext);
+ assertEquals("admin", result);
+ }
+
+ @Test
+ public void testAuthenticateExpiredToken() {
+ String plaintext = "onms_abcdef1234567890abcdef1234567890abcdef1234567890abcdef12345678";
+ String hash = ApiTokenServiceImpl.sha256Hex(plaintext);
+
+ ApiToken token = new ApiToken();
+ token.setId(1);
+ token.setTokenHash(hash);
+ token.setUsername("admin");
+ token.setExpiresAt(new Date(System.currentTimeMillis() - 86400000));
+
+ when(mockDao.findByTokenHash(hash)).thenReturn(token);
+
+ assertNull(service.authenticate(plaintext));
+ }
+
+ @Test
+ public void testAuthenticateInvalidToken() {
+ when(mockDao.findByTokenHash(anyString())).thenReturn(null);
+ assertNull(service.authenticate("onms_doesnotexist"));
+ }
+
+ @Test
+ public void testAuthenticateNullAndNonPrefixed() {
+ assertNull(service.authenticate(null));
+ assertNull(service.authenticate("not_a_token"));
+ }
+}
diff --git a/features/api-tokens/pom.xml b/features/api-tokens/pom.xml
new file mode 100644
index 000000000000..dff8837958ba
--- /dev/null
+++ b/features/api-tokens/pom.xml
@@ -0,0 +1,19 @@
+
+
+
+ org.opennms
+ org.opennms.features
+ 35.0.5-SNAPSHOT
+
+ 4.0.0
+ org.opennms.features
+ org.opennms.features.api-tokens
+ pom
+ OpenNMS :: Features :: API Tokens
+
+ api
+ impl
+ shell
+
+
diff --git a/features/api-tokens/shell/pom.xml b/features/api-tokens/shell/pom.xml
new file mode 100644
index 000000000000..5dc15110522e
--- /dev/null
+++ b/features/api-tokens/shell/pom.xml
@@ -0,0 +1,48 @@
+
+
+
+ org.opennms.features
+ org.opennms.features.api-tokens
+ 35.0.5-SNAPSHOT
+
+ 4.0.0
+ org.opennms.features.api-tokens.shell
+ bundle
+ OpenNMS :: Features :: API Tokens :: Shell
+
+
+
+ org.apache.felix
+ maven-bundle-plugin
+ true
+
+
+ JavaSE-17
+ ${project.groupId}.${project.artifactId}
+ ${project.version}
+ org.opennms.features.apitokens.shell
+
+
+
+
+
+
+
+ org.opennms.features
+ org.opennms.features.api-tokens.api
+ ${project.version}
+
+
+ org.apache.karaf.shell
+ org.apache.karaf.shell.core
+ ${karafVersion}
+ provided
+
+
+ org.osgi
+ osgi.core
+ provided
+
+
+
diff --git a/features/api-tokens/shell/src/main/java/org/opennms/features/apitokens/shell/ApiTokenGenerateCommand.java b/features/api-tokens/shell/src/main/java/org/opennms/features/apitokens/shell/ApiTokenGenerateCommand.java
new file mode 100644
index 000000000000..fa1581d98c97
--- /dev/null
+++ b/features/api-tokens/shell/src/main/java/org/opennms/features/apitokens/shell/ApiTokenGenerateCommand.java
@@ -0,0 +1,62 @@
+/*
+ * Licensed to The OpenNMS Group, Inc (TOG) under one or more
+ * contributor license agreements. See the LICENSE.md file
+ * distributed with this work for additional information
+ * regarding copyright ownership.
+ *
+ * TOG licenses this file to You under the GNU Affero General
+ * Public License Version 3 (the "License") or (at your option)
+ * any later version. You may not use this file except in
+ * compliance with the License. You may obtain a copy of the
+ * License at:
+ *
+ * https://www.gnu.org/licenses/agpl-3.0.txt
+ *
+ * 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 org.opennms.features.apitokens.shell;
+
+import org.apache.karaf.shell.api.action.Action;
+import org.apache.karaf.shell.api.action.Argument;
+import org.apache.karaf.shell.api.action.Command;
+import org.apache.karaf.shell.api.action.Option;
+import org.apache.karaf.shell.api.action.lifecycle.Reference;
+import org.apache.karaf.shell.api.action.lifecycle.Service;
+import org.opennms.features.apitokens.ApiTokenCreateResponse;
+import org.opennms.features.apitokens.ApiTokenService;
+
+@Command(scope = "opennms", name = "api-token-generate", description = "Generate an API token for a user")
+@Service
+public class ApiTokenGenerateCommand implements Action {
+
+ @Reference
+ private ApiTokenService apiTokenService;
+
+ @Argument(index = 0, name = "username", description = "Username to generate token for", required = true)
+ private String username;
+
+ @Option(name = "--description", aliases = {"-d"}, description = "Token description")
+ private String description;
+
+ @Option(name = "--expiry-days", aliases = {"-e"}, description = "Token expiry in days")
+ private Integer expiryDays;
+
+ @Override
+ public Object execute() throws Exception {
+ ApiTokenCreateResponse response = apiTokenService.createToken(username, description, expiryDays);
+ System.out.println("Token generated successfully.");
+ System.out.println("Token: " + response.getToken());
+ System.out.println("ID: " + response.getId());
+ System.out.println("Description: " + (response.getDescription() != null ? response.getDescription() : ""));
+ System.out.println("Created: " + response.getCreatedAt());
+ System.out.println("Expires: " + response.getExpiresAt());
+ System.out.println();
+ System.out.println("WARNING: This token will not be shown again. Copy it now.");
+ return null;
+ }
+}
diff --git a/features/api-tokens/shell/src/main/java/org/opennms/features/apitokens/shell/ApiTokenListCommand.java b/features/api-tokens/shell/src/main/java/org/opennms/features/apitokens/shell/ApiTokenListCommand.java
new file mode 100644
index 000000000000..50934c0e7460
--- /dev/null
+++ b/features/api-tokens/shell/src/main/java/org/opennms/features/apitokens/shell/ApiTokenListCommand.java
@@ -0,0 +1,68 @@
+/*
+ * Licensed to The OpenNMS Group, Inc (TOG) under one or more
+ * contributor license agreements. See the LICENSE.md file
+ * distributed with this work for additional information
+ * regarding copyright ownership.
+ *
+ * TOG licenses this file to You under the GNU Affero General
+ * Public License Version 3 (the "License") or (at your option)
+ * any later version. You may not use this file except in
+ * compliance with the License. You may obtain a copy of the
+ * License at:
+ *
+ * https://www.gnu.org/licenses/agpl-3.0.txt
+ *
+ * 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 org.opennms.features.apitokens.shell;
+
+import java.util.List;
+
+import org.apache.karaf.shell.api.action.Action;
+import org.apache.karaf.shell.api.action.Argument;
+import org.apache.karaf.shell.api.action.Command;
+import org.apache.karaf.shell.api.action.lifecycle.Reference;
+import org.apache.karaf.shell.api.action.lifecycle.Service;
+import org.opennms.features.apitokens.ApiToken;
+import org.opennms.features.apitokens.ApiTokenService;
+
+@Command(scope = "opennms", name = "api-token-list", description = "List API tokens for a user")
+@Service
+public class ApiTokenListCommand implements Action {
+
+ @Reference
+ private ApiTokenService apiTokenService;
+
+ @Argument(index = 0, name = "username", description = "Username to list tokens for", required = true)
+ private String username;
+
+ @Override
+ public Object execute() throws Exception {
+ List tokens = apiTokenService.listTokens(username);
+ if (tokens.isEmpty()) {
+ System.out.println("No tokens found for user: " + username);
+ return null;
+ }
+ System.out.printf("%-6s %-20s %-24s %-24s %-24s%n", "ID", "Description", "Created", "Expires", "Last Used");
+ System.out.println("-".repeat(100));
+ for (ApiToken token : tokens) {
+ System.out.printf("%-6d %-20s %-24s %-24s %-24s%n",
+ token.getId(),
+ truncate(token.getDescription(), 20),
+ token.getCreatedAt(),
+ token.getExpiresAt(),
+ token.getLastUsedAt() != null ? token.getLastUsedAt() : "never");
+ }
+ return null;
+ }
+
+ private static String truncate(String s, int max) {
+ if (s == null) return "";
+ return s.length() <= max ? s : s.substring(0, max - 3) + "...";
+ }
+}
diff --git a/features/api-tokens/shell/src/main/java/org/opennms/features/apitokens/shell/ApiTokenRevokeAllCommand.java b/features/api-tokens/shell/src/main/java/org/opennms/features/apitokens/shell/ApiTokenRevokeAllCommand.java
new file mode 100644
index 000000000000..d4f12a8a9458
--- /dev/null
+++ b/features/api-tokens/shell/src/main/java/org/opennms/features/apitokens/shell/ApiTokenRevokeAllCommand.java
@@ -0,0 +1,47 @@
+/*
+ * Licensed to The OpenNMS Group, Inc (TOG) under one or more
+ * contributor license agreements. See the LICENSE.md file
+ * distributed with this work for additional information
+ * regarding copyright ownership.
+ *
+ * TOG licenses this file to You under the GNU Affero General
+ * Public License Version 3 (the "License") or (at your option)
+ * any later version. You may not use this file except in
+ * compliance with the License. You may obtain a copy of the
+ * License at:
+ *
+ * https://www.gnu.org/licenses/agpl-3.0.txt
+ *
+ * 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 org.opennms.features.apitokens.shell;
+
+import org.apache.karaf.shell.api.action.Action;
+import org.apache.karaf.shell.api.action.Argument;
+import org.apache.karaf.shell.api.action.Command;
+import org.apache.karaf.shell.api.action.lifecycle.Reference;
+import org.apache.karaf.shell.api.action.lifecycle.Service;
+import org.opennms.features.apitokens.ApiTokenService;
+
+@Command(scope = "opennms", name = "api-token-revoke-all", description = "Revoke all API tokens for a user")
+@Service
+public class ApiTokenRevokeAllCommand implements Action {
+
+ @Reference
+ private ApiTokenService apiTokenService;
+
+ @Argument(index = 0, name = "username", description = "Username to revoke all tokens for", required = true)
+ private String username;
+
+ @Override
+ public Object execute() throws Exception {
+ int count = apiTokenService.revokeAllTokens(username);
+ System.out.println("Revoked " + count + " token(s) for user: " + username);
+ return null;
+ }
+}
diff --git a/features/api-tokens/shell/src/main/java/org/opennms/features/apitokens/shell/ApiTokenRevokeCommand.java b/features/api-tokens/shell/src/main/java/org/opennms/features/apitokens/shell/ApiTokenRevokeCommand.java
new file mode 100644
index 000000000000..464a406a60d3
--- /dev/null
+++ b/features/api-tokens/shell/src/main/java/org/opennms/features/apitokens/shell/ApiTokenRevokeCommand.java
@@ -0,0 +1,51 @@
+/*
+ * Licensed to The OpenNMS Group, Inc (TOG) under one or more
+ * contributor license agreements. See the LICENSE.md file
+ * distributed with this work for additional information
+ * regarding copyright ownership.
+ *
+ * TOG licenses this file to You under the GNU Affero General
+ * Public License Version 3 (the "License") or (at your option)
+ * any later version. You may not use this file except in
+ * compliance with the License. You may obtain a copy of the
+ * License at:
+ *
+ * https://www.gnu.org/licenses/agpl-3.0.txt
+ *
+ * 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 org.opennms.features.apitokens.shell;
+
+import org.apache.karaf.shell.api.action.Action;
+import org.apache.karaf.shell.api.action.Argument;
+import org.apache.karaf.shell.api.action.Command;
+import org.apache.karaf.shell.api.action.lifecycle.Reference;
+import org.apache.karaf.shell.api.action.lifecycle.Service;
+import org.opennms.features.apitokens.ApiTokenService;
+
+@Command(scope = "opennms", name = "api-token-revoke", description = "Revoke a specific API token by ID")
+@Service
+public class ApiTokenRevokeCommand implements Action {
+
+ @Reference
+ private ApiTokenService apiTokenService;
+
+ @Argument(index = 0, name = "token-id", description = "Token ID to revoke", required = true)
+ private Integer tokenId;
+
+ @Override
+ public Object execute() throws Exception {
+ boolean revoked = apiTokenService.revokeToken(tokenId);
+ if (revoked) {
+ System.out.println("Token " + tokenId + " revoked.");
+ } else {
+ System.out.println("Token " + tokenId + " not found.");
+ }
+ return null;
+ }
+}
diff --git a/features/pom.xml b/features/pom.xml
index 4a34a6132b5c..96f81cb34d90 100644
--- a/features/pom.xml
+++ b/features/pom.xml
@@ -24,6 +24,7 @@
activemqapi-layer
+ api-tokensalarms
diff --git a/features/springframework-security/pom.xml b/features/springframework-security/pom.xml
index fc85a71b8f58..b33a25b6460c 100644
--- a/features/springframework-security/pom.xml
+++ b/features/springframework-security/pom.xml
@@ -77,6 +77,11 @@
org.opennmsopennms-web-api
+
+ org.opennms.features
+ org.opennms.features.api-tokens.api
+ ${project.version}
+ org.eclipse.jettyjetty-server
diff --git a/features/springframework-security/src/main/java/org/opennms/web/springframework/security/ApiTokenAuthenticationFilter.java b/features/springframework-security/src/main/java/org/opennms/web/springframework/security/ApiTokenAuthenticationFilter.java
new file mode 100644
index 000000000000..e4171fd33f2d
--- /dev/null
+++ b/features/springframework-security/src/main/java/org/opennms/web/springframework/security/ApiTokenAuthenticationFilter.java
@@ -0,0 +1,94 @@
+/*
+ * Licensed to The OpenNMS Group, Inc (TOG) under one or more
+ * contributor license agreements. See the LICENSE.md file
+ * distributed with this work for additional information
+ * regarding copyright ownership.
+ *
+ * TOG licenses this file to You under the GNU Affero General
+ * Public License Version 3 (the "License") or (at your option)
+ * any later version. You may not use this file except in
+ * compliance with the License. You may obtain a copy of the
+ * License at:
+ *
+ * https://www.gnu.org/licenses/agpl-3.0.txt
+ *
+ * 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 org.opennms.web.springframework.security;
+
+import java.io.IOException;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.opennms.features.apitokens.ApiTokenService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+/**
+ * Spring Security filter that authenticates requests bearing an API token
+ * in the Authorization header (Bearer scheme). If the token is valid,
+ * sets the SecurityContext; otherwise passes through to the next filter.
+ */
+public class ApiTokenAuthenticationFilter extends OncePerRequestFilter {
+ private static final Logger LOG = LoggerFactory.getLogger(ApiTokenAuthenticationFilter.class);
+ private static final String BEARER_PREFIX = "Bearer ";
+ private static final String TOKEN_PREFIX = "onms_";
+
+ private ApiTokenService apiTokenService;
+ private SpringSecurityUserDao userDao;
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
+ FilterChain filterChain) throws ServletException, IOException {
+ if (SecurityContextHolder.getContext().getAuthentication() == null) {
+ String authHeader = request.getHeader("Authorization");
+ if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) {
+ String token = authHeader.substring(BEARER_PREFIX.length()).trim();
+ if (token.startsWith(TOKEN_PREFIX)) {
+ if (apiTokenService == null) {
+ LOG.debug("API token service not injected, skipping token auth");
+ } else {
+ try {
+ String username = apiTokenService.authenticate(token);
+ if (username != null) {
+ SpringSecurityUser user = userDao.getByUsername(username);
+ if (user != null) {
+ if (user.getAuthorities().isEmpty()) {
+ user.addAuthority(SpringSecurityUserDao.ROLE_USER);
+ }
+ UsernamePasswordAuthenticationToken auth =
+ new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
+ SecurityContextHolder.getContext().setAuthentication(auth);
+ LOG.debug("API token authentication successful for user: {}", username);
+ }
+ }
+ } catch (Exception e) {
+ // Service unavailable (OSGi proxy, network, etc.) — fall through to basic auth
+ LOG.debug("API token authentication failed, skipping token auth", e);
+ }
+ }
+ }
+ }
+ }
+ filterChain.doFilter(request, response);
+ }
+
+ public void setApiTokenService(ApiTokenService apiTokenService) {
+ this.apiTokenService = apiTokenService;
+ }
+
+ public void setUserDao(SpringSecurityUserDao userDao) {
+ this.userDao = userDao;
+ }
+}
diff --git a/features/springframework-security/src/test/java/org/opennms/web/springframework/security/ApiTokenAuthenticationFilterTest.java b/features/springframework-security/src/test/java/org/opennms/web/springframework/security/ApiTokenAuthenticationFilterTest.java
new file mode 100644
index 000000000000..9074e4e25834
--- /dev/null
+++ b/features/springframework-security/src/test/java/org/opennms/web/springframework/security/ApiTokenAuthenticationFilterTest.java
@@ -0,0 +1,165 @@
+/*
+ * Licensed to The OpenNMS Group, Inc (TOG) under one or more
+ * contributor license agreements. See the LICENSE.md file
+ * distributed with this work for additional information
+ * regarding copyright ownership.
+ *
+ * TOG licenses this file to You under the GNU Affero General
+ * Public License Version 3 (the "License") or (at your option)
+ * any later version. You may not use this file except in
+ * compliance with the License. You may obtain a copy of the
+ * License at:
+ *
+ * https://www.gnu.org/licenses/agpl-3.0.txt
+ *
+ * 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 org.opennms.web.springframework.security;
+
+import static org.junit.Assert.*;
+import static org.mockito.Mockito.*;
+
+import javax.servlet.FilterChain;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.opennms.features.apitokens.ApiTokenService;
+import org.opennms.netmgt.model.OnmsUser;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+import java.util.Collections;
+
+public class ApiTokenAuthenticationFilterTest {
+ private ApiTokenAuthenticationFilter filter;
+ private ApiTokenService mockService;
+ private SpringSecurityUserDao mockUserDao;
+ private HttpServletRequest mockRequest;
+ private HttpServletResponse mockResponse;
+ private FilterChain mockChain;
+
+ @Before
+ public void setUp() {
+ mockService = mock(ApiTokenService.class);
+ mockUserDao = mock(SpringSecurityUserDao.class);
+ mockRequest = mock(HttpServletRequest.class);
+ mockResponse = mock(HttpServletResponse.class);
+ mockChain = mock(FilterChain.class);
+ SecurityContextHolder.clearContext();
+
+ filter = new ApiTokenAuthenticationFilter();
+ filter.setApiTokenService(mockService);
+ filter.setUserDao(mockUserDao);
+ }
+
+ @After
+ public void tearDown() {
+ SecurityContextHolder.clearContext();
+ }
+
+ @Test
+ public void testValidBearerTokenSetsAuthentication() throws Exception {
+ when(mockRequest.getHeader("Authorization")).thenReturn("Bearer onms_abc123");
+ when(mockService.authenticate("onms_abc123")).thenReturn("admin");
+
+ OnmsUser onmsUser = new OnmsUser();
+ onmsUser.setUsername("admin");
+ SpringSecurityUser user = new SpringSecurityUser(onmsUser);
+ user.setAuthorities(Collections.singletonList(new SimpleGrantedAuthority("ROLE_ADMIN")));
+ when(mockUserDao.getByUsername("admin")).thenReturn(user);
+
+ filter.doFilterInternal(mockRequest, mockResponse, mockChain);
+
+ assertNotNull(SecurityContextHolder.getContext().getAuthentication());
+ assertEquals("admin", ((SpringSecurityUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal()).getUsername());
+ verify(mockChain).doFilter(mockRequest, mockResponse);
+ }
+
+ @Test
+ public void testInvalidTokenPassesThrough() throws Exception {
+ when(mockRequest.getHeader("Authorization")).thenReturn("Bearer onms_invalid");
+ when(mockService.authenticate("onms_invalid")).thenReturn(null);
+
+ filter.doFilterInternal(mockRequest, mockResponse, mockChain);
+
+ assertNull(SecurityContextHolder.getContext().getAuthentication());
+ verify(mockChain).doFilter(mockRequest, mockResponse);
+ }
+
+ @Test
+ public void testNoBearerHeaderPassesThrough() throws Exception {
+ when(mockRequest.getHeader("Authorization")).thenReturn(null);
+
+ filter.doFilterInternal(mockRequest, mockResponse, mockChain);
+
+ assertNull(SecurityContextHolder.getContext().getAuthentication());
+ verify(mockChain).doFilter(mockRequest, mockResponse);
+ }
+
+ @Test
+ public void testBasicAuthHeaderIgnored() throws Exception {
+ when(mockRequest.getHeader("Authorization")).thenReturn("Basic dXNlcjpwYXNz");
+
+ filter.doFilterInternal(mockRequest, mockResponse, mockChain);
+
+ assertNull(SecurityContextHolder.getContext().getAuthentication());
+ verify(mockChain).doFilter(mockRequest, mockResponse);
+ verifyNoInteractions(mockService);
+ }
+
+ @Test
+ public void testServiceUnavailablePassesThrough() throws Exception {
+ when(mockRequest.getHeader("Authorization")).thenReturn("Bearer onms_abc123");
+ when(mockService.authenticate("onms_abc123")).thenThrow(new IllegalStateException("Service unavailable"));
+
+ filter.doFilterInternal(mockRequest, mockResponse, mockChain);
+
+ assertNull(SecurityContextHolder.getContext().getAuthentication());
+ verify(mockChain).doFilter(mockRequest, mockResponse);
+ }
+
+ @Test
+ public void testOsgiServiceExceptionPassesThrough() throws Exception {
+ when(mockRequest.getHeader("Authorization")).thenReturn("Bearer onms_abc123");
+ when(mockService.authenticate("onms_abc123")).thenThrow(new RuntimeException("OSGi ServiceException"));
+
+ filter.doFilterInternal(mockRequest, mockResponse, mockChain);
+
+ assertNull(SecurityContextHolder.getContext().getAuthentication());
+ verify(mockChain).doFilter(mockRequest, mockResponse);
+ }
+
+ @Test
+ public void testNullServicePassesThrough() throws Exception {
+ when(mockRequest.getHeader("Authorization")).thenReturn("Bearer onms_abc123");
+
+ ApiTokenAuthenticationFilter nullServiceFilter = new ApiTokenAuthenticationFilter();
+ // apiTokenService is null — not set
+ nullServiceFilter.setUserDao(mockUserDao);
+
+ nullServiceFilter.doFilterInternal(mockRequest, mockResponse, mockChain);
+
+ assertNull(SecurityContextHolder.getContext().getAuthentication());
+ verify(mockChain).doFilter(mockRequest, mockResponse);
+ }
+
+ @Test
+ public void testDeletedUserPassesThrough() throws Exception {
+ when(mockRequest.getHeader("Authorization")).thenReturn("Bearer onms_abc123");
+ when(mockService.authenticate("onms_abc123")).thenReturn("deleteduser");
+ when(mockUserDao.getByUsername("deleteduser")).thenReturn(null);
+
+ filter.doFilterInternal(mockRequest, mockResponse, mockChain);
+
+ assertNull(SecurityContextHolder.getContext().getAuthentication());
+ verify(mockChain).doFilter(mockRequest, mockResponse);
+ }
+}
diff --git a/opennms-base-assembly/pom.xml b/opennms-base-assembly/pom.xml
index 9eaec8b19cc3..69241bc4416b 100644
--- a/opennms-base-assembly/pom.xml
+++ b/opennms-base-assembly/pom.xml
@@ -828,6 +828,11 @@
org.opennms.features.endpoints.grafana.persistence.impl${project.version}
+
+ org.opennms.features
+ org.opennms.features.api-tokens.impl
+ ${project.version}
+ org.opennms.features.timeformatorg.opennms.features.timeformat.api
diff --git a/opennms-base-assembly/src/main/filtered/etc/opennms.properties.d/api-tokens.properties b/opennms-base-assembly/src/main/filtered/etc/opennms.properties.d/api-tokens.properties
new file mode 100644
index 000000000000..753432712357
--- /dev/null
+++ b/opennms-base-assembly/src/main/filtered/etc/opennms.properties.d/api-tokens.properties
@@ -0,0 +1,19 @@
+# API Token Authentication
+#
+# API tokens allow users to generate long-lived bearer tokens for
+# programmatic REST API access without exposing their password.
+# Tokens are created once, displayed once, and stored as SHA-256 hashes.
+#
+# Authenticate with: Authorization: Bearer onms_
+
+# Maximum allowed token lifetime in days. Set to 0 to disable token creation.
+# Default: 365
+#org.opennms.api.tokens.max-expiry-days=365
+
+# Default token lifetime in days when no expiry is specified at creation.
+# Default: 365
+#org.opennms.api.tokens.default-expiry-days=365
+
+# Maximum number of active tokens per user.
+# Default: 50
+#org.opennms.api.tokens.max-tokens-per-user=50
diff --git a/opennms-dao/pom.xml b/opennms-dao/pom.xml
index c862d8371863..8f9826d45db0 100644
--- a/opennms-dao/pom.xml
+++ b/opennms-dao/pom.xml
@@ -126,6 +126,11 @@
org.opennms.features.enlinkdorg.opennms.features.enlinkd.persistence.api
+
+ org.opennms.features
+ org.opennms.features.api-tokens.api
+ ${project.version}
+ org.opennmsopennms-rrd-api
diff --git a/opennms-dao/src/main/resources/META-INF/opennms/applicationContext-shared.xml b/opennms-dao/src/main/resources/META-INF/opennms/applicationContext-shared.xml
index 7b98cf2e0862..0a0b9df9e37e 100644
--- a/opennms-dao/src/main/resources/META-INF/opennms/applicationContext-shared.xml
+++ b/opennms-dao/src/main/resources/META-INF/opennms/applicationContext-shared.xml
@@ -46,6 +46,7 @@
org.opennms.netmgt.telemetry.protocols.bmp.persistence.apiorg.opennms.features.deviceconfig.persistence.apiorg.opennms.features.usageanalytics.api
+ org.opennms.features.apitokens
+
+
+
+
+
+
+
@@ -194,6 +204,7 @@
+
@@ -480,6 +491,14 @@
+
+
+
+
+
+
+
+
diff --git a/opennms-webapp/src/main/webapp/account/selfService/apiTokens.jsp b/opennms-webapp/src/main/webapp/account/selfService/apiTokens.jsp
new file mode 100644
index 000000000000..10df4f16f900
--- /dev/null
+++ b/opennms-webapp/src/main/webapp/account/selfService/apiTokens.jsp
@@ -0,0 +1,205 @@
+<%--
+
+ Licensed to The OpenNMS Group, Inc (TOG) under one or more
+ contributor license agreements. See the LICENSE.md file
+ distributed with this work for additional information
+ regarding copyright ownership.
+
+ TOG licenses this file to You under the GNU Affero General
+ Public License Version 3 (the "License") or (at your option)
+ any later version. You may not use this file except in
+ compliance with the License. You may obtain a copy of the
+ License at:
+
+ https://www.gnu.org/licenses/agpl-3.0.txt
+
+ 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.
+
+--%>
+<%@page language="java" contentType="text/html" session="true" %>
+
+<%@ page import="org.opennms.web.utils.Bootstrap" %>
+<%
+ String remoteUser = request.getRemoteUser();
+ // Escape for safe interpolation into JavaScript string literal
+ String jsRemoteUser = remoteUser.replace("\\", "\\\\")
+ .replace("'", "\\'")
+ .replace("\"", "\\\"")
+ .replace("\n", "\\n")
+ .replace("\r", "\\r")
+ .replace("<", "\\x3c")
+ .replace(">", "\\x3e");
+%>
+<% Bootstrap.with(pageContext)
+ .headTitle("API Tokens")
+ .breadcrumb("Self-Service", "account/selfService/index.jsp")
+ .breadcrumb("API Tokens")
+ .build(request);
+%>
+
+
+
+
+
+
+ API Tokens
+
+
+
+ Token created! Copy this token now. It will not be shown again.
+
+
+
+
+
+
+
+
+
+
+
+
+
Description
+
Created
+
Expires
+
Last Used
+
+
+
+
+
+
+
No API tokens found.
+
+
+
Generate New Token
+
+
+
+
+
+
+
+
About API Tokens
+
+
API tokens allow programmatic access to the OpenNMS REST API without using your password.
+
Use tokens with the Authorization: Bearer <token> HTTP header.
+
Tokens inherit your current roles and permissions.
- Currently, account self-service is limited to password changes. Note that in environments using a
- reduced sign-on system such as LDAP, changing your password here may have no effect and may not even be
- possible.
+ Account self-service options include password changes and API token management. Note that in environments
+ using a reduced sign-on system such as LDAP, changing your password here may have no effect and may not
+ even be possible.
If you require further changes to your account, please contact the person within your organization responsible for
diff --git a/opennms-webapp/src/main/webapp/admin/userGroupView/users/apiTokens.jsp b/opennms-webapp/src/main/webapp/admin/userGroupView/users/apiTokens.jsp
new file mode 100644
index 000000000000..aba63eeda3f9
--- /dev/null
+++ b/opennms-webapp/src/main/webapp/admin/userGroupView/users/apiTokens.jsp
@@ -0,0 +1,204 @@
+<%--
+
+ Licensed to The OpenNMS Group, Inc (TOG) under one or more
+ contributor license agreements. See the LICENSE.md file
+ distributed with this work for additional information
+ regarding copyright ownership.
+
+ TOG licenses this file to You under the GNU Affero General
+ Public License Version 3 (the "License") or (at your option)
+ any later version. You may not use this file except in
+ compliance with the License. You may obtain a copy of the
+ License at:
+
+ https://www.gnu.org/licenses/agpl-3.0.txt
+
+ 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.
+
+--%>
+<%@page language="java" contentType="text/html" session="true"
+ import="org.opennms.core.utils.WebSecurityUtils,
+ org.opennms.web.utils.Bootstrap"
+%>
+
+<%
+ String userID = request.getParameter("userID");
+ if (userID == null || userID.trim().isEmpty()) {
+ throw new ServletException("userID parameter required");
+ }
+ String htmlUserID = WebSecurityUtils.sanitizeString(userID);
+ // For JavaScript context: escape backslash, quotes, and control chars
+ String jsUserID = userID.replace("\\", "\\\\")
+ .replace("'", "\\'")
+ .replace("\"", "\\\"")
+ .replace("\n", "\\n")
+ .replace("\r", "\\r")
+ .replace("<", "\\x3c")
+ .replace(">", "\\x3e");
+%>
+
+<% Bootstrap.with(pageContext)
+ .headTitle("API Tokens for " + htmlUserID)
+ .breadcrumb("Admin", "admin/index.jsp")
+ .breadcrumb("Users", "admin/userGroupView/users/list.jsp")
+ .breadcrumb("User Detail", "admin/userGroupView/users/userDetail.jsp?userID=" + java.net.URLEncoder.encode(userID, "UTF-8"))
+ .breadcrumb("API Tokens")
+ .build(request);
+%>
+
+
+
+
+
+
+ API Tokens for user: <%= htmlUserID %>
+
+
+
+ Token created! Copy this token now. It will not be shown again.
+