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
   // ---------------------------------------------------------------------------