This is an automated email from the ASF dual-hosted git repository.

smolnar82 pushed a commit to branch knox_idf
in repository https://gitbox.apache.org/repos/asf/knox.git


The following commit(s) were added to refs/heads/knox_idf by this push:
     new 705d27e9f KNOX-3405 - Extend JWTFederationFilter for dynamic JWKS and 
iss attribute on token-exchange (#1342)
705d27e9f is described below

commit 705d27e9f7837ca90137a232575daa1f41e63329
Author: hsheinblatt <[email protected]>
AuthorDate: Wed Aug 12 02:09:52 2026 -0700

    KNOX-3405 - Extend JWTFederationFilter for dynamic JWKS and iss attribute 
on token-exchange (#1342)
---
 .../federation/jwt/filter/AbstractJWTFilter.java   | 153 +++--
 .../federation/jwt/filter/JWTFederationFilter.java |  50 +-
 .../jwt/filter/TokenExchangeHandler.java           |  18 +-
 .../JWTFederationFilterTokenExchangeTest.java      | 737 +++++++++++++++++++++
 ...WTFederationFilterTokenExchangeRoutingTest.java |   2 +-
 .../jwt/filter/TokenExchangeHandlerTest.java       |  12 +-
 .../gateway/util/knoxidf/KnoxIDFConstants.java     |   1 +
 7 files changed, 908 insertions(+), 65 deletions(-)

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 de4987caa..75a4e22ee 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
@@ -469,67 +469,132 @@ public abstract class AbstractJWTFilter implements 
Filter {
     final String tokenId = TokenUtils.getTokenId(token);
     final String displayableTokenId = Tokens.getTokenIDDisplayText(tokenId);
     final String displayableToken = 
Tokens.getTokenDisplayText(token.toString());
-    // confirm that issuer matches the intended target
     if (expectedIssuers.contains(token.getIssuer())) {
-      // if there is no expiration data then the lifecycle is tied entirely to
-      // the cookie validity - otherwise ensure that the current time is before
-      // the designated expiration time
-      try {
-        if (tokenIsStillValid(token)) {
-          boolean audValid = validateAudiences(token);
-          if (audValid) {
-            Date nbf = token.getNotBeforeDate();
-            if (nbf == null || new Date().after(nbf)) {
-              final TokenMetadata tokenMetadata = tokenStateService == null ? 
null : tokenStateService.getTokenMetadata(tokenId);
-              if (isTokenEnabled(tokenMetadata)) {
-                if (isIdleTimeoutLimitNotExceeded(tokenMetadata)) {
-                  if (verifyTokenSignature(token)) {
-                    markLastUsedAt(tokenId, tokenMetadata);
-                    return true;
-                  } else {
-                    log.failedToVerifyTokenSignature(displayableToken, 
displayableTokenId);
-                    handleValidationError(request, response, 
HttpServletResponse.SC_UNAUTHORIZED, null);
-                  }
+      // Issuer in the static trusted list: full validation using the 
provider-configured
+      // PEM/JWKS/instance-key chain. An empty set signals "use 
verifyTokenSignature()".
+      return doFullTokenValidation(request, response, token, tokenId,
+          displayableToken, displayableTokenId, Set.of());
+    }
+    // For issuers not in the static list, subclasses may resolve JWKS for a 
runtime-registered issuer.
+    // An empty result means "not applicable for this request" and the token 
is rejected.
+    // All other validation checks (expiry, audiences, nbf, token state) run 
identically to the static path.
+    final Set<URI> registeredIssuerJwks = 
resolveRegisteredIssuerJwks(token.getIssuer(), request);
+    if (!registeredIssuerJwks.isEmpty()) {
+      return doFullTokenValidation(request, response, token, tokenId,
+          displayableToken, displayableTokenId, registeredIssuerJwks);
+    }
+    handleValidationError(request, response, 
HttpServletResponse.SC_UNAUTHORIZED, null);
+    return false;
+  }
+
+  /**
+   * Extension point for subclasses to resolve JWKS for an issuer that is 
registered at runtime
+   * (e.g., in {@code TrustedOidcIssuerService}) but is not in the static
+   * {@code jwt.expected.issuer} topology parameter.
+   *
+   * <p>Return semantics:
+   * <ul>
+   *   <li>Non-empty set — caller runs full token validation using only these 
JWKS for signature
+   *       verification; the provider-configured PEM/JWKS/instance-key chain 
is not consulted.</li>
+   *   <li>Empty set — not applicable for this request; caller rejects with 
401.</li>
+   * </ul>
+   *
+   * <p>The default implementation always returns an empty set. Subclasses 
that support a runtime
+   * issuer registry should override this method, applying any request-context 
checks themselves,
+   * and return a non-empty set only when the issuer is found in the registry 
and
+   * its JWKS URI has been successfully resolved.
+   */
+  protected Set<URI> resolveRegisteredIssuerJwks(String issuer, 
HttpServletRequest request) {
+    return Set.of();
+  }
+
+  /**
+   * Runs the full token validation sequence (expiry, audiences, nbf, token 
state, signature)
+   * used by both the static-issuer path and the registered-issuer path.
+   *
+   * @param registeredIssuerJwks if non-empty, the signature is verified 
exclusively against these
+   *     JWKS URIs (resolved for the issuer from the runtime registry); if 
empty,
+   *     {@link #verifyTokenSignature(JWT)} is used instead 
(provider-configured PEM / JWKS /
+   *     instance-key chain).
+   */
+  private boolean doFullTokenValidation(final HttpServletRequest request, 
final HttpServletResponse response,
+      final JWT token, final String tokenId, final String displayableToken,
+      final String displayableTokenId, final Set<URI> registeredIssuerJwks)
+      throws IOException, ServletException {
+    try {
+      if (tokenIsStillValid(token)) {
+        if (validateAudiences(token)) {
+          Date nbf = token.getNotBeforeDate();
+          if (nbf == null || new Date().after(nbf)) {
+            final TokenMetadata tokenMetadata = tokenStateService == null ? 
null : tokenStateService.getTokenMetadata(tokenId);
+            if (isTokenEnabled(tokenMetadata)) {
+              if (isIdleTimeoutLimitNotExceeded(tokenMetadata)) {
+                final boolean sigOk = registeredIssuerJwks.isEmpty()
+                    ? verifyTokenSignature(token)
+                    : verifyTokenSignatureWithJwks(token, 
registeredIssuerJwks);
+                if (sigOk) {
+                  markLastUsedAt(tokenId, tokenMetadata);
+                  return true;
                 } else {
-                  log.idleTimoutExceeded(token.getSubject(), 
displayableTokenId, idleTimeoutSeconds);
-                  handleValidationError(request, response, 
HttpServletResponse.SC_UNAUTHORIZED, TOKEN_PREFIX + displayableTokenId + 
IDLE_TIMEOUT_POSTFIX);
+                  log.failedToVerifyTokenSignature(displayableToken, 
displayableTokenId);
+                  handleValidationError(request, response, 
HttpServletResponse.SC_UNAUTHORIZED, null);
                 }
               } else {
-                log.disabledToken(displayableTokenId);
-                handleValidationError(request, response, 
HttpServletResponse.SC_UNAUTHORIZED, TOKEN_PREFIX + displayableTokenId + 
DISABLED_POSTFIX);
+                log.idleTimoutExceeded(token.getSubject(), displayableTokenId, 
idleTimeoutSeconds);
+                handleValidationError(request, response, 
HttpServletResponse.SC_UNAUTHORIZED,
+                    TOKEN_PREFIX + displayableTokenId + IDLE_TIMEOUT_POSTFIX);
               }
             } else {
-              log.notBeforeCheckFailed();
-              handleValidationError(request, response, 
HttpServletResponse.SC_BAD_REQUEST,
-                      "Bad request: the NotBefore check failed");
+              log.disabledToken(displayableTokenId);
+              handleValidationError(request, response, 
HttpServletResponse.SC_UNAUTHORIZED,
+                  TOKEN_PREFIX + displayableTokenId + DISABLED_POSTFIX);
             }
           } else {
-            log.failedToValidateAudience(displayableToken, displayableTokenId);
+            log.notBeforeCheckFailed();
             handleValidationError(request, response, 
HttpServletResponse.SC_BAD_REQUEST,
-                    "Bad request: missing required token audience");
+                "Bad request: the NotBefore check failed");
           }
         } else {
-          log.tokenHasExpired(displayableToken, displayableTokenId);
-
-          // Explicitly evict the record of this token's signature 
verification (if present).
-          // There is no value in keeping this record for expired tokens, and 
explicitly removing them may prevent
-          // records for other valid tokens from being prematurely evicted 
from the cache.
-          removeSignatureVerificationRecord(token.toString());
-
-          handleValidationError(request, response, 
HttpServletResponse.SC_UNAUTHORIZED, "Token has expired");
-
+          log.failedToValidateAudience(displayableToken, displayableTokenId);
+          handleValidationError(request, response, 
HttpServletResponse.SC_BAD_REQUEST,
+              "Bad request: missing required token audience");
         }
-      } catch (UnknownTokenException e) {
-        log.unableToVerifyExpiration(e);
-        handleValidationError(request, response, 
HttpServletResponse.SC_UNAUTHORIZED, e.getMessage());
+      } else {
+        log.tokenHasExpired(displayableToken, displayableTokenId);
+        // Explicitly evict the record of this token's signature verification 
(if present).
+        // There is no value in keeping this record for expired tokens, and 
explicitly removing them
+        // may prevent records for other valid tokens from being prematurely 
evicted from the cache.
+        removeSignatureVerificationRecord(token.toString());
+        handleValidationError(request, response, 
HttpServletResponse.SC_UNAUTHORIZED, "Token has expired");
       }
-    } else {
-      handleValidationError(request, response, 
HttpServletResponse.SC_UNAUTHORIZED, null);
+    } catch (UnknownTokenException e) {
+      log.unableToVerifyExpiration(e);
+      handleValidationError(request, response, 
HttpServletResponse.SC_UNAUTHORIZED, e.getMessage());
     }
-
     return false;
   }
 
+  /**
+   * Verifies the token's signature against the given JWKS URIs.
+   * Uses the filter's configured signature algorithm and JWS type verifier.
+   */
+  private boolean verifyTokenSignatureWithJwks(final JWT token, final Set<URI> 
jwksUrls) {
+    final String serializedJWT = token.toString();
+    if (hasSignatureBeenVerified(serializedJWT)) {
+      return true;
+    }
+    try {
+      final boolean verified = authority.verifyToken(token, jwksUrls, 
expectedSigAlg, typeVerifier);
+      if (verified) {
+        recordSignatureVerification(serializedJWT);
+      }
+      return verified;
+    } catch (TokenServiceException e) {
+      log.unableToVerifyToken(e);
+      return false;
+    }
+  }
+
   private boolean isTokenEnabled(TokenMetadata tokenMetadata) throws 
UnknownTokenException {
     return tokenMetadata == null ? true : tokenMetadata.isEnabled();
   }
diff --git 
a/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/JWTFederationFilter.java
 
b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/JWTFederationFilter.java
index 59cb219e7..5109a2c09 100644
--- 
a/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/JWTFederationFilter.java
+++ 
b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/JWTFederationFilter.java
@@ -22,6 +22,9 @@ import org.apache.commons.lang3.tuple.Pair;
 import org.apache.knox.gateway.i18n.messages.MessagesFactory;
 import org.apache.knox.gateway.provider.federation.jwt.JWTMessages;
 import org.apache.knox.gateway.security.PrimaryPrincipal;
+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.TokenUtils;
 import org.apache.knox.gateway.services.security.token.UnknownTokenException;
 import org.apache.knox.gateway.services.security.token.impl.JWT;
@@ -42,11 +45,14 @@ import javax.servlet.http.Cookie;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
 import java.text.ParseException;
 import java.util.Base64;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Locale;
+import java.util.Optional;
 import java.util.Set;
 
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -58,7 +64,6 @@ import static 
org.apache.knox.gateway.security.CommonTokenConstants.GRANT_TYPE;
 import static 
org.apache.knox.gateway.util.AuthFilterUtils.DEFAULT_AUTH_UNAUTHENTICATED_PATHS_PARAM;
 
 public class JWTFederationFilter extends AbstractJWTFilter {
-
   private static final JWTMessages LOGGER = MessagesFactory.get( 
JWTMessages.class );
   /* A semicolon separated list of paths that need to bypass authentication */
   public static final String JWT_UNAUTHENTICATED_PATHS_PARAM = 
"jwt.unauthenticated.path.list";
@@ -69,6 +74,16 @@ public class JWTFederationFilter extends AbstractJWTFilter {
   public static final String CLIENT_ASSERTION_JWT_BEARER = 
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
   public static final String CLIENT_ASSERTION_TYPE = "client_assertion_type";
   public static final String CLIENT_ASSERTION = "client_assertion";
+  // RFC 8693 constants
+  public static final String TOKEN_EXCHANGE = 
"urn:ietf:params:oauth:grant-type:token-exchange";
+  public static final String SUBJECT_TOKEN = "subject_token";
+  public static final String ACTOR_TOKEN = "actor_token";
+  public static final String SUBJECT_TOKEN_TYPE = "subject_token_type";
+  public static final String ACTOR_TOKEN_TYPE = "actor_token_type";
+  // RFC 8693 section 3 token type identifiers. Only JWT-family types are 
supported for exchange;
+  // Knox issues JWT access tokens, so the access_token URN is accepted as an 
alias for jwt.
+  public static final String TOKEN_TYPE_JWT = 
"urn:ietf:params:oauth:token-type:jwt";
+  public static final String TOKEN_TYPE_ACCESS_TOKEN = 
"urn:ietf:params:oauth:token-type:access_token";
 
   public enum TokenType {
     JWT, Passcode, TokenExchange;
@@ -245,6 +260,10 @@ public class JWTFederationFilter extends AbstractJWTFilter 
{
     if (scope != null) {
       request.setAttribute(KnoxIDFConstants.SCOPE_ATTRIBUTE, 
token.getClaim(scope));
     }
+    final String issuer = token.getIssuer();
+    if (issuer != null) {
+      request.setAttribute(KnoxIDFConstants.TOKEN_ISS_ATTRIBUTE, issuer);
+    }
   }
 
   private void validateClientID(HttpServletRequest request, String tokenValue) 
{
@@ -358,7 +377,7 @@ public class JWTFederationFilter extends AbstractJWTFilter {
         } else if (REFRESH_TOKEN.equals(grantType)) {
           // refresh_token flow: the refresh_token parameter contains the 
actual token
           return getClientTokenFromParams(unwrappedRequest, 
REFRESH_TOKEN_PARAM);
-        } else if (TokenExchangeHandler.TOKEN_EXCHANGE.equals(grantType)) {
+        } else if (TOKEN_EXCHANGE.equals(grantType)) {
           // RFC 8693 token exchange: signal it via the token type. doFilter 
routes this to
           // TokenExchangeHandler, which reads subject_token/actor_token from 
the unwrapped request.
           return Pair.of(TokenType.TokenExchange, null);
@@ -453,6 +472,33 @@ public class JWTFederationFilter extends AbstractJWTFilter 
{
     return null;
   }
 
+  @Override
+  protected Set<URI> resolveRegisteredIssuerJwks(String issuer, 
HttpServletRequest request) {
+    if (!TOKEN_EXCHANGE.equals(request.getParameter(GRANT_TYPE))) {
+      return Set.of();
+    }
+    final GatewayServices gws = (GatewayServices)
+        
request.getServletContext().getAttribute(GatewayServices.GATEWAY_SERVICES_ATTRIBUTE);
+    if (gws != null) {
+      final TrustedOidcIssuerService issuerSvc = 
gws.getService(ServiceType.TRUSTED_OIDC_ISSUER_SERVICE);
+      // isDynamicJwks() is the combined guard: true only if the issuer is 
both registered as
+      // trusted AND configured for dynamic JWKS discovery. If the issuer is 
not registered, or
+      // registered without dynamic JWKS, it is not actionable through this 
path.
+      if (issuerSvc != null && issuerSvc.isDynamicJwks(issuer)) {
+        // resolveJwksUri() performs OIDC discovery
+        final Optional<String> jwksUri = issuerSvc.resolveJwksUri(issuer);
+        if (jwksUri.isPresent()) {
+          try {
+            return Set.of(new URI(jwksUri.get()));
+          } catch (URISyntaxException e) {
+            LOGGER.unableToVerifyToken(e);
+          }
+        }
+      }
+    }
+    return Set.of();
+  }
+
   @Override
   protected void handleValidationError(HttpServletRequest request, 
HttpServletResponse response, int status,
                                        String error) throws IOException {
diff --git 
a/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandler.java
 
b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandler.java
index 99bd3de5f..9fe7558f0 100644
--- 
a/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandler.java
+++ 
b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandler.java
@@ -39,6 +39,10 @@ import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
+import static 
org.apache.knox.gateway.provider.federation.jwt.filter.JWTFederationFilter.ACTOR_TOKEN_TYPE;
+import static 
org.apache.knox.gateway.provider.federation.jwt.filter.JWTFederationFilter.SUBJECT_TOKEN_TYPE;
+import static 
org.apache.knox.gateway.provider.federation.jwt.filter.JWTFederationFilter.TOKEN_TYPE_ACCESS_TOKEN;
+import static 
org.apache.knox.gateway.provider.federation.jwt.filter.JWTFederationFilter.TOKEN_TYPE_JWT;
 /**
  * Handles RFC 8693 (OAuth 2.0 Token Exchange) requests on behalf of {@link 
JWTFederationFilter}.
  *
@@ -56,16 +60,6 @@ import java.util.Set;
  */
 class TokenExchangeHandler {
 
-  public static final String TOKEN_EXCHANGE = 
"urn:ietf:params:oauth:grant-type:token-exchange";
-  public static final String SUBJECT_TOKEN = "subject_token";
-  public static final String SUBJECT_TOKEN_TYPE = "subject_token_type";
-  public static final String ACTOR_TOKEN = "actor_token";
-  public static final String ACTOR_TOKEN_TYPE = "actor_token_type";
-  // RFC 8693 section 3 token type identifiers. Only JWT-family types are 
supported for exchange;
-  // Knox issues JWT access tokens, so the access_token URN is accepted as an 
alias for jwt.
-  public static final String TOKEN_TYPE_JWT = 
"urn:ietf:params:oauth:token-type:jwt";
-  public static final String TOKEN_TYPE_ACCESS_TOKEN = 
"urn:ietf:params:oauth:token-type:access_token";
-
   private final JWTFederationFilter filter;
 
   TokenExchangeHandler(JWTFederationFilter filter) {
@@ -88,9 +82,9 @@ class TokenExchangeHandler {
     // unchanged.
     final HttpServletRequest bodyRequest = 
ServletRequestUtils.unwrapHttpServletRequest(request);
 
-    final String subjectTokenValue = bodyRequest.getParameter(SUBJECT_TOKEN);
+    final String subjectTokenValue = 
bodyRequest.getParameter(JWTFederationFilter.SUBJECT_TOKEN);
     final String subjectTokenType = 
bodyRequest.getParameter(SUBJECT_TOKEN_TYPE);
-    final String actorTokenValue = bodyRequest.getParameter(ACTOR_TOKEN);
+    final String actorTokenValue = 
bodyRequest.getParameter(JWTFederationFilter.ACTOR_TOKEN);
     final String actorTokenType = bodyRequest.getParameter(ACTOR_TOKEN_TYPE);
     final boolean hasActorToken = actorTokenValue != null && 
!actorTokenValue.isEmpty();
     final boolean hasActorTokenType = actorTokenType != null && 
!actorTokenType.isEmpty();
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
new file mode 100644
index 000000000..6239096a3
--- /dev/null
+++ 
b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/JWTFederationFilterTokenExchangeTest.java
@@ -0,0 +1,737 @@
+/*
+ * 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
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * 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.
+ */
+package org.apache.knox.gateway.provider.federation;
+
+import com.nimbusds.jose.proc.JOSEObjectTypeVerifier;
+import com.nimbusds.jwt.SignedJWT;
+import 
org.apache.knox.gateway.provider.federation.jwt.filter.AbstractJWTFilter;
+import 
org.apache.knox.gateway.provider.federation.jwt.filter.JWTFederationFilter;
+
+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.impl.JWT;
+import org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants;
+import org.easymock.Capture;
+import org.easymock.EasyMock;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+import javax.servlet.ServletContext;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletRequestWrapper;
+import javax.servlet.http.HttpServletResponse;
+import java.net.URI;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Properties;
+import java.util.Set;
+
+import static org.apache.knox.gateway.security.CommonTokenConstants.GRANT_TYPE;
+
+/**
+ * Tests for two JWTFederationFilter extensions added for Knox IDF delegation.
+ *
+ * <p><b>Change 1 — TOKEN_ISS_ATTRIBUTE</b> ({@link 
#testIssAttributeSetAfterValidation}):
+ * After successful Bearer JWT validation the token's {@code iss} claim is 
stored as a request
+ * attribute for use by admin endpoint handlers (per-cluster scope limiting).
+ *
+ * <p><b>Change 2 — Dynamic JWKS for token-exchange</b>: If a token's issuer 
is absent from
+ * the static {@code jwt.expected.issuer} list, the filter consults
+ * {@code TrustedOidcIssuerService} via {@code resolveRegisteredIssuerJwks}.
+ * If the issuer is registered with {@code isDynamicJwks=true}, the 
dynamically resolved JWKS
+ * URI is used exclusively for signature verification. All other validation 
(expiry, audiences,
+ * nbf, token state) runs via the same {@code doFullTokenValidation} helper as 
the static path.
+ *
+ * <p> NOTE: Tests are simplified to single-token form (subject_token only) 
wherever
+ * actor_token was not the subject of the test. Only two tests retain both 
tokens:
+ * {@link #testDynamicIssuerAllowedActorExternal}, which specifically tests 
the actor_token
+ * dynamic JWKS path, and {@link 
#testDynamicPathUsesRegistryJwksNotStaticJwks}, which
+ * verifies that both tokens are validated against the correct JWKS source 
independently.
+ *
+ * <p>NOTE: We do not test the specific failure modes {@code isTokenEnabled} or
+ * {@code isIdleTimeoutLimitNotExceeded} in the dynamic JWKS path. It would 
require more complex
+ * {@code TokenStateService} setup; without TSS they return true
+ * trivially for both paths, same as the static-issuer path covered by the 
Knox TSS suite.
+ *
+ * <p><b>Filter configuration:</b> the default {@link TestFilterConfig} sets
+ * {@code jwt.expected.issuer} to {@value 
AbstractJWTFilter#JWT_DEFAULT_ISSUER} only. No static
+ * JWKS URLs are configured unless a test explicitly sets {@link 
JWTFederationFilter#JWKS_URL}.
+ */
+public class JWTFederationFilterTokenExchangeTest extends 
AbstractJWTFilterTest {
+
+  static final String EXTERNAL_ISSUER = "https://external.oidc.example.com";;
+  static final String KNOX_ISSUER = AbstractJWTFilter.JWT_DEFAULT_ISSUER;
+  static final String DYNAMIC_JWKS_URI = 
"https://external.oidc.example.com/.well-known/jwks.json";;
+
+  @Before
+  public void setUp() {
+    handler = new TestJWTFederationFilter();
+    ((TestJWTFederationFilter) handler).setTokenService(new 
TestJWTokenAuthority(publicKey));
+  }
+
+  @Override
+  protected String getAudienceProperty() {
+    return JWTFederationFilter.KNOX_TOKEN_AUDIENCES;
+  }
+
+  @Override
+  protected String getVerificationPemProperty() {
+    return JWTFederationFilter.TOKEN_VERIFICATION_PEM;
+  }
+
+  @Override
+  protected void setTokenOnRequest(HttpServletRequest request, SignedJWT jwt) {
+    EasyMock.expect(request.getHeader("Authorization"))
+        .andReturn(JWTFederationFilter.BEARER + " " + jwt.serialize());
+  }
+
+  @Override
+  protected void setGarbledTokenOnRequest(HttpServletRequest request, 
SignedJWT jwt) {
+    EasyMock.expect(request.getHeader("Authorization"))
+        .andReturn(JWTFederationFilter.BEARER + " ljm" + jwt.serialize());
+  }
+
+  // 
---------------------------------------------------------------------------
+  // Dynamic registry path — success
+  // 
---------------------------------------------------------------------------
+
+  /**
+   * Subject token from EXTERNAL_ISSUER (not in static list); no actor token. 
The authority mock
+   * verifies the dynamic path calls verifyToken with the resolved JWKS URI, 
configured sig-alg,
+   * and type-verifier.
+   */
+  @Test
+  public void testDynamicIssuerAllowedSubjectExternal() throws Exception {
+    handler.init(new TestFilterConfig(getProperties()));
+
+    final SignedJWT subjectJwt = getJWT(EXTERNAL_ISSUER, "k8s-sa",
+        new Date(System.currentTimeMillis() + 60000));
+
+    final Capture<JWT> capturedDynamicJwt = EasyMock.newCapture();
+
+    final JWTokenAuthority mockAuth = 
EasyMock.createMock(JWTokenAuthority.class);
+    EasyMock.expect(mockAuth.verifyToken(
+        EasyMock.capture(capturedDynamicJwt),
+        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);
+
+    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 should proceed", chain.doFilterCalled);
+    Assert.assertEquals(EXTERNAL_ISSUER, 
capturedDynamicJwt.getValue().getIssuer());
+    EasyMock.verify(mockAuth, issuerSvc);
+  }
+
+  /**
+   * Actor token from EXTERNAL_ISSUER (dynamic path); subject token from 
KNOX_ISSUER (static
+   * path). This is the primary K8s SA delegation scenario: the acting service 
carries a
+   * projected SA token with a dynamically registered issuer; the subject 
carries a Knox-issued
+   * token. The authority mock verifies the same argument contract as the 
previous test.
+   */
+  @Test
+  public void testDynamicIssuerAllowedActorExternal() throws Exception {
+    handler.init(new TestFilterConfig(getProperties()));
+
+    final SignedJWT subjectJwt = getJWT(KNOX_ISSUER, "end-user",
+        new Date(System.currentTimeMillis() + 60000));
+    final SignedJWT actorJwt = getJWT(EXTERNAL_ISSUER, "k8s-sa",
+        new Date(System.currentTimeMillis() + 60000));
+
+    final Capture<JWT> capturedDynamicJwt = EasyMock.newCapture();
+    final Capture<JWT> capturedStaticJwt = EasyMock.newCapture();
+
+    final JWTokenAuthority mockAuth = 
EasyMock.createMock(JWTokenAuthority.class);
+    
EasyMock.expect(mockAuth.verifyToken(EasyMock.capture(capturedStaticJwt))).andReturn(true).once();
+    EasyMock.expect(mockAuth.verifyToken(
+        EasyMock.capture(capturedDynamicJwt),
+        EasyMock.eq(Set.of(new URI(DYNAMIC_JWKS_URI))),
+        EasyMock.eq(AbstractJWTFilter.JWT_DEFAULT_SIGALG), // configured 
sig-alg
+        EasyMock.isA(JOSEObjectTypeVerifier.class)))       // 
filter-configured type verifier
+        .andReturn(true).once();
+    EasyMock.replay(mockAuth);
+    ((TestJWTFederationFilter) handler).setTokenService(mockAuth);
+
+    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(), actorJwt.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 should proceed", chain.doFilterCalled);
+    Assert.assertEquals(EXTERNAL_ISSUER, 
capturedDynamicJwt.getValue().getIssuer());
+    Assert.assertEquals(KNOX_ISSUER, capturedStaticJwt.getValue().getIssuer());
+    EasyMock.verify(mockAuth, issuerSvc);
+  }
+
+  // 
---------------------------------------------------------------------------
+  // Dynamic registry path — signature, expiry, nbf, audience failures
+  // 
---------------------------------------------------------------------------
+
+  /**
+   * Dynamic JWKS resolved; authority.verifyToken returns false for that URI. 
The authority
+   * mock verifies the exact JWKS URI, configured sig-alg ("RS256"), and 
JOSEObjectTypeVerifier
+   * type were passed to authority.verifyToken. Any other authority call 
(static JWKS, instance
+   * key) would fail the strict mock.
+   */
+  @Test
+  public void testSignatureVerificationFails() throws Exception {
+    handler.init(new TestFilterConfig(getProperties()));
+
+    final SignedJWT subjectJwt = getJWT(EXTERNAL_ISSUER, "some-subject",
+        new Date(System.currentTimeMillis() + 60000));
+
+    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(false).once();
+    EasyMock.replay(mockAuth);
+    ((TestJWTFederationFilter) handler).setTokenService(mockAuth);
+
+    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);
+    response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
+    EasyMock.expectLastCall().once();
+    EasyMock.replay(request, response, issuerSvc);
+
+    final TestFilterChain chain = new TestFilterChain();
+    handler.doFilter(request, response, chain);
+
+    Assert.assertFalse(chain.doFilterCalled);
+    EasyMock.verify(mockAuth, issuerSvc, response);
+  }
+
+  /**
+   * Dynamic JWKS resolved, but the token is expired. The strict mock with 
{@code .times(0, 1)}
+   * allows JWKS signature verification to happen 0 or 1 times (validation 
order is not
+   * guaranteed), so the "Token has expired" rejection is the guaranteed 
outcome. If the JWKS
+   * call occurs, the captured JWT must have EXTERNAL_ISSUER. {@code 
verify(issuerSvc)} confirms
+   * the dynamic path was entered before the expiry check.
+   */
+  @Test
+  public void testExpiredTokenRejectedOnDynamicPath() throws Exception {
+    handler.init(new TestFilterConfig(getProperties()));
+
+    final SignedJWT expiredJwt = getJWT(EXTERNAL_ISSUER, "k8s-sa",
+        new Date(System.currentTimeMillis() - 60000));
+
+    final Capture<JWT> capturedJwt = EasyMock.newCapture();
+    final JWTokenAuthority mockAuth = 
EasyMock.createMock(JWTokenAuthority.class);
+    EasyMock.expect(mockAuth.verifyToken(
+        EasyMock.capture(capturedJwt),
+        EasyMock.eq(Set.of(new URI(DYNAMIC_JWKS_URI))),
+        EasyMock.eq(AbstractJWTFilter.JWT_DEFAULT_SIGALG),
+        EasyMock.isA(JOSEObjectTypeVerifier.class)))
+        .andReturn(true).times(0, 1);
+    EasyMock.replay(mockAuth);
+    ((TestJWTFederationFilter) handler).setTokenService(mockAuth);
+
+    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(
+        expiredJwt.serialize(), buildContextWithIssuerService(issuerSvc));
+    final HttpServletResponse response = 
EasyMock.createNiceMock(HttpServletResponse.class);
+    response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Token has 
expired");
+    EasyMock.expectLastCall().once();
+    EasyMock.replay(request, response, issuerSvc);
+
+    final TestFilterChain chain = new TestFilterChain();
+    handler.doFilter(request, response, chain);
+
+    Assert.assertFalse(chain.doFilterCalled);
+    if (capturedJwt.hasCaptured()) {
+      Assert.assertEquals(EXTERNAL_ISSUER, capturedJwt.getValue().getIssuer());
+    }
+    EasyMock.verify(mockAuth, issuerSvc, response);
+  }
+
+  /**
+   * Dynamic JWKS resolved, but the token's NotBefore is in the future. The 
strict mock with
+   * {@code .times(0, 1)} allows JWKS signature verification to happen 0 or 1 
times (validation
+   * order is not guaranteed), so the "NotBefore check failed" rejection is 
the guaranteed
+   * outcome. If the JWKS call occurs, the captured JWT must have 
EXTERNAL_ISSUER.
+   */
+  @Test
+  public void testFutureNbfRejectedOnDynamicPath() throws Exception {
+    handler.init(new TestFilterConfig(getProperties()));
+
+    final Date futureNbf = new Date(System.currentTimeMillis() + 300000);
+    final Date futureExpiry = new Date(System.currentTimeMillis() + 600000);
+    final SignedJWT nbfJwt = getJWT(EXTERNAL_ISSUER, "k8s-sa", futureExpiry, 
futureNbf, privateKey, "RS256");
+
+    final Capture<JWT> capturedJwt = EasyMock.newCapture();
+    final JWTokenAuthority mockAuth = 
EasyMock.createMock(JWTokenAuthority.class);
+    EasyMock.expect(mockAuth.verifyToken(
+        EasyMock.capture(capturedJwt),
+        EasyMock.eq(Set.of(new URI(DYNAMIC_JWKS_URI))),
+        EasyMock.eq(AbstractJWTFilter.JWT_DEFAULT_SIGALG),
+        EasyMock.isA(JOSEObjectTypeVerifier.class)))
+        .andReturn(true).times(0, 1);
+    EasyMock.replay(mockAuth);
+    ((TestJWTFederationFilter) handler).setTokenService(mockAuth);
+
+    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(
+        nbfJwt.serialize(), buildContextWithIssuerService(issuerSvc));
+    final HttpServletResponse response = 
EasyMock.createNiceMock(HttpServletResponse.class);
+    response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Bad request: the 
NotBefore check failed");
+    EasyMock.expectLastCall().once();
+    EasyMock.replay(request, response, issuerSvc);
+
+    final TestFilterChain chain = new TestFilterChain();
+    handler.doFilter(request, response, chain);
+
+    Assert.assertFalse(chain.doFilterCalled);
+    if (capturedJwt.hasCaptured()) {
+      Assert.assertEquals(EXTERNAL_ISSUER, capturedJwt.getValue().getIssuer());
+    }
+    EasyMock.verify(mockAuth, issuerSvc, response);
+  }
+
+  /**
+   * Dynamic JWKS resolved, but the token's audience does not match the 
required audience. The
+   * strict mock with {@code .times(0, 1)} allows JWKS signature verification 
to happen 0 or 1
+   * times (validation order is not guaranteed), so the audience rejection is 
the guaranteed
+   * outcome. If the JWKS call occurs, the captured JWT must have 
EXTERNAL_ISSUER.
+   */
+  @Test
+  public void testAudienceMismatchRejectedOnDynamicPath() throws Exception {
+    final Properties props = getProperties();
+    props.setProperty(JWTFederationFilter.KNOX_TOKEN_AUDIENCES, 
"required-audience");
+    handler.init(new TestFilterConfig(props));
+
+    final SignedJWT subjectJwt = getJWT(EXTERNAL_ISSUER, "k8s-sa",
+        new Date(System.currentTimeMillis() + 60000)); // default aud="bar", 
not "required-audience"
+
+    final Capture<JWT> capturedJwt = EasyMock.newCapture();
+    final JWTokenAuthority mockAuth = 
EasyMock.createMock(JWTokenAuthority.class);
+    EasyMock.expect(mockAuth.verifyToken(
+        EasyMock.capture(capturedJwt),
+        EasyMock.eq(Set.of(new URI(DYNAMIC_JWKS_URI))),
+        EasyMock.eq(AbstractJWTFilter.JWT_DEFAULT_SIGALG),
+        EasyMock.isA(JOSEObjectTypeVerifier.class)))
+        .andReturn(true).times(0, 1);
+    EasyMock.replay(mockAuth);
+    ((TestJWTFederationFilter) handler).setTokenService(mockAuth);
+
+    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);
+    response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Bad request: 
missing required token audience");
+    EasyMock.expectLastCall().once();
+    EasyMock.replay(request, response, issuerSvc);
+
+    final TestFilterChain chain = new TestFilterChain();
+    handler.doFilter(request, response, chain);
+
+    Assert.assertFalse(chain.doFilterCalled);
+    if (capturedJwt.hasCaptured()) {
+      Assert.assertEquals(EXTERNAL_ISSUER, capturedJwt.getValue().getIssuer());
+    }
+    EasyMock.verify(mockAuth, issuerSvc, response);
+  }
+
+  // 
---------------------------------------------------------------------------
+  // Token rejected — issuer does not qualify for dynamic JWKS verification
+  // 
---------------------------------------------------------------------------
+
+  /**
+   * The issuer is not registered in the dynamic registry; isDynamicJwks 
returns false. The
+   * filter rejects with 401. resolveJwksUri is not expected on the strict 
mock — any call to
+   * it would fail verify(), proving no HTTP fetch was attempted (SSRF 
prevention).
+   */
+  @Test
+  public void testUntrustedIssuerRejectedNoHttpCall() throws Exception {
+    handler.init(new TestFilterConfig(getProperties()));
+
+    final SignedJWT subjectJwt = getJWT(EXTERNAL_ISSUER, "some-subject",
+        new Date(System.currentTimeMillis() + 60000));
+
+    final TrustedOidcIssuerService issuerSvc = 
EasyMock.createMock(TrustedOidcIssuerService.class);
+    
EasyMock.expect(issuerSvc.isDynamicJwks(EXTERNAL_ISSUER)).andReturn(false).once();
+    // resolveJwksUri not expected — any call fails verify(), proving no HTTP 
fetch attempted
+
+    final HttpServletRequest request = buildTokenExchangeRequest(
+        subjectJwt.serialize(), buildContextWithIssuerService(issuerSvc));
+    final HttpServletResponse response = 
EasyMock.createNiceMock(HttpServletResponse.class);
+    response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
+    EasyMock.expectLastCall().once();
+    EasyMock.replay(request, response, issuerSvc);
+
+    final TestFilterChain chain = new TestFilterChain();
+    handler.doFilter(request, response, chain);
+
+    Assert.assertFalse(chain.doFilterCalled);
+    EasyMock.verify(issuerSvc, response);
+  }
+
+  /**
+   * TrustedOidcIssuerService is null. The hook returns without calling any 
service method.
+   * EXTERNAL_ISSUER is not in expectedIssuers, so the filter rejects.
+   */
+  @Test
+  public void testServiceUnavailable() throws Exception {
+    handler.init(new TestFilterConfig(getProperties()));
+
+    final SignedJWT subjectJwt = getJWT(EXTERNAL_ISSUER, "some-subject",
+        new Date(System.currentTimeMillis() + 60000));
+
+    final GatewayServices gws = EasyMock.createNiceMock(GatewayServices.class);
+    
EasyMock.expect(gws.getService(ServiceType.TRUSTED_OIDC_ISSUER_SERVICE)).andReturn(null).anyTimes();
+    EasyMock.replay(gws);
+
+    final HttpServletRequest request = buildTokenExchangeRequest(
+        subjectJwt.serialize(), buildServletContext(gws));
+    final HttpServletResponse response = 
EasyMock.createNiceMock(HttpServletResponse.class);
+    response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
+    EasyMock.expectLastCall().once();
+    EasyMock.replay(request, response);
+
+    final TestFilterChain chain = new TestFilterChain();
+    handler.doFilter(request, response, chain);
+
+    Assert.assertFalse(chain.doFilterCalled);
+    EasyMock.verify(response);
+  }
+
+  /**
+   * Bearer JWT from EXTERNAL_ISSUER with no grant_type — not a token-exchange 
request. The
+   * hook checks grant_type first and returns without consulting the registry. 
EXTERNAL_ISSUER
+   * is not in expectedIssuers, so the filter rejects. The strict mock proves 
no service method
+   * was called.
+   */
+  @Test
+  public void testNonTokenExchangeRegistryIssuerRejected() throws Exception {
+    handler.init(new TestFilterConfig(getProperties()));
+
+    final SignedJWT jwt = getJWT(EXTERNAL_ISSUER, "k8s-sa",
+        new Date(System.currentTimeMillis() + 60000));
+
+    final TrustedOidcIssuerService strictIssuerSvc = 
EasyMock.createMock(TrustedOidcIssuerService.class);
+    EasyMock.replay(strictIssuerSvc);
+
+    final HttpServletRequest request = 
EasyMock.createNiceMock(HttpServletRequest.class);
+    EasyMock.expect(request.getRequestURL()).andReturn(new 
StringBuffer(SERVICE_URL)).anyTimes();
+    EasyMock.expect(request.getHeader("Authorization"))
+        .andReturn(JWTFederationFilter.BEARER + " " + 
jwt.serialize()).anyTimes();
+    EasyMock.expect(request.getServletContext())
+        .andReturn(buildContextWithIssuerService(strictIssuerSvc)).anyTimes();
+    final HttpServletResponse response = 
EasyMock.createNiceMock(HttpServletResponse.class);
+    EasyMock.replay(request, response);
+
+    final TestFilterChain chain = new TestFilterChain();
+    handler.doFilter(request, response, chain);
+
+    Assert.assertFalse(chain.doFilterCalled);
+    EasyMock.verify(strictIssuerSvc);
+  }
+
+  // 
---------------------------------------------------------------------------
+  // Static-issuer failures do not fall through to the dynamic path
+  // 
---------------------------------------------------------------------------
+
+  /**
+   * KNOX_ISSUER is in expectedIssuers. Signature verification on the static 
path fails.
+   * The strict issuerSvc mock with no expectations proves isDynamicJwks was 
never called —
+   * the static-issuer failure does not trigger the dynamic registry.
+   */
+  @Test
+  public void testStaticIssuerSignatureFailureDoesNotFallToDynamic() throws 
Exception {
+    handler.init(new TestFilterConfig(getProperties()));
+
+    final SignedJWT jwt = getJWT(KNOX_ISSUER, "some-user",
+        new Date(System.currentTimeMillis() + 60000));
+
+    final JWTokenAuthority mockAuth = 
EasyMock.createMock(JWTokenAuthority.class);
+    
EasyMock.expect(mockAuth.verifyToken(EasyMock.anyObject(JWT.class))).andReturn(false).once();
+    EasyMock.replay(mockAuth);
+    ((TestJWTFederationFilter) handler).setTokenService(mockAuth);
+
+    final TrustedOidcIssuerService strictIssuerSvc = 
EasyMock.createMock(TrustedOidcIssuerService.class);
+    EasyMock.replay(strictIssuerSvc);
+
+    final HttpServletRequest request = 
EasyMock.createNiceMock(HttpServletRequest.class);
+    EasyMock.expect(request.getRequestURL()).andReturn(new 
StringBuffer(SERVICE_URL)).anyTimes();
+    EasyMock.expect(request.getHeader("Authorization"))
+        .andReturn(JWTFederationFilter.BEARER + " " + 
jwt.serialize()).anyTimes();
+    EasyMock.expect(request.getServletContext())
+        .andReturn(buildContextWithIssuerService(strictIssuerSvc)).anyTimes();
+    final HttpServletResponse response = 
EasyMock.createNiceMock(HttpServletResponse.class);
+    response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
+    EasyMock.expectLastCall().once();
+    EasyMock.replay(request, response);
+
+    final TestFilterChain chain = new TestFilterChain();
+    handler.doFilter(request, response, chain);
+
+    Assert.assertFalse(chain.doFilterCalled);
+    EasyMock.verify(mockAuth, strictIssuerSvc, response);
+  }
+
+  // 
---------------------------------------------------------------------------
+  // Static JWKS and dynamic registry both configured
+  // 
---------------------------------------------------------------------------
+
+  /**
+   * Static JWKS (knox.token.jwks.url) and dynamic registry are both 
configured. The authority
+   * mock is strict with distinct URI-set expectations per token: the 
external-issuer token uses
+   * the dynamic JWKS URI exclusively (never the static JWKS), and the 
KNOX_ISSUER token uses
+   * the static JWKS. The eq() on sig-alg verifies the configured value 
("RS256") is passed to
+   * authority.verifyToken on the dynamic path.
+   */
+  @Test
+  public void testDynamicPathUsesRegistryJwksNotStaticJwks() throws Exception {
+    final String staticJwksUrl = "https://static.jwks.example.com/jwks";;
+    final String dynamicJwksUrl = "https://dynamic.jwks.example.com/jwks";;
+    final Set<URI> staticJwks = Set.of(new URI(staticJwksUrl));
+    final Set<URI> dynamicJwks = Set.of(new URI(dynamicJwksUrl));
+
+    final Properties props = getProperties();
+    props.setProperty(JWTFederationFilter.JWKS_URL, staticJwksUrl);
+    handler.init(new TestFilterConfig(props));
+
+    final JWTokenAuthority mockAuth = 
EasyMock.createMock(JWTokenAuthority.class);
+    EasyMock.expect(mockAuth.verifyToken(
+        EasyMock.anyObject(JWT.class), EasyMock.eq(dynamicJwks), // 
external-issuer token: dynamic JWKS only
+        EasyMock.eq(AbstractJWTFilter.JWT_DEFAULT_SIGALG),
+        EasyMock.isA(JOSEObjectTypeVerifier.class)))
+        .andReturn(true).once();
+    EasyMock.expect(mockAuth.verifyToken(
+        EasyMock.anyObject(JWT.class), EasyMock.eq(staticJwks),  // 
Knox-issuer token: static JWKS
+        EasyMock.eq(AbstractJWTFilter.JWT_DEFAULT_SIGALG),
+        EasyMock.isA(JOSEObjectTypeVerifier.class)))
+        .andReturn(true).once();
+    EasyMock.replay(mockAuth);
+    ((TestJWTFederationFilter) handler).setTokenService(mockAuth);
+
+    final SignedJWT subjectJwt = getJWT(EXTERNAL_ISSUER, "k8s-sa",
+        new Date(System.currentTimeMillis() + 60000));
+    final SignedJWT actorJwt = getJWT(KNOX_ISSUER, "actor-svc",
+        new Date(System.currentTimeMillis() + 60000));
+
+    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(dynamicJwksUrl)).once();
+
+    final HttpServletRequest request = buildTokenExchangeRequest(
+        subjectJwt.serialize(), actorJwt.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(chain.doFilterCalled);
+    EasyMock.verify(mockAuth, issuerSvc);
+  }
+
+  // 
---------------------------------------------------------------------------
+  // Existing behavior unaffected by the new hook
+  // 
---------------------------------------------------------------------------
+
+  /**
+   * Token-exchange request with a Knox-issuer (static) subject token and no 
actor token. The
+   * strict issuerSvc mock with no expectations proves isDynamicJwks is never 
called for a
+   * static-issuer token, even in a token-exchange grant.
+   */
+  @Test
+  public void testTokenExchangeWithStaticIssuerSubjectSucceeds() throws 
Exception {
+    handler.init(new TestFilterConfig(getProperties()));
+
+    final SignedJWT subjectJwt = getJWT(KNOX_ISSUER, "some-user",
+        new Date(System.currentTimeMillis() + 60000));
+
+    final TrustedOidcIssuerService strictIssuerSvc = 
EasyMock.createMock(TrustedOidcIssuerService.class);
+    EasyMock.replay(strictIssuerSvc);
+
+    final HttpServletRequest request = buildTokenExchangeRequest(
+        subjectJwt.serialize(), 
buildContextWithIssuerService(strictIssuerSvc));
+    final HttpServletResponse response = 
EasyMock.createNiceMock(HttpServletResponse.class);
+    EasyMock.replay(request, response);
+
+    final TestFilterChain chain = new TestFilterChain();
+    handler.doFilter(request, response, chain);
+
+    Assert.assertTrue("Filter chain should proceed", chain.doFilterCalled);
+    EasyMock.verify(strictIssuerSvc);
+  }
+
+  /**
+   * Bearer JWT from KNOX_ISSUER (in static expectedIssuers). validateToken() 
returns from the
+   * static-issuer branch before resolveRegisteredIssuerJwks is reached. The 
strict issuerSvc
+   * mock with no expectations proves the hook was not called: any service 
method call would
+   * throw immediately.
+   */
+  @Test
+  public void testNonTokenExchangeGrantUnaffected() throws Exception {
+    handler.init(new TestFilterConfig(getProperties()));
+
+    final SignedJWT jwt = getJWT(KNOX_ISSUER, "some-user",
+        new Date(System.currentTimeMillis() + 60000));
+
+    final TrustedOidcIssuerService strictIssuerSvc = 
EasyMock.createMock(TrustedOidcIssuerService.class);
+    EasyMock.replay(strictIssuerSvc);
+
+    final HttpServletRequest request = 
EasyMock.createNiceMock(HttpServletRequest.class);
+    EasyMock.expect(request.getRequestURL()).andReturn(new 
StringBuffer(SERVICE_URL)).anyTimes();
+    EasyMock.expect(request.getHeader("Authorization"))
+        .andReturn(JWTFederationFilter.BEARER + " " + 
jwt.serialize()).anyTimes();
+    EasyMock.expect(request.getServletContext())
+        .andReturn(buildContextWithIssuerService(strictIssuerSvc)).anyTimes();
+    final HttpServletResponse response = 
EasyMock.createNiceMock(HttpServletResponse.class);
+    EasyMock.replay(request, response);
+
+    final TestFilterChain chain = new TestFilterChain();
+    handler.doFilter(request, response, chain);
+
+    Assert.assertTrue(chain.doFilterCalled);
+    EasyMock.verify(strictIssuerSvc);
+  }
+
+  // 
---------------------------------------------------------------------------
+  // TOKEN_ISS_ATTRIBUTE — separate concern from JWKS logic
+  // 
---------------------------------------------------------------------------
+
+  /**
+   * After successful Bearer JWT validation, addKnoxIDFAttributes() stores 
TOKEN_ISS_ATTRIBUTE
+   * on the request. Used by admin endpoint handlers for per-cluster scope 
limiting.
+   */
+  @Test
+  public void testIssAttributeSetAfterValidation() throws Exception {
+    handler.init(new TestFilterConfig(getProperties()));
+
+    final SignedJWT jwt = getJWT(KNOX_ISSUER, "some-user",
+        new Date(System.currentTimeMillis() + 60000));
+
+    final Map<String, Object> capturedAttrs = new HashMap<>();
+    final HttpServletRequest underlying = 
EasyMock.createNiceMock(HttpServletRequest.class);
+    EasyMock.expect(underlying.getRequestURL()).andReturn(new 
StringBuffer(SERVICE_URL)).anyTimes();
+    EasyMock.expect(underlying.getHeader("Authorization"))
+        .andReturn(JWTFederationFilter.BEARER + " " + 
jwt.serialize()).anyTimes();
+    EasyMock.replay(underlying);
+
+    final HttpServletRequest request = new 
HttpServletRequestWrapper(underlying) {
+      @Override
+      public void setAttribute(String name, Object o) {
+        capturedAttrs.put(name, o);
+      }
+
+      @Override
+      public Object getAttribute(String name) {
+        return capturedAttrs.get(name);
+      }
+    };
+
+    final HttpServletResponse response = 
EasyMock.createNiceMock(HttpServletResponse.class);
+    EasyMock.replay(response);
+
+    final TestFilterChain chain = new TestFilterChain();
+    handler.doFilter(request, response, chain);
+
+    Assert.assertTrue(chain.doFilterCalled);
+    Assert.assertEquals(KNOX_ISSUER, 
capturedAttrs.get(KnoxIDFConstants.TOKEN_ISS_ATTRIBUTE));
+  }
+
+  // 
---------------------------------------------------------------------------
+  // Helpers
+  // 
---------------------------------------------------------------------------
+
+  private ServletContext 
buildContextWithIssuerService(TrustedOidcIssuerService issuerSvc) {
+    final GatewayServices gws = EasyMock.createNiceMock(GatewayServices.class);
+    
EasyMock.expect(gws.getService(ServiceType.TRUSTED_OIDC_ISSUER_SERVICE)).andReturn(issuerSvc).anyTimes();
+    EasyMock.replay(gws);
+    return buildServletContext(gws);
+  }
+
+  private ServletContext buildServletContext(GatewayServices gws) {
+    final ServletContext ctx = EasyMock.createNiceMock(ServletContext.class);
+    
EasyMock.expect(ctx.getAttribute(GatewayServices.GATEWAY_SERVICES_ATTRIBUTE)).andReturn(gws).anyTimes();
+    
EasyMock.expect(ctx.getAttribute(GatewayServices.GATEWAY_CLUSTER_ATTRIBUTE))
+        .andReturn("jwt-test-topology").anyTimes();
+    EasyMock.replay(ctx);
+    return ctx;
+  }
+
+  private HttpServletRequest buildTokenExchangeRequest(String subjectToken, 
ServletContext ctx) {
+    final HttpServletRequest request = 
EasyMock.createNiceMock(HttpServletRequest.class);
+    EasyMock.expect(request.getRequestURL()).andReturn(new 
StringBuffer(SERVICE_URL)).anyTimes();
+    
EasyMock.expect(request.getParameter(GRANT_TYPE)).andReturn(JWTFederationFilter.TOKEN_EXCHANGE).anyTimes();
+    
EasyMock.expect(request.getParameter(JWTFederationFilter.SUBJECT_TOKEN)).andReturn(subjectToken).anyTimes();
+    
EasyMock.expect(request.getParameter(JWTFederationFilter.SUBJECT_TOKEN_TYPE))
+        .andReturn(JWTFederationFilter.TOKEN_TYPE_JWT).anyTimes();
+    // ACTOR_TOKEN not mocked — niceMock returns null, making actor_token 
absent
+    EasyMock.expect(request.getServletContext()).andReturn(ctx).anyTimes();
+    return request;
+  }
+
+  private HttpServletRequest buildTokenExchangeRequest(String subjectToken, 
String actorToken,
+      ServletContext ctx) {
+    final HttpServletRequest request = 
EasyMock.createNiceMock(HttpServletRequest.class);
+    EasyMock.expect(request.getRequestURL()).andReturn(new 
StringBuffer(SERVICE_URL)).anyTimes();
+    
EasyMock.expect(request.getParameter(GRANT_TYPE)).andReturn(JWTFederationFilter.TOKEN_EXCHANGE).anyTimes();
+    
EasyMock.expect(request.getParameter(JWTFederationFilter.SUBJECT_TOKEN)).andReturn(subjectToken).anyTimes();
+    
EasyMock.expect(request.getParameter(JWTFederationFilter.SUBJECT_TOKEN_TYPE))
+        .andReturn(JWTFederationFilter.TOKEN_TYPE_JWT).anyTimes();
+    
EasyMock.expect(request.getParameter(JWTFederationFilter.ACTOR_TOKEN)).andReturn(actorToken).anyTimes();
+    EasyMock.expect(request.getParameter(JWTFederationFilter.ACTOR_TOKEN_TYPE))
+        .andReturn(JWTFederationFilter.TOKEN_TYPE_JWT).anyTimes();
+    EasyMock.expect(request.getServletContext()).andReturn(ctx).anyTimes();
+    return request;
+  }
+
+}
diff --git 
a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/JWTFederationFilterTokenExchangeRoutingTest.java
 
b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/JWTFederationFilterTokenExchangeRoutingTest.java
index 665f6fc9d..1dad10b28 100644
--- 
a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/JWTFederationFilterTokenExchangeRoutingTest.java
+++ 
b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/JWTFederationFilterTokenExchangeRoutingTest.java
@@ -64,7 +64,7 @@ public class JWTFederationFilterTokenExchangeRoutingTest {
   @Test
   public void testTokenExchangeGrantRoutesToHandler() throws Exception {
     // grant_type in the body (unwrapped), no Authorization header
-    final HttpServletRequest request = 
wrapped(bodyRequest(TokenExchangeHandler.TOKEN_EXCHANGE, null));
+    final HttpServletRequest request = 
wrapped(bodyRequest(JWTFederationFilter.TOKEN_EXCHANGE, null));
 
     filter.doFilter(request, response, chain);
 
diff --git 
a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandlerTest.java
 
b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandlerTest.java
index 49561ad66..a5296b349 100644
--- 
a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandlerTest.java
+++ 
b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandlerTest.java
@@ -49,8 +49,8 @@ import java.util.Set;
  */
 public class TokenExchangeHandlerTest {
 
-  private static final String JWT_TYPE = TokenExchangeHandler.TOKEN_TYPE_JWT;
-  private static final String ACCESS_TOKEN_TYPE = 
TokenExchangeHandler.TOKEN_TYPE_ACCESS_TOKEN;
+  private static final String JWT_TYPE = JWTFederationFilter.TOKEN_TYPE_JWT;
+  private static final String ACCESS_TOKEN_TYPE = 
JWTFederationFilter.TOKEN_TYPE_ACCESS_TOKEN;
   private static final String SAML2_TYPE = 
"urn:ietf:params:oauth:token-type:saml2";
 
   private RecordingFilter filter;
@@ -193,10 +193,10 @@ public class TokenExchangeHandlerTest {
   private HttpServletRequest request(String subjectToken, String 
subjectTokenType,
                                      String actorToken, String actorTokenType) 
{
     final HttpServletRequest request = 
EasyMock.createNiceMock(HttpServletRequest.class);
-    
EasyMock.expect(request.getParameter(TokenExchangeHandler.SUBJECT_TOKEN)).andReturn(subjectToken).anyTimes();
-    
EasyMock.expect(request.getParameter(TokenExchangeHandler.SUBJECT_TOKEN_TYPE)).andReturn(subjectTokenType).anyTimes();
-    
EasyMock.expect(request.getParameter(TokenExchangeHandler.ACTOR_TOKEN)).andReturn(actorToken).anyTimes();
-    
EasyMock.expect(request.getParameter(TokenExchangeHandler.ACTOR_TOKEN_TYPE)).andReturn(actorTokenType).anyTimes();
+    
EasyMock.expect(request.getParameter(JWTFederationFilter.SUBJECT_TOKEN)).andReturn(subjectToken).anyTimes();
+    
EasyMock.expect(request.getParameter(JWTFederationFilter.SUBJECT_TOKEN_TYPE)).andReturn(subjectTokenType).anyTimes();
+    
EasyMock.expect(request.getParameter(JWTFederationFilter.ACTOR_TOKEN)).andReturn(actorToken).anyTimes();
+    
EasyMock.expect(request.getParameter(JWTFederationFilter.ACTOR_TOKEN_TYPE)).andReturn(actorTokenType).anyTimes();
     EasyMock.replay(request);
     return request;
   }
diff --git 
a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFConstants.java
 
b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFConstants.java
index 99ba8b5e3..d757b573f 100644
--- 
a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFConstants.java
+++ 
b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFConstants.java
@@ -48,6 +48,7 @@ public interface KnoxIDFConstants {
     String PKCE_METHOD_PLAIN = "plain";
 
     String TOKEN_ID_ATTRIBUTE = "X-Token-Id";
+    String TOKEN_ISS_ATTRIBUTE = "X-Token-Iss";
     String SCOPE_ATTRIBUTE = "X-Token-Scope";
 
     String FEDERATED_IDENTITY_ID = "federated_identity_id";

Reply via email to