Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .github/workflows/build/conf/topologies/knoxldap.xml
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,19 @@ limitations under the License.
<service>
<role>KNOXTOKEN</role>
<param>
<name>knoxsso.token.ttl</name>
<name>knox.token.ttl</name>
<value>86400000</value>
</param>
<!-- Server-managed state is required for renew / revoke / enable / disable. -->
<param>
<name>knox.token.exp.server-managed</name>
<value>true</value>
</param>
<!-- guest may renew/revoke others' tokens; own-token revoke is always allowed. -->
<param>
<name>knox.token.renewer.whitelist</name>
<value>guest</value>
</param>
</service>
<service>
<role>KNOX-AUTH-SERVICE</role>
Expand Down
7 changes: 6 additions & 1 deletion .github/workflows/build/conf/topologies/knoxtoken.xml
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,17 @@ limitations under the License.
<name>jwt.expected.sigalg</name>
<value>RS256</value>
</param>
<!-- Must match issuance so revoke / disable are enforced at federation. -->
<param>
<name>knox.token.exp.server-managed</name>
<value>true</value>
</param>
</provider>
</gateway>
<service>
<role>KNOXTOKEN</role>
<param>
<name>knoxsso.token.ttl</name>
<name>knox.token.ttl</name>
<value>86400000</value>
</param>
</service>
Expand Down
10 changes: 9 additions & 1 deletion .github/workflows/build/gateway.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/compose/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions .github/workflows/tests/common_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
292 changes: 292 additions & 0 deletions .github/workflows/tests/test_knoxtoken_jwt.py
Original file line number Diff line number Diff line change
@@ -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()
Loading