From e228bf08036719ecafbef7ff47184aead0b1f7d9 Mon Sep 17 00:00:00 2001 From: Raghav Date: Fri, 24 Jul 2026 11:37:17 +0530 Subject: [PATCH 1/3] KNOX-3395: Add KNOXTOKEN issuance and JWTProvider federation integration tests --- .github/workflows/tests/test_knoxtoken_jwt.py | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 .github/workflows/tests/test_knoxtoken_jwt.py diff --git a/.github/workflows/tests/test_knoxtoken_jwt.py b/.github/workflows/tests/test_knoxtoken_jwt.py new file mode 100644 index 0000000000..af7c6622d5 --- /dev/null +++ b/.github/workflows/tests/test_knoxtoken_jwt.py @@ -0,0 +1,113 @@ +# 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 KNOXTOKEN issuance and JWTProvider federation. + +These exercise the ``knoxtoken`` topology (JWTProvider federation) together +with the KNOXTOKEN service exposed by the ``knoxldap`` topology: + +1. A JWT is minted from the KNOXTOKEN service using Basic auth (knoxldap). +2. The resulting bearer token is presented to the JWTProvider-protected + ``knoxtoken`` topology, which must accept it and assert the caller's + identity. + +No other suite issues Knox tokens or authenticates via JWTProvider, so this +file does not overlap with the Basic-auth / preauth coverage elsewhere. +""" + +import unittest + +from requests.auth import HTTPBasicAuth + +from common_utils import gateway_base_url, knox_get + + +class TestKnoxTokenJwt(unittest.TestCase): + """Mint a Knox JWT and use it against a JWTProvider-federated topology.""" + + def setUp(self): + self.base_url = gateway_base_url() + # KNOXTOKEN service lives in the knoxldap topology (Basic auth in front). + self.token_url = self.base_url + "gateway/knoxldap/knoxtoken/api/v1/token" + # JWTProvider-protected auth service in the knoxtoken topology. + self.federated_pre_url = self.base_url + "gateway/knoxtoken/auth/api/v1/pre" + + def _issue_token(self, username, password): + """Return the parsed JSON body of a freshly issued Knox token.""" + response = knox_get( + self.token_url, + auth=HTTPBasicAuth(username, password), + ) + self.assertEqual( + response.status_code, + 200, + msg=f"Token issuance failed: {response.status_code} {response.text}", + ) + return response.json() + + def test_token_endpoint_returns_jwt_and_metadata(self): + """The KNOXTOKEN service returns a Bearer access_token plus metadata.""" + payload = self._issue_token("guest", "guest-password") + + self.assertIn("access_token", payload) + self.assertIn("token_type", payload) + self.assertIn("expires_in", payload) + + self.assertEqual(payload["token_type"], "Bearer") + + # A serialized JWS has three dot-separated segments (header.payload.sig). + access_token = payload["access_token"] + self.assertEqual( + len(access_token.split(".")), + 3, + msg="access_token does not look like a signed JWT", + ) + + def test_token_requires_authentication(self): + """The token endpoint must reject anonymous callers with 401.""" + response = knox_get(self.token_url) + self.assertEqual(response.status_code, 401) + + def test_jwt_grants_access_to_federated_topology(self): + """A valid Knox JWT authenticates against the JWTProvider topology.""" + access_token = self._issue_token("guest", "guest-password")["access_token"] + + response = knox_get( + self.federated_pre_url, + headers={"Authorization": f"Bearer {access_token}"}, + ) + self.assertEqual( + response.status_code, + 200, + msg=f"JWT was not accepted: {response.status_code} {response.text}", + ) + self.assertEqual(response.headers.get("x-knox-actor-username"), "guest") + + def test_federated_topology_requires_token(self): + """The JWTProvider topology rejects requests that carry no token.""" + response = knox_get(self.federated_pre_url) + self.assertEqual(response.status_code, 401) + + def test_federated_topology_rejects_invalid_token(self): + """A malformed bearer token must not be accepted (401).""" + response = knox_get( + self.federated_pre_url, + headers={"Authorization": "Bearer not.a.valid.jwt"}, + ) + self.assertEqual(response.status_code, 401) + + +if __name__ == "__main__": + unittest.main() From 804d921dd3fb74a356cdb7edb070e0ae6db75e16 Mon Sep 17 00:00:00 2001 From: Raghav Date: Sun, 9 Aug 2026 09:51:57 +0530 Subject: [PATCH 2/3] added more tests and logic around revoke and renew --- .../build/conf/topologies/knoxldap.xml | 12 +- .../build/conf/topologies/knoxtoken.xml | 7 +- .github/workflows/build/gateway.sh | 4 +- .github/workflows/compose/docker-compose.yml | 3 +- .github/workflows/tests/common_utils.py | 14 ++ .github/workflows/tests/test_knoxtoken_jwt.py | 229 ++++++++++++++++-- 6 files changed, 240 insertions(+), 29 deletions(-) diff --git a/.github/workflows/build/conf/topologies/knoxldap.xml b/.github/workflows/build/conf/topologies/knoxldap.xml index c89e9ee940..080e5663b1 100644 --- a/.github/workflows/build/conf/topologies/knoxldap.xml +++ b/.github/workflows/build/conf/topologies/knoxldap.xml @@ -63,9 +63,19 @@ limitations under the License. KNOXTOKEN - knoxsso.token.ttl + knox.token.ttl 86400000 + + + knox.token.exp.server-managed + true + + + + knox.token.renewer.whitelist + guest + KNOX-AUTH-SERVICE diff --git a/.github/workflows/build/conf/topologies/knoxtoken.xml b/.github/workflows/build/conf/topologies/knoxtoken.xml index d103603d77..9b197e076d 100644 --- a/.github/workflows/build/conf/topologies/knoxtoken.xml +++ b/.github/workflows/build/conf/topologies/knoxtoken.xml @@ -29,12 +29,17 @@ limitations under the License. jwt.expected.sigalg RS256 + + + knox.token.exp.server-managed + true + KNOXTOKEN - knoxsso.token.ttl + knox.token.ttl 86400000 diff --git a/.github/workflows/build/gateway.sh b/.github/workflows/build/gateway.sh index e4927b7b28..01b2487564 100755 --- a/.github/workflows/build/gateway.sh +++ b/.github/workflows/build/gateway.sh @@ -34,7 +34,9 @@ keytool -genkeypair -alias ldaps -keyalg RSA -keysize 2048 \ # 2) Store the keystore password under the alias the LDAP SSL config resolves. /knox-runtime/bin/knoxcli.sh create-alias "$KEYSTORE_PASSWORD_ALIAS" --value "$KEYSTORE_PASSWORD" -# 3) Trust that certificate in the JVM default truststore (cacerts) so the JNDI-based +# 3) Provision the gateway-level JWK required for server-managed Knox token state +# (renew / revoke / enable / disable and JWTProvider enforcement). +/knox-runtime/bin/knoxcli.sh generate-jwk --jwkAlg HS256 --saveAlias knox.token.hash.key # Shiro LDAP realm accepts it. This is additive - it does not remove the default CAs. keytool -exportcert -alias ldaps -rfc \ -keystore "$KEYSTORE" -storepass "$KEYSTORE_PASSWORD" -file /tmp/ldaps-cert.pem diff --git a/.github/workflows/compose/docker-compose.yml b/.github/workflows/compose/docker-compose.yml index 727bca2633..c6988ff4ba 100644 --- a/.github/workflows/compose/docker-compose.yml +++ b/.github/workflows/compose/docker-compose.yml @@ -92,7 +92,8 @@ services: # Point the fabric8 client used by the k8s ServiceAccountValidator at k3s. - KUBECONFIG=/k3s/knox-kubeconfig.yaml volumes: -# - ./topologies:/knox-runtime/conf/topologies + # Mount topologies from the workspace so config changes apply on restart + - ../build/conf/topologies:/knox-runtime/conf/topologies - ./logs:/knox-runtime/logs # - ./knoxshell:/knoxshell - k3s-output:/k3s:ro diff --git a/.github/workflows/tests/common_utils.py b/.github/workflows/tests/common_utils.py index 0a773b44e6..b7d043a977 100644 --- a/.github/workflows/tests/common_utils.py +++ b/.github/workflows/tests/common_utils.py @@ -53,6 +53,20 @@ def knox_post(url: str, **kwargs: Any) -> requests.Response: return requests.post(url, **opts) +def knox_put(url: str, **kwargs: Any) -> requests.Response: + """PUT against Knox with verify=False and default timeout unless overridden.""" + opts: dict[str, Any] = {"verify": False, "timeout": KNOX_REQUEST_TIMEOUT} + opts.update(kwargs) + return requests.put(url, **opts) + + +def knox_delete(url: str, **kwargs: Any) -> requests.Response: + """DELETE against Knox with verify=False and default timeout unless overridden.""" + opts: dict[str, Any] = {"verify": False, "timeout": KNOX_REQUEST_TIMEOUT} + opts.update(kwargs) + return requests.delete(url, **opts) + + def collect_actor_group_values( response: requests.Response, prefix: str = "x-knox-actor-groups" ) -> list[str]: diff --git a/.github/workflows/tests/test_knoxtoken_jwt.py b/.github/workflows/tests/test_knoxtoken_jwt.py index af7c6622d5..e0b84e07b5 100644 --- a/.github/workflows/tests/test_knoxtoken_jwt.py +++ b/.github/workflows/tests/test_knoxtoken_jwt.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""End-to-end tests for KNOXTOKEN issuance and JWTProvider federation. +"""End-to-end tests for KNOXTOKEN issuance, lifecycle, and JWTProvider federation. These exercise the ``knoxtoken`` topology (JWTProvider federation) together with the KNOXTOKEN service exposed by the ``knoxldap`` topology: @@ -22,6 +22,10 @@ 2. The resulting bearer token is presented to the JWTProvider-protected ``knoxtoken`` topology, which must accept it and assert the caller's identity. +3. Lifecycle operations (renew / revoke / enable / disable) require + ``knox.token.exp.server-managed=true`` on both the issuing service and + the JWTProvider so that revocation and disablement are enforced at + federation time — not just acknowledged by the management API. No other suite issues Knox tokens or authenticates via JWTProvider, so this file does not overlap with the Basic-auth / preauth coverage elsewhere. @@ -31,7 +35,7 @@ from requests.auth import HTTPBasicAuth -from common_utils import gateway_base_url, knox_get +from common_utils import gateway_base_url, knox_delete, knox_get, knox_put class TestKnoxTokenJwt(unittest.TestCase): @@ -41,30 +45,61 @@ def setUp(self): self.base_url = gateway_base_url() # KNOXTOKEN service lives in the knoxldap topology (Basic auth in front). self.token_url = self.base_url + "gateway/knoxldap/knoxtoken/api/v1/token" + # Non-deprecated lifecycle paths (PUT renew / DELETE revoke). + self.token_v2_url = self.base_url + "gateway/knoxldap/knoxtoken/api/v2/token" # JWTProvider-protected auth service in the knoxtoken topology. self.federated_pre_url = self.base_url + "gateway/knoxtoken/auth/api/v1/pre" - def _issue_token(self, username, password): + self.guest_auth = HTTPBasicAuth("guest", "guest-password") + self.admin_auth = HTTPBasicAuth("admin", "admin-password") + + def _issue_token(self, auth): """Return the parsed JSON body of a freshly issued Knox token.""" - response = knox_get( - self.token_url, - auth=HTTPBasicAuth(username, password), - ) + response = knox_get(self.token_url, auth=auth) self.assertEqual( response.status_code, 200, msg=f"Token issuance failed: {response.status_code} {response.text}", ) - return response.json() + payload = response.json() + self.assertIn("access_token", payload) + self.assertIn("token_id", payload) + return payload + + def _federate(self, access_token): + """Present a bearer token to the JWTProvider-protected topology.""" + return knox_get( + self.federated_pre_url, + headers={"Authorization": f"Bearer {access_token}"}, + ) + + def _assert_federates(self, access_token, expected_username): + response = self._federate(access_token) + self.assertEqual( + response.status_code, + 200, + msg=f"JWT was not accepted: {response.status_code} {response.text}", + ) + self.assertEqual( + response.headers.get("x-knox-actor-username"), + expected_username, + ) + + def assert_federation_rejected(self, access_token): + """Assert that a bearer token is rejected by the JWTProvider topology.""" + response = self._federate(access_token) + self.assertEqual( + response.status_code, + 401, + msg=f"Expected federation rejection, got {response.status_code}: {response.text}", + ) def test_token_endpoint_returns_jwt_and_metadata(self): """The KNOXTOKEN service returns a Bearer access_token plus metadata.""" - payload = self._issue_token("guest", "guest-password") + payload = self._issue_token(self.guest_auth) - self.assertIn("access_token", payload) self.assertIn("token_type", payload) self.assertIn("expires_in", payload) - self.assertEqual(payload["token_type"], "Bearer") # A serialized JWS has three dot-separated segments (header.payload.sig). @@ -82,32 +117,176 @@ def test_token_requires_authentication(self): def test_jwt_grants_access_to_federated_topology(self): """A valid Knox JWT authenticates against the JWTProvider topology.""" - access_token = self._issue_token("guest", "guest-password")["access_token"] - - response = knox_get( - self.federated_pre_url, - headers={"Authorization": f"Bearer {access_token}"}, - ) - self.assertEqual( - response.status_code, - 200, - msg=f"JWT was not accepted: {response.status_code} {response.text}", - ) - self.assertEqual(response.headers.get("x-knox-actor-username"), "guest") + access_token = self._issue_token(self.guest_auth)["access_token"] + self._assert_federates(access_token, "guest") def test_federated_topology_requires_token(self): """The JWTProvider topology rejects requests that carry no token.""" response = knox_get(self.federated_pre_url) self.assertEqual(response.status_code, 401) - def test_federated_topology_rejects_invalid_token(self): - """A malformed bearer token must not be accepted (401).""" + def test_federated_topology_rejects_malformed_token(self): + """A structurally malformed bearer token must not be accepted (401).""" response = knox_get( self.federated_pre_url, headers={"Authorization": "Bearer not.a.valid.jwt"}, ) self.assertEqual(response.status_code, 401) + def test_federated_topology_rejects_wrong_signature(self): + """ + A parseable JWT with a bad signature must fail RS256 verification. + """ + access_token = self._issue_token(self.guest_auth)["access_token"] + + header, payload, signature = access_token.split(".") + mid = len(signature) // 2 + replacement = "A" if signature[mid] != "A" else "B" + tampered_signature = signature[:mid] + replacement + signature[mid + 1 :] + tampered = ".".join([header, payload, tampered_signature]) + self.assertNotEqual(tampered, access_token) + self.assertEqual(len(tampered.split(".")), 3) + + self.assert_federation_rejected(tampered) + + def test_revoke_is_enforced_at_federation(self): + """Mint → revoke → re-present must yield 401 (not just a revoked:true response).""" + payload = self._issue_token(self.guest_auth) + access_token = payload["access_token"] + self._assert_federates(access_token, "guest") + + revoke = knox_delete( + self.token_v2_url + "/revoke", + data=access_token, + auth=self.guest_auth, + ) + self.assertEqual( + revoke.status_code, + 200, + msg=f"Revoke failed: {revoke.status_code} {revoke.text}", + ) + self.assertEqual(revoke.json().get("revoked"), "true") + + self.assert_federation_rejected(access_token) + + def test_renew_extends_and_token_still_federates(self): + """A whitelisted renewer gets renewed:true and the token still federates.""" + access_token = self._issue_token(self.guest_auth)["access_token"] + + renew = knox_put( + self.token_v2_url + "/renew", + data=access_token, + auth=self.guest_auth, + ) + self.assertEqual( + renew.status_code, + 200, + msg=f"Renew failed: {renew.status_code} {renew.text}", + ) + body = renew.json() + self.assertEqual(body.get("renewed"), "true") + self.assertIn("expires", body) + + self._assert_federates(access_token, "guest") + + def test_renew_forbidden_for_non_whitelisted_user(self): + """admin is not on knox.token.renewer.whitelist and must get 403 on renew.""" + access_token = self._issue_token(self.guest_auth)["access_token"] + + renew = knox_put( + self.token_v2_url + "/renew", + data=access_token, + auth=self.admin_auth, + ) + self.assertEqual(renew.status_code, 403) + body = renew.json() + self.assertEqual(body.get("renewed"), "false") + self.assertIn("not authorized", body.get("error", "").lower()) + + def test_revoke_forbidden_for_non_owner_non_whitelisted_user(self): + """admin may not revoke guest's token without being on the renewer whitelist.""" + access_token = self._issue_token(self.guest_auth)["access_token"] + + revoke = knox_delete( + self.token_v2_url + "/revoke", + data=access_token, + auth=self.admin_auth, + ) + self.assertEqual(revoke.status_code, 403) + body = revoke.json() + self.assertEqual(body.get("revoked"), "false") + self.assertIn("not authorized", body.get("error", "").lower()) + + def test_disable_is_enforced_at_federation(self): + """Disabling a token must stop federation; re-enabling restores it.""" + payload = self._issue_token(self.guest_auth) + access_token = payload["access_token"] + token_id = payload["token_id"] + self._assert_federates(access_token, "guest") + + disable = knox_put( + self.token_url + "/disable", + data=token_id, + auth=self.guest_auth, + ) + self.assertEqual( + disable.status_code, + 200, + msg=f"Disable failed: {disable.status_code} {disable.text}", + ) + self.assertEqual(disable.json().get("setEnabledFlag"), "true") + self.assertEqual(disable.json().get("isEnabled"), "false") + self.assert_federation_rejected(access_token) + + enable = knox_put( + self.token_url + "/enable", + data=token_id, + auth=self.guest_auth, + ) + self.assertEqual( + enable.status_code, + 200, + msg=f"Enable failed: {enable.status_code} {enable.text}", + ) + self.assertEqual(enable.json().get("setEnabledFlag"), "true") + self.assertEqual(enable.json().get("isEnabled"), "true") + self._assert_federates(access_token, "guest") + + def test_enable_already_enabled_returns_400(self): + """Enabling an already-enabled token returns 400 ALREADY_ENABLED.""" + token_id = self._issue_token(self.guest_auth)["token_id"] + + enable = knox_put( + self.token_url + "/enable", + data=token_id, + auth=self.guest_auth, + ) + self.assertEqual(enable.status_code, 400) + body = enable.json() + self.assertEqual(body.get("setEnabledFlag"), "false") + self.assertIn("already enabled", body.get("error", "").lower()) + + def test_disable_already_disabled_returns_400(self): + """Disabling an already-disabled token returns 400 ALREADY_DISABLED.""" + token_id = self._issue_token(self.guest_auth)["token_id"] + + first = knox_put( + self.token_url + "/disable", + data=token_id, + auth=self.guest_auth, + ) + self.assertEqual(first.status_code, 200) + + second = knox_put( + self.token_url + "/disable", + data=token_id, + auth=self.guest_auth, + ) + self.assertEqual(second.status_code, 400) + body = second.json() + self.assertEqual(body.get("setEnabledFlag"), "false") + self.assertIn("already disabled", body.get("error", "").lower()) + if __name__ == "__main__": unittest.main() From c4a04588a9ceb30402f244cc6c732c17185f37d8 Mon Sep 17 00:00:00 2001 From: Raghav Maheshwari Date: Tue, 11 Aug 2026 12:08:47 +0530 Subject: [PATCH 3/3] Fixed code as per Sandor review --- .github/workflows/build/gateway.sh | 10 ++++++++-- .github/workflows/tests/test_knoxtoken_jwt.py | 8 ++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build/gateway.sh b/.github/workflows/build/gateway.sh index 01b2487564..3d2bf949ef 100755 --- a/.github/workflows/build/gateway.sh +++ b/.github/workflows/build/gateway.sh @@ -34,8 +34,14 @@ keytool -genkeypair -alias ldaps -keyalg RSA -keysize 2048 \ # 2) Store the keystore password under the alias the LDAP SSL config resolves. /knox-runtime/bin/knoxcli.sh create-alias "$KEYSTORE_PASSWORD_ALIAS" --value "$KEYSTORE_PASSWORD" -# 3) Provision the gateway-level JWK required for server-managed Knox token state -# (renew / revoke / enable / disable and JWTProvider enforcement). +# 3) Provision the gateway-level JWK required for server-managed Knox token state (renew / revoke / enable / disable and JWTProvider enforcement). +/knox-runtime/bin/knoxcli.sh generate-jwk --jwkAlg HS256 --saveAlias knox.token.hash.key + +# 4) Trust that certificate in the JVM default truststore (cacerts) so the JNDI-based +# Shiro LDAP realm accepts it. This is additive - it does not remove the default CAs. +# keytool -exportcert -alias ldaps -rfc +# -keystore "$KEYSTORE" -storepass "$KEYSTORE_PASSWORD" -file /tmp/ldaps-cert.pem + /knox-runtime/bin/knoxcli.sh generate-jwk --jwkAlg HS256 --saveAlias knox.token.hash.key # Shiro LDAP realm accepts it. This is additive - it does not remove the default CAs. keytool -exportcert -alias ldaps -rfc \ diff --git a/.github/workflows/tests/test_knoxtoken_jwt.py b/.github/workflows/tests/test_knoxtoken_jwt.py index e0b84e07b5..db1141b71b 100644 --- a/.github/workflows/tests/test_knoxtoken_jwt.py +++ b/.github/workflows/tests/test_knoxtoken_jwt.py @@ -85,7 +85,7 @@ def _assert_federates(self, access_token, expected_username): expected_username, ) - def assert_federation_rejected(self, access_token): + def _assert_federation_rejected(self, access_token): """Assert that a bearer token is rejected by the JWTProvider topology.""" response = self._federate(access_token) self.assertEqual( @@ -147,7 +147,7 @@ def test_federated_topology_rejects_wrong_signature(self): self.assertNotEqual(tampered, access_token) self.assertEqual(len(tampered.split(".")), 3) - self.assert_federation_rejected(tampered) + self._assert_federation_rejected(tampered) def test_revoke_is_enforced_at_federation(self): """Mint → revoke → re-present must yield 401 (not just a revoked:true response).""" @@ -167,7 +167,7 @@ def test_revoke_is_enforced_at_federation(self): ) self.assertEqual(revoke.json().get("revoked"), "true") - self.assert_federation_rejected(access_token) + self._assert_federation_rejected(access_token) def test_renew_extends_and_token_still_federates(self): """A whitelisted renewer gets renewed:true and the token still federates.""" @@ -236,7 +236,7 @@ def test_disable_is_enforced_at_federation(self): ) self.assertEqual(disable.json().get("setEnabledFlag"), "true") self.assertEqual(disable.json().get("isEnabled"), "false") - self.assert_federation_rejected(access_token) + self._assert_federation_rejected(access_token) enable = knox_put( self.token_url + "/enable",