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..3d2bf949ef 100755 --- a/.github/workflows/build/gateway.sh +++ b/.github/workflows/build/gateway.sh @@ -34,7 +34,15 @@ 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 + +# 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 \ -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 new file mode 100644 index 0000000000..db1141b71b --- /dev/null +++ b/.github/workflows/tests/test_knoxtoken_jwt.py @@ -0,0 +1,292 @@ +# 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, lifecycle, 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. +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. +""" + +import unittest + +from requests.auth import HTTPBasicAuth + +from common_utils import gateway_base_url, knox_delete, knox_get, knox_put + + +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" + # 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" + + 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=auth) + self.assertEqual( + response.status_code, + 200, + msg=f"Token issuance failed: {response.status_code} {response.text}", + ) + 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(self.guest_auth) + + 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(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_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()