You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@knox.apache.org by sm...@apache.org on 2026/09/23 16:02:52 UTC

(knox) branch master updated: KNOX-3480: Support user-supplied clientId in the Client Credentials endpoint (#1422)

This is an automated email from the ASF dual-hosted git repository.

smolnar82 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/knox.git


The following commit(s) were added to refs/heads/master by this push:
     new 189d1f73b KNOX-3480: Support user-supplied clientId in the Client Credentials endpoint (#1422)
189d1f73b is described below

commit 189d1f73b1b46b17e99dbb76d54ccdad9f490be8
Author: Sandor Molnar <sm...@apache.org>
AuthorDate: Wed Sep 23 18:02:47 2026 +0200

    KNOX-3480: Support user-supplied clientId in the Client Credentials endpoint (#1422)
---
 .../build/conf/topologies/clientid-default.xml     |  62 +++++++
 .../workflows/build/conf/topologies/clientid.xml   |  67 +++++++
 .../workflows/tests/test_clientid_credentials.py   | 193 +++++++++++++++++++++
 .../token/impl/DefaultTokenStateService.java       |   6 +
 .../services/token/impl/JDBCTokenStateService.java |  17 +-
 .../token/impl/TokenStateServiceMessages.java      |   3 +
 .../token/impl/JDBCTokenStateServiceTest.java      |  19 ++
 .../knoxtoken/ClientCredentialsResource.java       |  57 +++++-
 .../gateway/service/knoxtoken/TokenResource.java   |  12 +-
 .../knoxtoken/TokenServiceResourceTest.java        | 133 ++++++++++++++
 .../services/security/token/JWTokenAttributes.java |   9 +-
 .../security/token/JWTokenAttributesBuilder.java   |   9 +-
 .../token/TokenAlreadyExistsException.java         |  41 +++++
 .../services/security/token/TokenUtils.java        |  33 +++-
 .../services/security/token/impl/JWTToken.java     |   8 +-
 .../services/security/token/TokenUtilsTest.java    |  45 +++++
 .../services/security/token/impl/JWTTokenTest.java |  21 +++
 .../java/org/apache/knox/gateway/util/Tokens.java  |  44 +++--
 .../org/apache/knox/gateway/util/TokensTest.java   |  38 ++--
 knox-site/docs/config_client_credentials.md        |  35 +++-
 20 files changed, 805 insertions(+), 47 deletions(-)

diff --git a/.github/workflows/build/conf/topologies/clientid-default.xml b/.github/workflows/build/conf/topologies/clientid-default.xml
new file mode 100644
index 000000000..1526cfa2c
--- /dev/null
+++ b/.github/workflows/build/conf/topologies/clientid-default.xml
@@ -0,0 +1,62 @@
+<!--
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you 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.
+-->
+<!--
+Client Credentials endpoint with DEFAULT settings: the user-supplied clientId
+feature is OFF (clientid.allowUserSuppliedClientId is unset, defaulting to
+false), so a supplied clientId query param must be ignored and a generated UUID
+returned. Companion topology clientid.xml enables the feature.
+-->
+<topology>
+	<gateway>
+		<provider>
+			<role>authentication</role>
+			<name>ShiroProvider</name>
+			<enabled>true</enabled>
+			<param>
+				<name>sessionTimeout</name>
+				<value>30</value>
+			</param>
+			<param>
+				<name>main.ldapRealm</name>
+				<value>org.apache.knox.gateway.shirorealm.KnoxLdapRealm</value>
+			</param>
+			<param>
+				<name>main.ldapRealm.userDnTemplate</name>
+				<value>uid={0},ou=people,dc=proxy,dc=org</value>
+			</param>
+			<param>
+				<name>main.ldapRealm.contextFactory.url</name>
+				<value>ldaps://localhost:33390</value>  <!-- Local Knox LDAP service (LDAPS); configured to proxy to the demo LDAP as a backend -->
+			</param>
+			<param>
+				<name>main.ldapRealm.contextFactory.authenticationMechanism</name>
+				<value>simple</value>
+			</param>
+			<param>
+				<name>urls./**</name>
+				<value>authcBasic</value>
+			</param>
+		</provider>
+	</gateway>
+	<service>
+		<role>CLIENTID</role>
+	</service>
+	<service>
+		<role>KNOX</role>
+	</service>
+</topology>
diff --git a/.github/workflows/build/conf/topologies/clientid.xml b/.github/workflows/build/conf/topologies/clientid.xml
new file mode 100644
index 000000000..fecde29f3
--- /dev/null
+++ b/.github/workflows/build/conf/topologies/clientid.xml
@@ -0,0 +1,67 @@
+<!--
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you 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.
+-->
+<!--
+Client Credentials endpoint with the user-supplied clientId feature ENABLED
+(clientid.allowUserSuppliedClientId=true). Basic auth (Knox LDAP) fronts the
+CLIENTID service so guest/admin can register credentials. Companion topology
+clientid-default.xml leaves the feature OFF to prove supplied ids are ignored.
+-->
+<topology>
+	<gateway>
+		<provider>
+			<role>authentication</role>
+			<name>ShiroProvider</name>
+			<enabled>true</enabled>
+			<param>
+				<name>sessionTimeout</name>
+				<value>30</value>
+			</param>
+			<param>
+				<name>main.ldapRealm</name>
+				<value>org.apache.knox.gateway.shirorealm.KnoxLdapRealm</value>
+			</param>
+			<param>
+				<name>main.ldapRealm.userDnTemplate</name>
+				<value>uid={0},ou=people,dc=proxy,dc=org</value>
+			</param>
+			<param>
+				<name>main.ldapRealm.contextFactory.url</name>
+				<value>ldaps://localhost:33390</value>  <!-- Local Knox LDAP service (LDAPS); configured to proxy to the demo LDAP as a backend -->
+			</param>
+			<param>
+				<name>main.ldapRealm.contextFactory.authenticationMechanism</name>
+				<value>simple</value>
+			</param>
+			<param>
+				<name>urls./**</name>
+				<value>authcBasic</value>
+			</param>
+		</provider>
+	</gateway>
+	<service>
+		<role>CLIENTID</role>
+		<!-- Honor a caller-supplied clientId query param as the token_id (client_id). -->
+		<param>
+			<name>clientid.allowUserSuppliedClientId</name>
+			<value>true</value>
+		</param>
+	</service>
+	<service>
+		<role>KNOX</role>
+	</service>
+</topology>
diff --git a/.github/workflows/tests/test_clientid_credentials.py b/.github/workflows/tests/test_clientid_credentials.py
new file mode 100644
index 000000000..fe4917b8d
--- /dev/null
+++ b/.github/workflows/tests/test_clientid_credentials.py
@@ -0,0 +1,193 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to you 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.
+
+"""End-to-end tests for the Client Credentials endpoint's user-supplied clientId.
+
+The CLIENTID service (an extension of KNOXTOKEN) exposes
+``clientid/api/v1/oauth/credentials`` and returns ``{"client_id", "client_secret"}``
+where ``client_id`` is the token's ``token_id`` primary key and ``client_secret``
+is a passcode. Historically ``client_id`` was always a server-generated UUID.
+
+These tests cover the ``clientid.allowUserSuppliedClientId`` feature via two
+topologies bind-mounted into the CI gateway:
+
+* ``clientid`` — feature ON (``clientid.allowUserSuppliedClientId=true``): a
+  caller-supplied ``clientId`` query param becomes the ``client_id``/``token_id``.
+* ``clientid-default`` — feature OFF (param unset): a supplied ``clientId`` is
+  ignored and a UUID is generated (proves the default is non-disruptive).
+
+The gateway runs on H2 (persistent, primary-key enforced), so the duplicate ->
+409 path is genuinely exercised. The end-to-end case then presents the minted
+``client_secret`` as a Passcode to the JWTProvider-federated ``knoxtoken``
+topology to prove a custom id authenticates like any other.
+"""
+
+import base64
+import re
+import unittest
+import uuid
+
+from requests.auth import HTTPBasicAuth
+
+from common_utils import gateway_base_url, knox_delete, knox_get, knox_post
+
+UUID_RE = re.compile(
+    r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
+)
+
+
+class TestClientIdCredentials(unittest.TestCase):
+    """Register client credentials with and without a caller-supplied clientId."""
+
+    def setUp(self):
+        self.base_url = gateway_base_url()
+        # Feature ON: honor a caller-supplied clientId as the token_id.
+        self.enabled_url = (
+            self.base_url + "gateway/clientid/clientid/api/v1/oauth/credentials"
+        )
+        # Feature OFF (default): a supplied clientId must be ignored.
+        self.default_url = (
+            self.base_url
+            + "gateway/clientid-default/clientid/api/v1/oauth/credentials"
+        )
+        # JWTProvider-protected auth service used to verify a minted passcode.
+        self.federated_pre_url = self.base_url + "gateway/knoxtoken/auth/api/v1/pre"
+        # v2 revoke lives on the KNOXTOKEN service in the knoxldap topology.
+        self.token_v2_url = self.base_url + "gateway/knoxldap/knoxtoken/api/v2/token"
+
+        self.guest_auth = HTTPBasicAuth("guest", "guest-password")
+        self._minted_client_ids = []
+
+    def tearDown(self):
+        for client_id in self._minted_client_ids:
+            self._revoke_quietly(client_id)
+
+    def _revoke_quietly(self, client_id):
+        """Best-effort revoke of a minted client_id (= token_id).
+
+        A generated UUID id is revocable via the v2 revoke endpoint; a custom
+        (non-UUID) id is not resolvable by ``TokenUtils.getTokenId`` and simply
+        fails here. The CI gateway is fresh per run and ids are unique, so any
+        failure is harmless — swallow everything.
+        """
+        try:
+            knox_delete(
+                self.token_v2_url + "/revoke",
+                data=client_id,
+                auth=self.guest_auth,
+            )
+        except Exception:  # pylint: disable=broad-except
+            pass
+
+    def _register(self, url, client_id=None):
+        """POST to a credentials endpoint, optionally supplying a clientId param."""
+        params = {"clientId": client_id} if client_id is not None else None
+        return knox_post(url, auth=self.guest_auth, params=params)
+
+    def _register_ok(self, url, client_id=None):
+        """Register credentials, assert 200, track the id, and return the body."""
+        response = self._register(url, client_id=client_id)
+        self.assertEqual(
+            response.status_code,
+            200,
+            msg=f"Registration failed: {response.status_code} {response.text}",
+        )
+        payload = response.json()
+        self.assertIn("client_id", payload)
+        self.assertIn("client_secret", payload)
+        self._minted_client_ids.append(payload["client_id"])
+        return payload
+
+    def test_supplied_client_id_is_used(self):
+        """An enabled topology returns the caller-supplied clientId as client_id."""
+        supplied = "my-app.dev_" + uuid.uuid4().hex
+        payload = self._register_ok(self.enabled_url, client_id=supplied)
+        self.assertEqual(payload["client_id"], supplied)
+
+    def test_supplied_client_id_authenticates(self):
+        """A custom client_id's client_secret authenticates as a Passcode (E2E)."""
+        supplied = "e2e-app_" + uuid.uuid4().hex
+        payload = self._register_ok(self.enabled_url, client_id=supplied)
+
+        basic = base64.b64encode(
+            ("Passcode:" + payload["client_secret"]).encode("utf-8")
+        ).decode("ascii")
+        response = knox_get(
+            self.federated_pre_url,
+            headers={"Authorization": "Basic " + basic},
+        )
+        self.assertEqual(
+            response.status_code,
+            200,
+            msg=f"custom-id passcode was not accepted: "
+            f"{response.status_code} {response.text}",
+        )
+        # For a CLIENTID (third-party-app) token the authenticated principal's audit username is
+        # the token_id itself, so the custom client_id flows through as the actor username.
+        self.assertEqual(response.headers.get("x-knox-actor-username"), supplied)
+
+    def test_omitted_client_id_returns_generated_id(self):
+        """With no clientId param, an enabled topology still generates a UUID."""
+        payload = self._register_ok(self.enabled_url)
+        self.assertRegex(payload["client_id"], UUID_RE)
+
+    def test_invalid_client_id_rejected(self):
+        """A present-but-malformed clientId is a client error (400)."""
+        response = self._register(self.enabled_url, client_id="bad id/with*chars")
+        self.assertEqual(
+            response.status_code,
+            400,
+            msg=f"expected 400 for invalid clientId, got "
+            f"{response.status_code}: {response.text}",
+        )
+        self.assertEqual(response.json().get("error"), "invalid_request")
+
+    def test_too_long_client_id_rejected(self):
+        """A clientId longer than the 128-char column width is rejected (400)."""
+        response = self._register(self.enabled_url, client_id="a" * 129)
+        self.assertEqual(
+            response.status_code,
+            400,
+            msg=f"expected 400 for over-long clientId, got "
+            f"{response.status_code}: {response.text}",
+        )
+        self.assertEqual(response.json().get("error"), "invalid_request")
+
+    def test_duplicate_client_id_conflict(self):
+        """Registering the same clientId twice yields 409 on the second call."""
+        supplied = "dup-app_" + uuid.uuid4().hex
+
+        first = self._register_ok(self.enabled_url, client_id=supplied)
+        self.assertEqual(first["client_id"], supplied)
+
+        second = self._register(self.enabled_url, client_id=supplied)
+        self.assertEqual(
+            second.status_code,
+            409,
+            msg=f"expected 409 for duplicate clientId, got "
+            f"{second.status_code}: {second.text}",
+        )
+        self.assertEqual(second.json().get("error"), "invalid_client")
+
+    def test_supplied_client_id_ignored_when_disabled(self):
+        """The default topology ignores a supplied clientId and generates a UUID."""
+        supplied = "ignored-app_" + uuid.uuid4().hex
+        payload = self._register_ok(self.default_url, client_id=supplied)
+        self.assertNotEqual(payload["client_id"], supplied)
+        self.assertRegex(payload["client_id"], UUID_RE)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateService.java
index 95d0b8eab..423bf6b30 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateService.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateService.java
@@ -42,6 +42,7 @@ import org.apache.knox.gateway.config.GatewayConfig;
 import org.apache.knox.gateway.i18n.messages.MessagesFactory;
 import org.apache.knox.gateway.services.ServiceLifecycleException;
 import org.apache.knox.gateway.services.security.token.KnoxToken;
+import org.apache.knox.gateway.services.security.token.TokenAlreadyExistsException;
 import org.apache.knox.gateway.services.security.token.TokenMetadata;
 import org.apache.knox.gateway.services.security.token.TokenStateService;
 import org.apache.knox.gateway.services.security.token.TokenUtils;
@@ -145,6 +146,11 @@ public class DefaultTokenStateService implements TokenStateService {
                              long   expiration,
                              long   maxLifetimeDuration) {
     validateTokenIdentifier(tokenId);
+    // Best-effort duplicate rejection on the in-memory path (the KNOX_TOKENS.token_id primary key
+    // is the authoritative guarantee on the persistent path; see JDBCTokenStateService).
+    if (tokenExpirations.containsKey(tokenId)) {
+      throw new TokenAlreadyExistsException("A token with id " + Tokens.getTokenIDDisplayText(tokenId) + " already exists");
+    }
     setIssueTime(tokenId, issueTime);
     tokenExpirations.put(tokenId, expiration);
     setMaxLifetime(tokenId, issueTime, maxLifetimeDuration);
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/JDBCTokenStateService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/JDBCTokenStateService.java
index 5d60fbe33..d698f82d6 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/JDBCTokenStateService.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/JDBCTokenStateService.java
@@ -20,9 +20,11 @@ package org.apache.knox.gateway.services.token.impl;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.knox.gateway.config.GatewayConfig;
 import org.apache.knox.gateway.database.DataSourceProvider;
+import org.apache.knox.gateway.database.JDBCUtils;
 import org.apache.knox.gateway.services.ServiceLifecycleException;
 import org.apache.knox.gateway.services.security.AliasService;
 import org.apache.knox.gateway.services.security.token.KnoxToken;
+import org.apache.knox.gateway.services.security.token.TokenAlreadyExistsException;
 import org.apache.knox.gateway.services.security.token.TokenMetadata;
 import org.apache.knox.gateway.services.security.token.TokenMigrationTarget;
 import org.apache.knox.gateway.services.security.token.TokenStateServiceException;
@@ -106,20 +108,25 @@ public class JDBCTokenStateService extends AbstractPersistentTokenStateService i
 
   @Override
   public void addToken(String tokenId, long issueTime, long expiration, long maxLifetimeDuration) {
+    final String displayableTokenId = Tokens.getTokenIDDisplayText(tokenId);
     try {
       final boolean added = tokenDatabase.addToken(tokenId, issueTime, expiration, maxLifetimeDuration);
       if (added) {
-        log.savedTokenInDatabase(Tokens.getTokenIDDisplayText(tokenId));
+        log.savedTokenInDatabase(displayableTokenId);
 
         // add in-memory
         super.addToken(tokenId, issueTime, expiration, maxLifetimeDuration);
       } else {
-        log.failedToSaveTokenInDatabase(Tokens.getTokenIDDisplayText(tokenId));
-        throw new TokenStateServiceException("Failed to save token " + Tokens.getTokenIDDisplayText(tokenId) + " in the database");
+        log.failedToSaveTokenInDatabase(displayableTokenId);
+        throw new TokenStateServiceException("Failed to save token " + displayableTokenId + " in the database");
       }
     } catch (SQLException e) {
-      log.errorSavingTokenInDatabase(Tokens.getTokenIDDisplayText(tokenId), e.getMessage(), e);
-      throw new TokenStateServiceException("An error occurred while saving token " + Tokens.getTokenIDDisplayText(tokenId) + " in the database", e);
+      if (JDBCUtils.isUniqueConstraintViolation(e)) {
+        log.tokenAlreadyExistsInDatabase(displayableTokenId);
+        throw new TokenAlreadyExistsException("A token with id " + displayableTokenId + " already exists", e);
+      }
+      log.errorSavingTokenInDatabase(displayableTokenId, e.getMessage(), e);
+      throw new TokenStateServiceException("An error occurred while saving token " + displayableTokenId + " in the database", e);
     }
   }
 
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/TokenStateServiceMessages.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/TokenStateServiceMessages.java
index 8ca485cbe..3953c7a4c 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/TokenStateServiceMessages.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/TokenStateServiceMessages.java
@@ -187,6 +187,9 @@ public interface TokenStateServiceMessages {
   @Message(level = MessageLevel.ERROR, text = "An error occurred while saving token {0} in the database : {1}")
   void errorSavingTokenInDatabase(String tokenId, String errorMessage, @StackTrace(level = MessageLevel.DEBUG) Exception e);
 
+  @Message(level = MessageLevel.WARN, text = "Token {0} already exists in the database")
+  void tokenAlreadyExistsInDatabase(String tokenId);
+
   @Message(level = MessageLevel.DEBUG, text = "Token {0} has been removed from the database")
   void removedTokenFromDatabase(String tokenId);
 
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/JDBCTokenStateServiceTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/JDBCTokenStateServiceTest.java
index eb59e1648..9e916f1c4 100644
--- a/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/JDBCTokenStateServiceTest.java
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/JDBCTokenStateServiceTest.java
@@ -25,6 +25,7 @@ import org.apache.knox.gateway.database.AbstractDataSourceFactory;
 import org.apache.knox.gateway.database.DatabaseType;
 import org.apache.knox.gateway.services.security.AliasService;
 import org.apache.knox.gateway.services.security.token.KnoxToken;
+import org.apache.knox.gateway.services.security.token.TokenAlreadyExistsException;
 import org.apache.knox.gateway.services.security.token.TokenMetadata;
 import org.apache.knox.gateway.services.security.token.UnknownTokenException;
 import org.apache.knox.gateway.services.security.token.impl.TokenMAC;
@@ -53,6 +54,7 @@ import java.util.concurrent.ConcurrentHashMap;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.assertTrue;
 
 public class JDBCTokenStateServiceTest {
@@ -113,6 +115,23 @@ public class JDBCTokenStateServiceTest {
     assertEquals(issueTime + maxLifetimeDuration, getLongTokenAttributeFromDatabase(tokenId, TokenStateDatabase.GET_MAX_LIFETIME_SQL));
   }
 
+  @Test
+  public void testAddDuplicateTokenIdThrowsTokenAlreadyExists() throws Exception {
+    truncateDatabase();
+    // A second insert of the same token_id must surface the unique-constraint violation as
+    // TokenAlreadyExistsException rather than overwriting or failing generically.
+    final String tokenId = "duplicate-client-id";
+    final long issueTime = System.currentTimeMillis();
+    jdbcTokenStateService.addToken(tokenId, issueTime, issueTime + 1000, 1000);
+
+    assertThrows(TokenAlreadyExistsException.class,
+        () -> jdbcTokenStateService.addToken(tokenId, issueTime, issueTime + 2000, 2000));
+
+    // Exactly one row survives, and its state is that of the original (winning) insert.
+    assertEquals(1, getLongTokenAttributeFromDatabase(null, GET_TOKENS_COUNT_SQL));
+    assertEquals(issueTime + 1000, jdbcTokenStateService.getTokenExpiration(tokenId));
+  }
+
   @Test
   public void testAddTokenThatCanAlwaysBeRenewed() throws Exception {
     final String tokenId = UUID.randomUUID().toString();
diff --git a/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/ClientCredentialsResource.java b/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/ClientCredentialsResource.java
index 0a33e096c..7caa8ada0 100644
--- a/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/ClientCredentialsResource.java
+++ b/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/ClientCredentialsResource.java
@@ -18,8 +18,10 @@
 package org.apache.knox.gateway.service.knoxtoken;
 
 import com.nimbusds.jose.KeyLengthException;
+import org.apache.commons.lang3.StringUtils;
 import org.apache.knox.gateway.services.ServiceLifecycleException;
 import org.apache.knox.gateway.services.security.AliasServiceException;
+import org.apache.knox.gateway.services.security.token.TokenAlreadyExistsException;
 import org.apache.knox.gateway.services.security.token.TokenMetadata;
 import org.apache.knox.gateway.services.security.token.TokenMetadataType;
 import org.apache.knox.gateway.util.JsonUtils;
@@ -33,7 +35,9 @@ import jakarta.ws.rs.Path;
 import jakarta.ws.rs.Produces;
 import jakarta.ws.rs.core.Response;
 import java.util.HashMap;
+import java.util.LinkedHashMap;
 import java.util.Map;
+import java.util.regex.Pattern;
 
 import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON;
 import static jakarta.ws.rs.core.MediaType.APPLICATION_XML;
@@ -46,8 +50,15 @@ public class ClientCredentialsResource extends PasscodeTokenResourceBase {
     public static final String CLIENT_SECRET = "client_secret";
     private static final String PREFIX = "clientid.";
     private static final String THIRD_PARTY_APP = "thirdPartyApp";
+    private static final String ALLOW_USER_SUPPLIED_CLIENT_ID = "allowUserSuppliedClientId";
+    private static final String CLIENT_ID_PARAM = "clientId";
+
+    // A user-supplied clientId becomes the token_id primary key: alphanumerics plus
+    // dot/underscore/hyphen, 1..128 chars (the column width).
+    private static final Pattern CLIENT_ID_PATTERN = Pattern.compile("^[A-Za-z0-9._-]{1,128}$");
 
     private boolean thirdPartyApp;
+    private boolean allowUserSuppliedClientId;
 
     @Override
     @PostConstruct
@@ -55,6 +66,7 @@ public class ClientCredentialsResource extends PasscodeTokenResourceBase {
         super.init();
         final String configuredThirdPartyApp = context.getInitParameter(THIRD_PARTY_APP);
         thirdPartyApp = configuredThirdPartyApp == null ? true : Boolean.parseBoolean(configuredThirdPartyApp);
+        allowUserSuppliedClientId = Boolean.parseBoolean(context.getInitParameter(ALLOW_USER_SUPPLIED_CLIENT_ID));
     }
 
     @Override
@@ -90,7 +102,25 @@ public class ClientCredentialsResource extends PasscodeTokenResourceBase {
         if (response != null) {
             return response;
         }
-        TokenResponseContext resp = getTokenResponse(context);
+
+        // A present-but-malformed clientId is a client error (400); a blank/absent one falls
+        // through to the normal UUID-generating behavior.
+        if (allowUserSuppliedClientId) {
+            final String suppliedClientId = request.getParameter(CLIENT_ID_PARAM);
+            if (StringUtils.isNotBlank(suppliedClientId) && !isValidClientId(suppliedClientId)) {
+                return errorResponse(Response.Status.BAD_REQUEST, "invalid_request",
+                        "The supplied " + CLIENT_ID_PARAM + " is invalid; it must match " + CLIENT_ID_PATTERN.pattern());
+            }
+        }
+
+        final TokenResponseContext resp;
+        try {
+            resp = getTokenResponse(context);
+        } catch (TokenAlreadyExistsException e) {
+            // The token_id primary key rejected a duplicate clientId; surface it as 409, not 500.
+            return errorResponse(Response.Status.CONFLICT, "invalid_client",
+                    "The supplied " + CLIENT_ID_PARAM + " already exists");
+        }
         if (resp.responseMap != null) {
             String passcode = (String) resp.responseMap.map.get(PASSCODE);
             String tokenId = resp.responseMap.tokenId;
@@ -114,4 +144,29 @@ public class ClientCredentialsResource extends PasscodeTokenResourceBase {
     protected void decorateResponseMap(Map<String, Object> responseMap) {
         //NOP
     }
+
+    /**
+     * The caller-supplied {@code clientId} when the feature is enabled and the value is valid,
+     * otherwise null (generate a UUID). A present value is already validated in
+     * {@link #getAuthenticationToken()}; the recheck here is defensive.
+     */
+    @Override
+    protected String getRequestedTokenId() {
+        if (!allowUserSuppliedClientId) {
+            return null;
+        }
+        final String suppliedClientId = request.getParameter(CLIENT_ID_PARAM);
+        return (StringUtils.isNotBlank(suppliedClientId) && isValidClientId(suppliedClientId)) ? suppliedClientId : null;
+    }
+
+    private static boolean isValidClientId(String clientId) {
+        return CLIENT_ID_PATTERN.matcher(clientId).matches();
+    }
+
+    private static Response errorResponse(Response.Status status, String error, String description) {
+        final Map<String, Object> body = new LinkedHashMap<>();
+        body.put("error", error);
+        body.put("error_description", description);
+        return Response.status(status).entity(JsonUtils.renderAsJsonString(body)).build();
+    }
 }
diff --git a/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/TokenResource.java b/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/TokenResource.java
index 77228cc40..e2b57fba8 100644
--- a/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/TokenResource.java
+++ b/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/TokenResource.java
@@ -1270,6 +1270,15 @@ public class TokenResource {
     return JsonUtils.renderAsJsonString(body);
   }
 
+  /**
+   * Hook for a subclass to supply the token's {@code knox.id}/{@code token_id}. Defaults to null
+   * (generate a UUID); {@link ClientCredentialsResource} overrides it to honor a user-supplied
+   * {@code clientId}.
+   */
+  protected String getRequestedTokenId() {
+    return null;
+  }
+
   private JWT getJWT(UserContext userContext, long issueTime, long expires, String jku, List<String> audiences)
       throws TokenServiceException, ActorChainDepthExceededException {
     JWTokenAttributes jwtAttributes;
@@ -1285,7 +1294,8 @@ public class TokenResource {
         .setExpires(expires)
         .setManaged(managedToken)
         .setJku(jku)
-        .setType(tokenType);
+        .setType(tokenType)
+        .setTokenId(getRequestedTokenId());
     if (audiences != null && !audiences.isEmpty()) {
       jwtAttributesBuilder.setAudiences(audiences);
     }
diff --git a/gateway-service-knoxtoken/src/test/java/org/apache/knox/gateway/service/knoxtoken/TokenServiceResourceTest.java b/gateway-service-knoxtoken/src/test/java/org/apache/knox/gateway/service/knoxtoken/TokenServiceResourceTest.java
index d524f2040..f46b02259 100644
--- a/gateway-service-knoxtoken/src/test/java/org/apache/knox/gateway/service/knoxtoken/TokenServiceResourceTest.java
+++ b/gateway-service-knoxtoken/src/test/java/org/apache/knox/gateway/service/knoxtoken/TokenServiceResourceTest.java
@@ -59,6 +59,7 @@ import java.util.Map;
 import java.util.Set;
 import java.util.TimeZone;
 import java.util.TreeSet;
+import java.util.UUID;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ExecutorService;
@@ -107,6 +108,7 @@ import org.apache.knox.gateway.services.security.token.JWTokenAttributes;
 import org.apache.knox.gateway.services.security.token.JWTokenAuthority;
 import org.apache.knox.gateway.services.security.token.KnoxToken;
 import org.apache.knox.gateway.services.security.token.PersistentTokenStateService;
+import org.apache.knox.gateway.services.security.token.TokenAlreadyExistsException;
 import org.apache.knox.gateway.services.security.token.TokenMetadata;
 import org.apache.knox.gateway.services.security.token.TokenServiceException;
 import org.apache.knox.gateway.services.security.token.TokenStateService;
@@ -140,6 +142,7 @@ public class TokenServiceResourceTest {
 
   private ServletContext context;
   private HttpServletRequest request;
+  private String clientIdParamValue;
   private String[] resourceParamValues;
   private List<String> exchangeRequestedAudiences;
   private Integer exchangeRequestedTtlSec;
@@ -170,6 +173,7 @@ public class TokenServiceResourceTest {
   @After
   public void cleanUp() {
     this.usersCanSeeAllTokens.clear();
+    this.clientIdParamValue = null;
     TokenResource.auditor = ORIGINAL_AUDITOR;
   }
 
@@ -210,6 +214,8 @@ public class TokenServiceResourceTest {
     if (contextExpectations.containsKey(TokenResource.QUERY_PARAMETER_DOAS)) {
       EasyMock.expect(request.getParameter(TokenResource.QUERY_PARAMETER_DOAS)).andReturn(contextExpectations.get(TokenResource.QUERY_PARAMETER_DOAS)).anyTimes();
     }
+    // The client-credentials clientId query param (null unless a test opts a value in).
+    EasyMock.expect(request.getParameter("clientId")).andReturn(clientIdParamValue).anyTimes();
     EasyMock.expect(request.getParameterNames()).andReturn(Collections.emptyEnumeration()).anyTimes();
     final Map<String, String[]> parameterMap = new HashMap<>();
     if (resourceParamValues != null) {
@@ -2098,6 +2104,116 @@ public class TokenServiceResourceTest {
     }
   }
 
+  @Test
+  public void testUserSuppliedClientIdUsedWhenEnabled() throws Exception {
+    final String supplied = "my-app.dev_1";
+    // The returned client_id is the token's token_id, so equality proves it became the identifier.
+    final String clientId = registerClientCredentials("true", supplied, 200);
+    assertEquals(supplied, clientId);
+  }
+
+  @Test
+  public void testUserSuppliedClientIdIgnoredWhenDisabledByDefault() throws Exception {
+    final String supplied = "my-app.dev_1";
+    final String clientId = registerClientCredentials(null, supplied, 200);
+    assertNotEquals(supplied, clientId);
+    assertGeneratedUuid(clientId);
+  }
+
+  @Test
+  public void testUserSuppliedClientIdIgnoredWhenExplicitlyDisabled() throws Exception {
+    final String supplied = "my-app.dev_1";
+    final String clientId = registerClientCredentials("false", supplied, 200);
+    assertNotEquals(supplied, clientId);
+    assertGeneratedUuid(clientId);
+  }
+
+  @Test
+  public void testOmittedClientIdReturnsGeneratedIdWhenEnabled() throws Exception {
+    final String clientId = registerClientCredentials("true", null, 200);
+    assertGeneratedUuid(clientId);
+  }
+
+  @Test
+  public void testInvalidUserSuppliedClientIdRejected() throws Exception {
+    tryInvalidUserSuppliedClientId("bad id/with*chars");
+    tryInvalidUserSuppliedClientId(StringUtils.repeat('a', 129));
+  }
+
+  private void tryInvalidUserSuppliedClientId(String supplied) throws Exception {
+    try {
+      tss = new PersistentTestTokenStateService();
+      clientIdParamValue = supplied;
+      final Map<String, String> contextExpectations = new HashMap<>();
+      contextExpectations.put("clientid.allowUserSuppliedClientId", "true");
+      configureCommonExpectations(contextExpectations, Boolean.TRUE);
+      final ClientCredentialsResource ccr = new ClientCredentialsResource();
+      ccr.request = request;
+      ccr.context = context;
+      ccr.init();
+
+      final Response response = ccr.doPost();
+      assertEquals(400, response.getStatus());
+      assertEquals("invalid_request", getTagValue(response.getEntity().toString(), "error"));
+    } finally {
+      tss = new TestTokenStateService();
+    }
+  }
+
+  @Test
+  public void testDuplicateUserSuppliedClientIdConflict() throws Exception {
+    final String supplied = "dup-app_1";
+    try {
+      tss = new DuplicateRejectingTokenStateService();
+      clientIdParamValue = supplied;
+      final Map<String, String> contextExpectations = new HashMap<>();
+      contextExpectations.put("clientid.allowUserSuppliedClientId", "true");
+      configureCommonExpectations(contextExpectations, Boolean.TRUE);
+      final ClientCredentialsResource ccr = new ClientCredentialsResource();
+      ccr.request = request;
+      ccr.context = context;
+      ccr.init();
+
+      final Response first = ccr.doPost();
+      assertEquals(200, first.getStatus());
+      assertEquals(supplied, getTagValue(first.getEntity().toString(), ClientCredentialsResource.CLIENT_ID));
+
+      final Response second = ccr.doPost();
+      assertEquals(409, second.getStatus());
+      assertEquals("invalid_client", getTagValue(second.getEntity().toString(), "error"));
+    } finally {
+      tss = new TestTokenStateService();
+    }
+  }
+
+  private String registerClientCredentials(String allowUserSuppliedClientId, String suppliedClientId, int expectedStatus) throws Exception {
+    try {
+      tss = new PersistentTestTokenStateService();
+      clientIdParamValue = suppliedClientId;
+      final Map<String, String> contextExpectations = new HashMap<>();
+      if (allowUserSuppliedClientId != null) {
+        contextExpectations.put("clientid.allowUserSuppliedClientId", allowUserSuppliedClientId);
+      }
+      configureCommonExpectations(contextExpectations, Boolean.TRUE);
+      final ClientCredentialsResource ccr = new ClientCredentialsResource();
+      ccr.request = request;
+      ccr.context = context;
+      ccr.init();
+
+      final Response response = ccr.doPost();
+      assertEquals(expectedStatus, response.getStatus());
+      return getTagValue(response.getEntity().toString(), ClientCredentialsResource.CLIENT_ID);
+    } finally {
+      tss = new TestTokenStateService();
+    }
+  }
+
+  private static void assertGeneratedUuid(String clientId) {
+    assertNotNull(clientId);
+    // A generated id is a random UUID; UUID.fromString throws if it is not.
+    UUID.fromString(clientId);
+  }
+
   @Test
   public void testClientCredentialsResponse() throws Exception {
     tryClientCredentialsResource(null);
@@ -2673,6 +2789,23 @@ public class TokenServiceResourceTest {
   private static class PersistentTestTokenStateService extends TestTokenStateService implements PersistentTokenStateService {
   }
 
+  /**
+   * Persistent token state service that mirrors the KNOX_TOKENS.token_id primary-key guarantee:
+   * a second {@code addToken} for an id already present throws {@link TokenAlreadyExistsException},
+   * exactly as {@code JDBCTokenStateService} translates a unique-constraint violation.
+   */
+  private static class DuplicateRejectingTokenStateService extends PersistentTestTokenStateService {
+    private final Set<String> knownTokenIds = ConcurrentHashMap.newKeySet();
+
+    @Override
+    public void addToken(String tokenId, long issueTime, long expiration, long maxLifetimeDuration) {
+      if (!knownTokenIds.add(tokenId)) {
+        throw new TokenAlreadyExistsException("A token with id " + tokenId + " already exists");
+      }
+      super.addToken(tokenId, issueTime, expiration, maxLifetimeDuration);
+    }
+  }
+
   private static class TestJWTokenAuthority implements JWTokenAuthority {
 
     private RSAPublicKey publicKey;
diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/JWTokenAttributes.java b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/JWTokenAttributes.java
index 40f9f4712..92ed61a65 100644
--- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/JWTokenAttributes.java
+++ b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/JWTokenAttributes.java
@@ -44,10 +44,11 @@ public class JWTokenAttributes {
   private final String clientId;
   private final List<Map<String, Object>> actorChain;
   private final Map<String, Object> customAttributes;
+  private final String tokenId;
 
   JWTokenAttributes(String userName, List<String> audiences, String algorithm, long issueTime, long expires, String signingKeystoreName, String signingKeystoreAlias,
       char[] signingKeystorePassphrase, boolean managed, String jku, String type, Set<String> groups, String kid, String issuer, String clientId, List<Map<String, Object>> actorChain,
-                    Map<String, Object> customAttributes) {
+                    Map<String, Object> customAttributes, String tokenId) {
     this.userName = userName;
     this.audiences = audiences;
     this.algorithm = algorithm;
@@ -65,6 +66,7 @@ public class JWTokenAttributes {
     this.clientId = clientId;
     this.actorChain = actorChain;
     this.customAttributes = customAttributes;
+    this.tokenId = tokenId;
   }
     public String getUserName() {
         return userName;
@@ -169,4 +171,9 @@ public class JWTokenAttributes {
   public Map<String, Object> getCustomAttributes() {
       return customAttributes;
   }
+
+  /** The requested {@code knox.id}/{@code token_id}, or null to let {@code JWTToken} generate a UUID. */
+  public String getTokenId() {
+    return tokenId;
+  }
 }
diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/JWTokenAttributesBuilder.java b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/JWTokenAttributesBuilder.java
index 1e4fb96b5..148ed36fa 100644
--- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/JWTokenAttributesBuilder.java
+++ b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/JWTokenAttributesBuilder.java
@@ -45,6 +45,7 @@ public class JWTokenAttributesBuilder {
   private String clientId;
   private List<Map<String, Object>> actorChain;
   private Map<String, Object> customAttributes;
+  private String tokenId;
 
   public JWTokenAttributesBuilder setUserName(String userName) {
     this.userName = userName;
@@ -159,8 +160,14 @@ public class JWTokenAttributesBuilder {
     return this;
   }
 
+  /** Sets the {@code knox.id}/{@code token_id}; null or empty (the default) generates a UUID. */
+  public JWTokenAttributesBuilder setTokenId(String tokenId) {
+    this.tokenId = tokenId;
+    return this;
+  }
+
   public JWTokenAttributes build() {
     return new JWTokenAttributes(userName, (audiences == null ? new ArrayList<>() : audiences), algorithm, issueTime, expires, signingKeystoreName, signingKeystoreAlias,
-        signingKeystorePassphrase, managed, jku, type, groups, kid, issuer, clientId, actorChain, customAttributes);
+        signingKeystorePassphrase, managed, jku, type, groups, kid, issuer, clientId, actorChain, customAttributes, tokenId);
   }
 }
diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenAlreadyExistsException.java b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenAlreadyExistsException.java
new file mode 100644
index 000000000..1eff4276c
--- /dev/null
+++ b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenAlreadyExistsException.java
@@ -0,0 +1,41 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 org.apache.knox.gateway.services.security.token;
+
+/**
+ * Thrown when a token cannot be added because its identifier ({@code KNOX_TOKENS.token_id})
+ * already exists. This surfaces a unique/primary-key constraint violation from the persistent
+ * token store so callers (e.g. the client-credentials endpoint honoring a user-supplied
+ * {@code clientId}) can translate it into an HTTP 409 Conflict rather than a generic 500.
+ *
+ * <p>It extends {@link TokenStateServiceException} (itself unchecked), so it requires no change to
+ * the {@link TokenStateService} interface signatures and is transparent to callers that do not
+ * distinguish it.</p>
+ */
+@SuppressWarnings("serial")
+public class TokenAlreadyExistsException extends TokenStateServiceException {
+
+  public TokenAlreadyExistsException(String message) {
+    super(message);
+  }
+
+  public TokenAlreadyExistsException(String message, Throwable cause) {
+    super(message, cause);
+  }
+
+}
diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenUtils.java b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenUtils.java
index 0a4672dbb..0ab20d5a3 100644
--- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenUtils.java
+++ b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenUtils.java
@@ -26,6 +26,7 @@ import org.apache.knox.gateway.services.security.AliasService;
 import org.apache.knox.gateway.services.security.AliasServiceException;
 import org.apache.knox.gateway.services.security.token.impl.JWT;
 import org.apache.knox.gateway.services.security.token.impl.JWTToken;
+import org.apache.knox.gateway.util.Tokens;
 
 import jakarta.servlet.FilterConfig;
 import jakarta.servlet.ServletContext;
@@ -36,7 +37,6 @@ import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.text.ParseException;
-import java.util.UUID;
 
 public class TokenUtils {
   public static final String ATTR_CURRENT_KNOXSSO_COOKIE_TOKEN_ID = "currentKnoxSsoCookieTokenId";
@@ -56,18 +56,33 @@ public class TokenUtils {
   }
 
   /**
-   * If the supplied 'token' conforms the UUID string representation, we consider
-   * that as the token ID; otherwise we expect that 'token' is the entire JWT, and
-   * we get the token ID from it
+   * Resolves the token id from a value that is either a token id or an entire serialized JWT.
+   * A UUID is returned as-is. Otherwise the value is parsed as a JWT (to read the {@code knox.id}
+   * claim) only when it has the compact JWT shape; anything else — including a user-supplied
+   * client_id used as the token id (which may itself contain dots, e.g. {@code com.example.app}) —
+   * is returned verbatim as the token id.
    */
   public static String getTokenId(String token) throws ParseException {
-    try {
-      UUID.fromString(token);
+    if (Tokens.isUUID(token)) {
       return token;
-    } catch (IllegalArgumentException e) {
-      //NOP: the supplied token is not a UUID, we expect the entire JWT
     }
-    return getTokenId(new JWTToken(token));
+    if (isSerializedJWT(token)) {
+      try {
+        return getTokenId(new JWTToken(token));
+      } catch (ParseException e) {
+        //NOP: dot-delimited but not a real JWT (e.g. a dotted client_id); treat it as the token id
+      }
+    }
+    return token;
+  }
+
+  /**
+   * A compact-serialized JWT is a dot-delimited value with three segments (JWS; an unsigned token
+   * keeps a trailing empty segment) or five segments (JWE). Any other shape is a plain token id.
+   */
+  private static boolean isSerializedJWT(String token) {
+    final int segments = token.split("\\.", -1).length;
+    return segments == 3 || segments == 5;
   }
 
   /**
diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/impl/JWTToken.java b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/impl/JWTToken.java
index 78ad648f0..51719d1ac 100644
--- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/impl/JWTToken.java
+++ b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/impl/JWTToken.java
@@ -112,8 +112,12 @@ public class JWTToken implements JWT {
       builder.claim(ACT_CLAIM, nestedAct);
     }
 
-    // Add a private UUID claim for uniqueness
-    builder.claim(KNOX_ID_CLAIM, String.valueOf(UUID.randomUUID()));
+    // Add the knox.id claim: a caller-supplied token id (e.g. a user-chosen client_id) when
+    // present, otherwise a random UUID for uniqueness.
+    final String requestedTokenId = jwtAttributes.getTokenId();
+    final String knoxId = (requestedTokenId == null || requestedTokenId.isEmpty())
+        ? String.valueOf(UUID.randomUUID()) : requestedTokenId;
+    builder.claim(KNOX_ID_CLAIM, knoxId);
 
     builder.claim(MANAGED_TOKEN_CLAIM, String.valueOf(jwtAttributes.isManaged()));
 
diff --git a/gateway-spi/src/test/java/org/apache/knox/gateway/services/security/token/TokenUtilsTest.java b/gateway-spi/src/test/java/org/apache/knox/gateway/services/security/token/TokenUtilsTest.java
index a0967e3c9..f4a8930c1 100644
--- a/gateway-spi/src/test/java/org/apache/knox/gateway/services/security/token/TokenUtilsTest.java
+++ b/gateway-spi/src/test/java/org/apache/knox/gateway/services/security/token/TokenUtilsTest.java
@@ -21,14 +21,21 @@ import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
 
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.interfaces.RSAPrivateKey;
 import java.util.ArrayList;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.UUID;
 
 import org.apache.knox.gateway.services.security.token.impl.JWTToken;
 import org.junit.Test;
 
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+
 /**
  * Test class for TokenUtils, focusing on RFC 8693 actor chain functionality.
  */
@@ -241,4 +248,42 @@ public class TokenUtilsTest {
     assertEquals("actor2", extractedChain.get(1).get("sub"));
     assertEquals("actor3", extractedChain.get(2).get("sub"));
   }
+
+  @Test
+  public void testGetTokenIdReturnsUuidVerbatim() throws Exception {
+    final String uuid = UUID.randomUUID().toString();
+    assertEquals(uuid, TokenUtils.getTokenId(uuid));
+  }
+
+  @Test
+  public void testGetTokenIdReturnsUserSuppliedClientId() throws Exception {
+    // The reported bug: a non-UUID token id (a user-supplied client_id) is neither a UUID nor a
+    // parseable JWT, so it must be returned verbatim instead of being parsed as a JWT.
+    final String clientId = "my-app.prod_1";
+    assertEquals(clientId, TokenUtils.getTokenId(clientId));
+  }
+
+  @Test
+  public void testGetTokenIdReturnsDottedClientId() throws Exception {
+    // Two dots makes this JWT-shaped, but it is not a real JWT; the parse-failure fallback must
+    // still return it verbatim as the token id.
+    final String clientId = "com.example.app";
+    assertEquals(clientId, TokenUtils.getTokenId(clientId));
+  }
+
+  @Test
+  public void testGetTokenIdExtractsFromSerializedJwt() throws Exception {
+    final String knoxId = "known-knox-id";
+    final KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
+    kpg.initialize(2048);
+    final KeyPair keyPair = kpg.genKeyPair();
+
+    final JWTToken token = new JWTToken(new JWTokenAttributesBuilder()
+        .setUserName("john.doe@example.com").setAlgorithm("RS256").setTokenId(knoxId).build());
+    final JWSSigner signer = new RSASSASigner((RSAPrivateKey) keyPair.getPrivate());
+    token.sign(signer);
+
+    // A full serialized JWT is still parsed to read its knox.id claim.
+    assertEquals(knoxId, TokenUtils.getTokenId(token.toString()));
+  }
 }
diff --git a/gateway-spi/src/test/java/org/apache/knox/gateway/services/security/token/impl/JWTTokenTest.java b/gateway-spi/src/test/java/org/apache/knox/gateway/services/security/token/impl/JWTTokenTest.java
index becb9c32c..51b981159 100644
--- a/gateway-spi/src/test/java/org/apache/knox/gateway/services/security/token/impl/JWTTokenTest.java
+++ b/gateway-spi/src/test/java/org/apache/knox/gateway/services/security/token/impl/JWTTokenTest.java
@@ -128,6 +128,27 @@ public class JWTTokenTest {
     assertNotNull(uuid);
   }
 
+  @Test
+  public void testKnoxIdClaimHonoursSuppliedTokenId() throws Exception {
+    final String suppliedTokenId = "my-app.dev_1";
+    JWT token = new JWTToken(new JWTokenAttributesBuilder().setUserName("john.doe@example.com")
+        .setAlgorithm("RS256").setTokenId(suppliedTokenId).build());
+
+    // A supplied token id becomes the knox.id claim verbatim instead of a generated UUID.
+    assertEquals(suppliedTokenId, token.getClaim(JWTToken.KNOX_ID_CLAIM));
+  }
+
+  @Test
+  public void testKnoxIdClaimGeneratesUuidWhenTokenIdBlank() throws Exception {
+    // An empty supplied token id must fall back to a generated UUID (unchanged legacy behavior).
+    JWT token = new JWTToken(new JWTokenAttributesBuilder().setUserName("john.doe@example.com")
+        .setAlgorithm("RS256").setTokenId("").build());
+
+    final String knoxId = token.getClaim(JWTToken.KNOX_ID_CLAIM);
+    assertNotNull(knoxId);
+    assertNotNull(UUID.fromString(knoxId));
+  }
+
   @Test
   public void testTokenCreationWithAudienceListSingle() throws Exception {
     List<String> audiences = new ArrayList<>();
diff --git a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/Tokens.java b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/Tokens.java
index 57235d536..25a7edba8 100644
--- a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/Tokens.java
+++ b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/Tokens.java
@@ -19,6 +19,7 @@ package org.apache.knox.gateway.util;
 import java.util.HashSet;
 import java.util.Locale;
 import java.util.Set;
+import java.util.UUID;
 
 public class Tokens {
 
@@ -42,24 +43,47 @@ public class Tokens {
     }
 
     /**
-     * Get a String derived from a Knox token UUID String, which is suitable for presentation (e.g., logging) without
-     * compromising security.
+     * Get a String derived from a Knox token id, which is suitable for presentation (e.g., logging) without
+     * compromising security. A generated UUID id is abbreviated to its first and last dash-delimited groups; a
+     * user-supplied token id (e.g. a client_id) is abbreviated as first...last when long enough. A value that is
+     * null, empty, or too short to abbreviate safely yields null (the raw value is never returned).
      *
-     * @param uuid A Knox token UUID String.
+     * @param tokenId A Knox token id.
      *
-     * @return An abbreviated form of the specified UUID String.
+     * @return An abbreviated form of the specified token id, or null if it cannot be abbreviated safely.
      */
-    public static String getTokenIDDisplayText(final String uuid) {
+    public static String getTokenIDDisplayText(final String tokenId) {
         String displayText = null;
-        if (uuid != null && uuid.length() == 36 && uuid.contains("-")) {
-            displayText = String.format(Locale.ROOT,
-                                        "%s...%s",
-                                        uuid.substring(0, uuid.indexOf('-')),
-                                        uuid.substring(uuid.lastIndexOf('-') + 1));
+        if (tokenId != null && !tokenId.isEmpty()) {
+            if (isUUID(tokenId)) {
+                displayText = String.format(Locale.ROOT,
+                                            "%s...%s",
+                                            tokenId.substring(0, tokenId.indexOf('-')),
+                                            tokenId.substring(tokenId.lastIndexOf('-') + 1));
+            } else if (tokenId.length() >= 7) {
+                displayText = String.format(Locale.ROOT,
+                                            "%s...%s",
+                                            tokenId.substring(0, 3),
+                                            tokenId.substring(tokenId.length() - 3));
+            }
         }
         return displayText;
     }
 
+    /**
+     * @param value A String to test.
+     *
+     * @return true if the value is a valid UUID (e.g. a generated Knox token id); false otherwise.
+     */
+    public static boolean isUUID(final String value) {
+        try {
+            UUID.fromString(value);
+            return true;
+        } catch (IllegalArgumentException e) {
+            return false;
+        }
+    }
+
     public static Set<String> getDisplayableTokenIDsText(final Set<String> tokenIds) {
         Set<String> displayableTokenIds = new HashSet<>();
         for (String tokenId : tokenIds) {
diff --git a/gateway-util-common/src/test/java/org/apache/knox/gateway/util/TokensTest.java b/gateway-util-common/src/test/java/org/apache/knox/gateway/util/TokensTest.java
index afabc64c3..ca07aab36 100644
--- a/gateway-util-common/src/test/java/org/apache/knox/gateway/util/TokensTest.java
+++ b/gateway-util-common/src/test/java/org/apache/knox/gateway/util/TokensTest.java
@@ -47,25 +47,23 @@ public class TokensTest {
     }
 
     @Test
-    public void testTokenIdDisplayTextShortUUID() {
-        final String tokenId = UUID.randomUUID().toString();
-        doTestTokenDisplay(tokenId.substring(1), true);
+    public void testTokenIdDisplayTextNonUuid() {
+        // A user-supplied client_id used as the token id is not a UUID; it must still be
+        // abbreviated for logging (rather than rendering as null).
+        doTestNonUuidTokenDisplay("my-app.prod_1");
     }
 
     @Test
-    public void testTokenIdDisplayTextInvalidUUID() {
-        final String tokenId = UUID.randomUUID().toString();
-        // Strip the '-' from the token ID
-        char[] invalid = new char[tokenId.length() - 4];
-        int count = 0;
-        for (int i = 0 ; i < tokenId.length(); i++) {
-            char c = tokenId.charAt(i);
-            if (c != '-') {
-                invalid[count++] = tokenId.charAt(i);
-            }
-        }
-        // Invoke the test with the invalid ID
-        doTestTokenDisplay(new String(invalid), true);
+    public void testTokenIdDisplayTextNonUuidNoDashes() {
+        // A 32-char, dashless value is not a valid UUID but is a legitimate token id; abbreviate it.
+        final String tokenId = UUID.randomUUID().toString().replace("-", "");
+        doTestNonUuidTokenDisplay(tokenId);
+    }
+
+    @Test
+    public void testTokenIdDisplayTextShortTokenId() {
+        // Too short to abbreviate safely -> null (never the raw value, which audit masking relies on).
+        assertNull(Tokens.getTokenIDDisplayText("app"));
     }
 
     @Test
@@ -87,6 +85,14 @@ public class TokensTest {
         doTestTokenDisplay(tokenId, false);
     }
 
+    private void doTestNonUuidTokenDisplay(final String tokenId) {
+        final String displayableTokenId = Tokens.getTokenIDDisplayText(tokenId);
+        assertNotNull(displayableTokenId);
+        assertTrue(displayableTokenId.length() < tokenId.length());
+        assertEquals(tokenId.substring(0, 3) + "..." + tokenId.substring(tokenId.length() - 3),
+                     displayableTokenId);
+    }
+
     private void doTestTokenDisplay(final String tokenId, final Boolean invalidID) {
         String displayableTokenId = Tokens.getTokenIDDisplayText(tokenId);
         if (invalidID) {
diff --git a/knox-site/docs/config_client_credentials.md b/knox-site/docs/config_client_credentials.md
index 989b46822..80466ab5d 100644
--- a/knox-site/docs/config_client_credentials.md
+++ b/knox-site/docs/config_client_credentials.md
@@ -58,4 +58,37 @@ In this deployment example the TTL is set to 74000 ms which is translated to sec
     {"client_secret":"WXpKaE1qRmlOR0V0TkRBMk5DMDBNelZsTFdFek16RXR....WTVaVFprOjpZelJsTlRJMFlXVXROMlEwTXkwME5EQTVMV0k1WWpJdFlqZ3pOR00xTmpsa01qUXg=","expires_in":74,"client_id":"c2a21b4a-4064-435e-a331-6d6858ef9e6d"}
 
 Note that in both of the above responses that there is a client_id and the client_secret.
-The key_id may be used in management operations of the API Key lifecycle by those with appropriate permissions to do so.
\ No newline at end of file
+The key_id may be used in management operations of the API Key lifecycle by those with appropriate permissions to do so.
+
+### User-supplied client_id
+
+By default the `client_id` is a server-generated UUID. Well-known identity providers (Auth0, Okta,
+Keycloak) instead let the caller choose the client identifier at registration time. The CLIENTID
+API supports the same when the topology opts in via `clientid.allowUserSuppliedClientId`:
+
+    <service>
+        <role>CLIENTID</role>
+        <param>
+            <name>clientid.allowUserSuppliedClientId</name>
+            <value>true</value>
+        </param>
+    </service>
+
+When enabled, a caller may pass a `clientId` query parameter and that value becomes the returned
+`client_id` (which is the token's `token_id` primary key), instead of a generated UUID:
+
+    $ curl -ivku guest:guest-password -X POST "https://localhost:8443/gateway/sandbox/clientid/api/v1/oauth/credentials?clientId=my-app.prod"
+    {"client_secret":"WXpOa1l6SmxPRFF0...","client_id":"my-app.prod"}
+
+Behavior and constraints:
+
+* **Default off.** When `clientid.allowUserSuppliedClientId` is unset or `false` (the default), a
+  supplied `clientId` is ignored and a UUID is generated — existing integrations are unaffected.
+* **Optional.** Even when enabled, omitting the `clientId` parameter preserves the generated-UUID
+  behavior.
+* **Validation.** A supplied `clientId` must match `^[A-Za-z0-9._-]{1,128}$` (alphanumerics plus
+  dot, underscore, and hyphen; 1 to 128 characters, matching the `token_id` column width). An
+  invalid value returns `400 Bad Request` with an `invalid_request` error.
+* **Uniqueness.** The `client_id` is the `token_id` primary key, so it must be unique. Supplying a
+  `clientId` that already exists returns `409 Conflict` with an `invalid_client` error. Uniqueness
+  is enforced atomically by the database, so there is no check-then-insert race across an HA pair.
\ No newline at end of file