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/14 10:53:29 UTC
(knox) branch master updated: KNOX-3449: Prove Knox accepts Kubernetes ServiceAccount tokens on the RFC 8693 token-exchange path (#1396)
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 746c86592 KNOX-3449: Prove Knox accepts Kubernetes ServiceAccount tokens on the RFC 8693 token-exchange path (#1396)
746c86592 is described below
commit 746c86592a0382673c8b8102f5036daf4c581dca
Author: Sandor Molnar <sm...@apache.org>
AuthorDate: Mon Sep 14 12:53:24 2026 +0200
KNOX-3449: Prove Knox accepts Kubernetes ServiceAccount tokens on the RFC 8693 token-exchange path (#1396)
---
.../build/conf/topologies/knoxidf-admin.xml | 78 ++++++++
.github/workflows/compose/docker-compose.yml | 43 +++-
.../k8s-delegation/gateway-k8s-delegation.sh | 43 ++++
.github/workflows/tests/test_k8s_delegation.py | 220 +++++++++++++++++++++
.../federation/jwt/filter/AbstractJWTFilter.java | 32 ++-
.../provider/federation/CommonJWTFilterTest.java | 2 +
.../JWTFederationFilterTokenExchangeTest.java | 63 ++++++
7 files changed, 475 insertions(+), 6 deletions(-)
diff --git a/.github/workflows/build/conf/topologies/knoxidf-admin.xml b/.github/workflows/build/conf/topologies/knoxidf-admin.xml
new file mode 100644
index 000000000..a63f2090b
--- /dev/null
+++ b/.github/workflows/build/conf/topologies/knoxidf-admin.xml
@@ -0,0 +1,78 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ 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.
+-->
+<!--
+ KnoxIDF admin topology. Hosts the KNOXIDF_ADMIN service role, which mounts all
+ KnoxIDF admin REST APIs under knoxidf/admin/** (currently the TrustedOIDCIssuers
+ registry used by RFC 8693 token exchange). Access is restricted to the LDAP
+ 'admin' user: ShiroProvider authenticates over the embedded LDAPS server (the
+ same one knoxidf-ldap.xml uses) and AclsAuthz limits the KNOXIDF_ADMIN role to
+ that single principal (KNOXIDF_ADMIN.acl = admin;*;*).
+
+ Declaring KNOXIDF_ADMIN here also auto-enables the gateway-wide, H2-backed
+ TrustedOidcIssuerService, so an issuer registered through this topology is
+ trusted by the token-exchange path served on knoxidf-token.
+-->
+<topology>
+ <gateway>
+ <provider>
+ <role>authentication</role>
+ <name>ShiroProvider</name>
+ <enabled>true</enabled>
+ <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=hadoop,dc=apache,dc=org</value>
+ </param>
+ <param>
+ <name>main.ldapRealm.contextFactory.url</name>
+ <value>ldaps://localhost:33390</value>
+ </param>
+ <param>
+ <name>main.ldapRealm.contextFactory.authenticationMechanism</name>
+ <value>simple</value>
+ </param>
+ <param>
+ <name>urls./**</name>
+ <value>authcBasic</value>
+ </param>
+ </provider>
+ <provider>
+ <role>authorization</role>
+ <name>AclsAuthz</name>
+ <enabled>true</enabled>
+ <param>
+ <!-- <ROLE>.acl applies one ACL to every endpoint under the role.
+ Format is users;groups;permissions; only the 'admin' user passes. -->
+ <name>KNOXIDF_ADMIN.acl</name>
+ <value>admin;*;*</value>
+ </param>
+ </provider>
+ <provider>
+ <role>identity-assertion</role>
+ <name>Default</name>
+ <enabled>true</enabled>
+ </provider>
+ </gateway>
+
+ <service>
+ <role>KNOXIDF_ADMIN</role>
+ </service>
+</topology>
diff --git a/.github/workflows/compose/docker-compose.yml b/.github/workflows/compose/docker-compose.yml
index 60ff37bed..26b9a5ccd 100644
--- a/.github/workflows/compose/docker-compose.yml
+++ b/.github/workflows/compose/docker-compose.yml
@@ -30,6 +30,11 @@ services:
# ServiceAccountValidator. It exposes a real API server that the fabric8
# client inside Knox talks to. --tls-san=k3s makes the API server cert valid
# for the "k3s" compose hostname so the rewritten kubeconfig can target it.
+ #
+ # The service-account-issuer/jwks-uri args make k3s a real OIDC issuer at
+ # https://k3s:6443 whose discovery + JWKS endpoints Knox can fetch. This lets
+ # the RFC 8693 token-exchange path (test_k8s_delegation.py) verify real
+ # ServiceAccount projected tokens, whose 'iss' then resolves to https://k3s:6443.
k3s:
image: rancher/k3s:v1.31.5-k3s1
command:
@@ -40,6 +45,14 @@ services:
- --disable=local-storage
- --tls-san=k3s
- --write-kubeconfig-mode=666
+ - --kube-apiserver-arg=service-account-issuer=https://k3s:6443
+ - --kube-apiserver-arg=service-account-jwks-uri=https://k3s:6443/openid/v1/jwks
+ # k3s defaults the apiserver to --anonymous-auth=false, which rejects Knox's
+ # unauthenticated OIDC discovery/JWKS fetch at the authentication layer (401),
+ # before RBAC. Re-enable it so the knox-sa-issuer-discovery ClusterRoleBinding
+ # (system:service-account-issuer-discovery -> system:unauthenticated, created by
+ # k8s-bootstrap) can grant anonymous access to exactly those two endpoints.
+ - --kube-apiserver-arg=anonymous-auth=true
privileged: true
environment:
- K3S_KUBECONFIG_OUTPUT=/output/kubeconfig.yaml
@@ -78,6 +91,28 @@ services:
echo 'Applying test fixtures...'
kubectl apply -f /manifests/
kubectl get serviceaccount test-sa -n test
+
+ echo 'Opening anonymous access to the OIDC discovery + JWKS endpoints...'
+ # Knox fetches /.well-known/openid-configuration and /openid/v1/jwks
+ # unauthenticated on the token-exchange path; k8s gates these behind the
+ # service-account-issuer-discovery ClusterRole, so bind it to
+ # system:unauthenticated for the throwaway CI cluster.
+ kubectl create clusterrolebinding knox-sa-issuer-discovery \
+ --clusterrole=system:service-account-issuer-discovery \
+ --group=system:unauthenticated \
+ --dry-run=client -o yaml | kubectl apply -f -
+
+ echo 'Exporting k3s CA cert, issuer URL and a projected SA token...'
+ # The kubeconfig CA is the API server's serving CA -- exactly what the JVM
+ # needs to trust the HTTPS discovery/JWKS endpoints. knox imports it into
+ # cacerts on startup (see gateway-k8s-delegation.sh).
+ kubectl config view --raw \
+ -o jsonpath='{.clusters[0].cluster.certificate-authority-data}' \
+ | base64 -d > /output/k3s-ca.crt
+ printf 'https://k3s:6443' > /output/issuer-url
+ # Projected SA token for namespace test / serviceaccount test-sa. Its 'iss'
+ # is https://k3s:6443 (the configured service-account-issuer).
+ kubectl create token test-sa -n test --duration=3600s > /output/sa-token
echo 'k8s bootstrap complete'
volumes:
- k3s-output:/output
@@ -87,7 +122,10 @@ services:
knox:
image: apache/knox-dev:${IMAGE_TAG:-master}
- command: /gateway.sh
+ # Custom entrypoint: import the k3s serving CA into the JVM cacerts (so the
+ # token-exchange path can fetch the k3s OIDC discovery/JWKS over HTTPS), then
+ # hand off to the standard /gateway.sh.
+ command: /gateway-k8s-delegation.sh
environment:
# Point the fabric8 client used by the k8s ServiceAccountValidator at k3s.
- KUBECONFIG=/k3s/knox-kubeconfig.yaml
@@ -97,6 +135,7 @@ services:
- ./logs:/knox-runtime/logs
# - ./knoxshell:/knoxshell
- k3s-output:/k3s:ro
+ - ./k8s-delegation/gateway-k8s-delegation.sh:/gateway-k8s-delegation.sh:ro
depends_on:
ldap:
condition: service_started
@@ -108,6 +147,8 @@ services:
working_dir: /tests
volumes:
- ../tests:/tests
+ # Exposes the exported SA token + issuer URL to test_k8s_delegation.py.
+ - k3s-output:/k3s:ro
environment:
- KNOX_GATEWAY_URL=https://knox:8443/
command: >
diff --git a/.github/workflows/compose/k8s-delegation/gateway-k8s-delegation.sh b/.github/workflows/compose/k8s-delegation/gateway-k8s-delegation.sh
new file mode 100755
index 000000000..0f9cf89fc
--- /dev/null
+++ b/.github/workflows/compose/k8s-delegation/gateway-k8s-delegation.sh
@@ -0,0 +1,43 @@
+#!/bin/sh
+# 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.
+#
+# Custom knox entrypoint used by the compose stack (docker-compose.yml).
+#
+# The RFC 8693 token-exchange path fetches a dynamically-registered issuer's
+# OIDC discovery document and JWKS to verify the subject token's signature.
+# Both fetches (Nimbus JWKS retrieval + OIDC discovery) use the JVM default
+# SSLContext / cacerts -- NOT the gateway.httpclient.truststore.* config -- so
+# for Knox to trust the throwaway k3s cluster's HTTPS endpoints we must import
+# its serving CA into cacerts before the gateway starts.
+#
+# The k8s-bootstrap service exports that CA to the shared k3s-output volume
+# (mounted read-only at /k3s) and knox depends_on it with
+# service_completed_successfully, so the file is present by the time this runs.
+set -e
+
+CA_CERT=/k3s/k3s-ca.crt
+echo "Waiting for k3s CA cert at ${CA_CERT}..."
+until [ -f "${CA_CERT}" ]; do sleep 2; done
+
+# Additive import -- does not remove the default CAs. Fresh container on every CI
+# run, so the alias never pre-exists.
+keytool -importcert -noprompt -alias k3s-ca \
+ -keystore "${JAVA_HOME}/lib/security/cacerts" -storepass changeit \
+ -file "${CA_CERT}"
+echo "Imported k3s CA into JVM cacerts."
+
+# Hand off to the standard entrypoint (LDAPS dev cert + token JWK + gateway start).
+exec /gateway.sh
diff --git a/.github/workflows/tests/test_k8s_delegation.py b/.github/workflows/tests/test_k8s_delegation.py
new file mode 100644
index 000000000..46472a5a6
--- /dev/null
+++ b/.github/workflows/tests/test_k8s_delegation.py
@@ -0,0 +1,220 @@
+# 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 k8s ServiceAccount token delegation via RFC 8693.
+
+These prove that Knox accepts a real Kubernetes ServiceAccount projected token
+on the OAuth 2.0 Token Exchange path once the issuing cluster is registered as a
+trusted OIDC issuer. The compose stack turns the throwaway k3s cluster into an
+OIDC issuer at https://k3s:6443 and exports, to the shared volume mounted at
+/k3s, a freshly minted projected token for ServiceAccount test-sa in namespace
+test (see docker-compose.yml).
+
+Two topologies are exercised:
+ - knoxidf-admin -- the KNOXIDF_ADMIN TrustedOIDCIssuers registry admin API,
+ restricted to the LDAP 'admin' user (HTTP Basic).
+ - knoxidf-token -- the JWTProvider token endpoint that performs the exchange.
+
+Because the registry is a gateway-wide service, an issuer registered through the
+admin topology is trusted by the token-exchange path on the token topology.
+
+Covered acceptance criteria: real SA token is a genuine OIDC-issued JWT (AC2),
+same-subject exchange succeeds (AC3), an unregistered issuer is rejected without
+a JWKS fetch (AC6), an expired token is rejected before signature verification
+(AC7), and the full admin lifecycle register/list/refresh/remove works (AC8/AC9).
+"""
+
+import base64
+import json
+import unittest
+
+from requests.auth import HTTPBasicAuth
+
+from common_utils import gateway_base_url, knox_get, knox_post, knox_delete
+
+# The LDAP 'admin' user is the only principal the knoxidf-admin ACL permits
+# (see conf/topologies/knoxidf-admin.xml and the demo users.ldif).
+ADMIN_USER = "admin"
+ADMIN_PASSWORD = "admin-password"
+
+# The k3s cluster is configured (service-account-issuer) to issue SA tokens whose
+# 'iss' is this URL; it is also the OIDC discovery base Knox registers as trusted.
+ISSUER_URL = "https://k3s:6443"
+
+# RFC 8693 token-exchange identifiers.
+TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange"
+SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:jwt"
+
+# The k8s-bootstrap service writes a projected SA token here on the shared volume.
+SA_TOKEN_FILE = "/k3s/sa-token"
+
+# An issuer that is never registered, used to prove unregistered issuers are rejected.
+UNREGISTERED_ISSUER = "https://unregistered.example.com"
+
+
+def _b64url(raw):
+ """Base64url-encode raw bytes without padding, as JOSE requires."""
+ return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
+
+
+def _decode_segment(token, index):
+ """Decode the JOSE header (index 0) or payload (index 1) of a JWT to a dict."""
+ segment = token.split(".")[index]
+ padding = "=" * (-len(segment) % 4)
+ return json.loads(base64.urlsafe_b64decode(segment + padding))
+
+
+def _unsigned_jwt(payload):
+ """Serialize a payload into a header.payload.signature JWT with a dummy signature.
+
+ The negative paths under test (unregistered issuer, expired token) are both
+ rejected before Knox verifies the signature, so no real signing key is needed.
+ """
+ header = {"alg": "RS256", "typ": "JWT", "kid": "test-key"}
+ return ".".join([
+ _b64url(json.dumps(header, separators=(",", ":")).encode("utf-8")),
+ _b64url(json.dumps(payload, separators=(",", ":")).encode("utf-8")),
+ _b64url(b"dummy-signature"),
+ ])
+
+
+class TestK8sDelegation(unittest.TestCase):
+ """RFC 8693 token exchange of real k8s ServiceAccount tokens through Knox."""
+
+ def setUp(self):
+ base_url = gateway_base_url()
+ self.admin_url = base_url + "gateway/knoxidf-admin/knoxidf/admin/v1/trusted-oidc-issuers"
+ self.token_url = base_url + "gateway/knoxidf-token/knoxidf/api/v1/token"
+ try:
+ with open(SA_TOKEN_FILE, encoding="utf-8") as handle:
+ self.sa_token = handle.read().strip()
+ except OSError:
+ self.skipTest(f"SA token not available at {SA_TOKEN_FILE}")
+
+ def _admin_auth(self):
+ """HTTP Basic credentials for the knoxidf-admin API."""
+ return HTTPBasicAuth(ADMIN_USER, ADMIN_PASSWORD)
+
+ def _register_issuer(self, issuer_url):
+ """Register a trusted OIDC issuer (dynamic JWKS via discovery)."""
+ return knox_post(
+ self.admin_url,
+ json={"issuerUrl": issuer_url, "dynamicJwks": True, "clusterName": "k3s"},
+ auth=self._admin_auth(),
+ )
+
+ def _list_issuers(self):
+ """Return the registered trusted issuers as a list of dicts."""
+ response = knox_get(self.admin_url, auth=self._admin_auth())
+ self.assertEqual(response.status_code, 200, response.text)
+ return response.json()
+
+ def _remove_issuer(self, issuer_url):
+ """Deregister a trusted OIDC issuer (idempotent at the service layer)."""
+ return knox_delete(
+ self.admin_url, params={"issuerUrl": issuer_url}, auth=self._admin_auth()
+ )
+
+ def _refresh_jwks(self, issuer_url):
+ """Force a re-resolution of the issuer's JWKS URI from its discovery document."""
+ return knox_post(
+ self.admin_url + "/refresh-jwks",
+ params={"issuerUrl": issuer_url},
+ auth=self._admin_auth(),
+ )
+
+ def _exchange(self, subject_token):
+ """Perform a same-subject RFC 8693 token exchange (no actor_token)."""
+ return knox_post(
+ self.token_url,
+ data={
+ "grant_type": TOKEN_EXCHANGE_GRANT,
+ "subject_token": subject_token,
+ "subject_token_type": SUBJECT_TOKEN_TYPE,
+ },
+ )
+
+ def _issuer_urls(self):
+ """Return just the issuerUrl values currently in the registry."""
+ return [entry.get("issuerUrl") for entry in self._list_issuers()]
+
+ def test_sa_token_is_a_genuine_oidc_jwt(self):
+ """AC2: the exported token is a real k3s-issued JWT for the test ServiceAccount."""
+ header = _decode_segment(self.sa_token, 0)
+ payload = _decode_segment(self.sa_token, 1)
+ # A real signed JWT carries a signing algorithm and key id in its JOSE header.
+ self.assertIn("alg", header)
+ self.assertIn("kid", header)
+ # Knox's verifier tolerates a missing 'typ'; k8s may or may not set it.
+ self.assertIn(header.get("typ"), (None, "JWT"))
+ self.assertEqual(payload.get("iss"), ISSUER_URL)
+ self.assertIn("system:serviceaccount:test:test-sa", payload.get("sub", ""))
+
+ def test_unregistered_issuer_is_rejected(self):
+ """AC6: a token from an unregistered issuer is rejected without a JWKS fetch."""
+ # Ensure the issuer really is absent from the registry.
+ self._remove_issuer(UNREGISTERED_ISSUER)
+ token = _unsigned_jwt({
+ "iss": UNREGISTERED_ISSUER,
+ "sub": "system:serviceaccount:test:test-sa",
+ "exp": 4102444800, # year 2100, so expiry is not what triggers the rejection
+ })
+ response = self._exchange(token)
+ self.assertEqual(response.status_code, 401, response.text)
+ self.assertIn("invalid_request", response.text)
+
+ def test_issuer_lifecycle_and_token_exchange(self):
+ """AC3/AC7/AC8/AC9: register, exchange a real token, reject an expired one, remove."""
+ # Start from a known-clean state (deregister is idempotent -> 204 either way).
+ self.assertEqual(self._remove_issuer(ISSUER_URL).status_code, 204)
+
+ # AC8: registering a new trusted issuer returns 201 Created.
+ self.assertEqual(self._register_issuer(ISSUER_URL).status_code, 201)
+
+ # AC9: the registry now lists it, and a second registration conflicts.
+ self.assertIn(ISSUER_URL, self._issuer_urls())
+ self.assertEqual(self._register_issuer(ISSUER_URL).status_code, 409)
+
+ # Refreshing the JWKS URI from discovery is accepted (204 No Content).
+ self.assertEqual(self._refresh_jwks(ISSUER_URL).status_code, 204)
+
+ # AC3: same-subject exchange of the real SA token yields a Knox access token.
+ exchanged = self._exchange(self.sa_token)
+ self.assertEqual(exchanged.status_code, 200, exchanged.text)
+ self.assertIn("access_token", exchanged.json())
+
+ # AC7: an expired token for the registered issuer is rejected on expiry,
+ # before signature verification (so the dummy signature is never reached).
+ expired = _unsigned_jwt({
+ "iss": ISSUER_URL,
+ "sub": "system:serviceaccount:test:test-sa",
+ "exp": 1000000000, # year 2001
+ })
+ expired_response = self._exchange(expired)
+ self.assertEqual(expired_response.status_code, 401, expired_response.text)
+ self.assertIn("Token has expired", expired_response.text)
+
+ # AC9: removing the issuer returns 204 and drops it from the registry.
+ self.assertEqual(self._remove_issuer(ISSUER_URL).status_code, 204)
+ self.assertNotIn(ISSUER_URL, self._issuer_urls())
+
+ # Once the issuer is gone, even the previously accepted token is rejected.
+ after_removal = self._exchange(self.sa_token)
+ self.assertEqual(after_removal.status_code, 401, after_removal.text)
+ self.assertIn("invalid_request", after_removal.text)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/AbstractJWTFilter.java b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/AbstractJWTFilter.java
index 7b7d3d803..763cf0119 100644
--- a/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/AbstractJWTFilter.java
+++ b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/AbstractJWTFilter.java
@@ -267,7 +267,14 @@ public abstract class AbstractJWTFilter implements Filter {
private Date getServerManagedStateExpiration(final String tokenId) throws UnknownTokenException {
Date expires = null;
- if (tokenStateService != null) {
+ // Server-managed token state is keyed by the Knox token id (the knox.id claim). An
+ // externally-issued token accepted on the token-exchange path (e.g. a Kubernetes
+ // ServiceAccount projected token from a registered OIDC issuer) has no knox.id, so
+ // TokenUtils.getTokenId returns null. Such a token can never have Knox-managed state:
+ // skip the lookup and let the caller fall back to the token's own exp claim. Passing a
+ // null/empty id to the state service would raise IllegalArgumentException (surfacing as a
+ // 500), rather than the UnknownTokenException the caller handles gracefully.
+ if (tokenStateService != null && tokenId != null && !tokenId.isEmpty()) {
long value = tokenStateService.getTokenExpiration(tokenId);
if (value > 0) {
expires = new Date(value);
@@ -276,6 +283,21 @@ public abstract class AbstractJWTFilter implements Filter {
return expires;
}
+ /**
+ * Look up server-managed metadata for a token, tolerating the absence of token state.
+ *
+ * <p>Returns {@code null} when there is no token state service, or when {@code tokenId} is
+ * null/empty. Server-managed metadata is keyed by the Knox token id (the {@code knox.id}
+ * claim); an externally-issued token accepted on the token-exchange path (e.g. a Kubernetes
+ * ServiceAccount projected token from a registered OIDC issuer) has none. Passing its null id
+ * to the state service would raise {@link IllegalArgumentException} (surfacing as a 500) or
+ * {@link UnknownTokenException} (a spurious 401), rather than simply resolving to no metadata.
+ */
+ private TokenMetadata getTokenMetadata(final String tokenId) throws UnknownTokenException {
+ return (tokenStateService == null || tokenId == null || tokenId.isEmpty())
+ ? null : tokenStateService.getTokenMetadata(tokenId);
+ }
+
/**
* Validate whether any of the accepted audience claims is present in the
* issued token claims list for audience. Override this method in subclasses
@@ -401,7 +423,7 @@ public abstract class AbstractJWTFilter implements Filter {
}
public Subject createSubjectFromTokenIdentifier(final String tokenId) throws UnknownTokenException {
- TokenMetadata metadata = tokenStateService.getTokenMetadata(tokenId);
+ TokenMetadata metadata = getTokenMetadata(tokenId);
if (metadata != null) {
// using tokenID and passcode as CLIENT_ID and CLIENT_SECRET will
// result in a metadata item called "type". If the value is set
@@ -526,7 +548,7 @@ public abstract class AbstractJWTFilter implements Filter {
if (validateAudiences(token)) {
Date nbf = token.getNotBeforeDate();
if (nbf == null || new Date().after(nbf)) {
- final TokenMetadata tokenMetadata = tokenStateService == null ? null : tokenStateService.getTokenMetadata(tokenId);
+ final TokenMetadata tokenMetadata = getTokenMetadata(tokenId);
if (isTokenEnabled(tokenMetadata)) {
if (isIdleTimeoutLimitNotExceeded(tokenMetadata)) {
final boolean sigOk = registeredIssuerJwks.isEmpty()
@@ -636,7 +658,7 @@ public abstract class AbstractJWTFilter implements Filter {
try {
if (tokenId != null) {
if (tokenIsStillValid(tokenId)) {
- final TokenMetadata tokenMetadata = tokenStateService == null ? null : tokenStateService.getTokenMetadata(tokenId);
+ final TokenMetadata tokenMetadata = getTokenMetadata(tokenId);
if (isTokenEnabled(tokenMetadata)) {
if (isIdleTimeoutLimitNotExceeded(tokenMetadata)) {
if (hasSignatureBeenVerified(passcodeVerificationCacheKey(tokenId, passcode)) || validatePasscode(tokenId, passcode)) {
@@ -681,7 +703,7 @@ public abstract class AbstractJWTFilter implements Filter {
private boolean validatePasscode(String tokenId, String passcode) throws UnknownTokenException {
final long issueTime = tokenStateService.getTokenIssueTime(tokenId);
- final TokenMetadata tokenMetadata = tokenStateService.getTokenMetadata(tokenId);
+ final TokenMetadata tokenMetadata = getTokenMetadata(tokenId);
final String userName = tokenMetadata == null ? "" : tokenMetadata.getUserName();
final byte[] storedPasscode = tokenMetadata == null ? null : tokenMetadata.getPasscode().getBytes(UTF_8);
final boolean validPasscode = Arrays.equals(tokenMAC.hash(tokenId, issueTime, userName, passcode).getBytes(UTF_8), storedPasscode);
diff --git a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/CommonJWTFilterTest.java b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/CommonJWTFilterTest.java
index 9442a5517..d0ad7db58 100644
--- a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/CommonJWTFilterTest.java
+++ b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/CommonJWTFilterTest.java
@@ -24,6 +24,7 @@ import org.apache.knox.gateway.services.security.token.TokenStateService;
import org.apache.knox.gateway.services.security.token.TokenUtils;
import org.apache.knox.gateway.services.security.token.UnknownTokenException;
import org.apache.knox.gateway.services.security.token.impl.JWT;
+import org.apache.knox.gateway.services.security.token.impl.JWTToken;
import org.easymock.EasyMock;
import org.junit.After;
import org.junit.Assert;
@@ -205,6 +206,7 @@ public class CommonJWTFilterTest {
EasyMock.replay(sc);
JWT jwt = EasyMock.createNiceMock(JWT.class);
+ EasyMock.expect(jwt.getClaim(JWTToken.KNOX_ID_CLAIM)).andReturn(UUID.randomUUID().toString()).anyTimes();
EasyMock.replay(jwt);
Field tokenStateServiceField = AbstractJWTFilter.class.getDeclaredField("tokenStateService");
diff --git a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/JWTFederationFilterTokenExchangeTest.java b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/JWTFederationFilterTokenExchangeTest.java
index 8144a248f..ed9b2051e 100644
--- a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/JWTFederationFilterTokenExchangeTest.java
+++ b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/JWTFederationFilterTokenExchangeTest.java
@@ -27,6 +27,7 @@ import org.apache.knox.gateway.services.GatewayServices;
import org.apache.knox.gateway.services.ServiceType;
import org.apache.knox.gateway.services.knoxidf.trustedoidcissuer.TrustedOidcIssuerService;
import org.apache.knox.gateway.services.security.token.JWTokenAuthority;
+import org.apache.knox.gateway.services.security.token.TokenStateService;
import org.apache.knox.gateway.services.security.token.impl.JWT;
import org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants;
import org.easymock.Capture;
@@ -213,6 +214,68 @@ public class JWTFederationFilterTokenExchangeTest extends AbstractJWTFilterTest
EasyMock.verify(mockAuth, issuerSvc);
}
+ /**
+ * Regression test for the token-exchange failure on real Kubernetes ServiceAccount tokens.
+ *
+ * <p>When server-managed token state is enabled on the token topology (knoxidf-token.xml sets
+ * {@code knox.token.exp.server-managed=true}), the filter holds a non-null
+ * {@code TokenStateService}. An externally-issued subject token from a registered dynamic-JWKS
+ * issuer (a projected SA token) carries no {@code knox.id} claim, so {@code TokenUtils.getTokenId}
+ * returns {@code null}. Server-managed state is keyed by that id, so the filter must not consult
+ * the state service for such a token — neither for the expiration nor the metadata lookup.
+ * Previously it did: {@code getTokenExpiration(null)} raised {@code IllegalArgumentException}
+ * (surfacing as HTTP 500) and, once that was guarded, {@code getTokenMetadata(null)} raised
+ * {@code UnknownTokenException} (a spurious HTTP 401). The token must instead validate on its own
+ * claims and the chain must proceed.
+ *
+ * <p>The strict {@code TokenStateService} mock has no expectations, so any lookup with the null id
+ * is an unexpected invocation and fails the test. This is the coverage the class Javadoc notes was
+ * previously missing ("without TSS they return true trivially"): earlier tests never injected a
+ * {@code TokenStateService}, leaving this path unguarded.
+ */
+ @Test
+ public void testExternalSubjectTokenWithServerManagedStateSkipsStateLookup() throws Exception {
+ handler.init(new TestFilterConfig(getProperties()));
+
+ // knoxId=null -> no knox.id claim, exactly like a real k8s SA projected token, so
+ // TokenUtils.getTokenId(token) returns null.
+ final SignedJWT subjectJwt = getJWT(EXTERNAL_ISSUER, "k8s-sa", "bar",
+ new Date(System.currentTimeMillis() + 60000), new Date(), privateKey,
+ AbstractJWTFilter.JWT_DEFAULT_SIGALG, null);
+
+ final JWTokenAuthority mockAuth = EasyMock.createMock(JWTokenAuthority.class);
+ EasyMock.expect(mockAuth.verifyToken(
+ EasyMock.anyObject(JWT.class),
+ EasyMock.eq(Set.of(new URI(DYNAMIC_JWKS_URI))),
+ EasyMock.eq(AbstractJWTFilter.JWT_DEFAULT_SIGALG),
+ EasyMock.isA(JOSEObjectTypeVerifier.class)))
+ .andReturn(true).once();
+ EasyMock.replay(mockAuth);
+ ((TestJWTFederationFilter) handler).setTokenService(mockAuth);
+
+ // Simulate server-managed token state being enabled (as on knoxidf-token). Strict mock, no
+ // expectations: a lookup with the external token's null id would be an unexpected call.
+ final TokenStateService strictTss = EasyMock.createMock(TokenStateService.class);
+ EasyMock.replay(strictTss);
+ ((TestJWTFederationFilter) handler).setTokenStateService(strictTss);
+
+ final TrustedOidcIssuerService issuerSvc = EasyMock.createMock(TrustedOidcIssuerService.class);
+ EasyMock.expect(issuerSvc.isDynamicJwks(EXTERNAL_ISSUER)).andReturn(true).once();
+ EasyMock.expect(issuerSvc.resolveJwksUri(EXTERNAL_ISSUER)).andReturn(Optional.of(DYNAMIC_JWKS_URI)).once();
+
+ final HttpServletRequest request = buildTokenExchangeRequest(
+ subjectJwt.serialize(), buildContextWithIssuerService(issuerSvc));
+ final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class);
+ EasyMock.replay(request, response, issuerSvc);
+
+ final TestFilterChain chain = new TestFilterChain();
+ handler.doFilter(request, response, chain);
+
+ Assert.assertTrue("Filter chain must proceed for an external subject token even with "
+ + "server-managed token state enabled", chain.doFilterCalled);
+ EasyMock.verify(mockAuth, issuerSvc, strictTss);
+ }
+
// ---------------------------------------------------------------------------
// Dynamic registry path — signature, expiry, nbf, audience failures
// ---------------------------------------------------------------------------