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