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/08/13 16:03:43 UTC
(knox) 14/37: KNOX-3405 - Extend JWTFederationFilter for dynamic JWKS and iss attribute on token-exchange (#1342)
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
commit cd163171ab6155440373de7bf7e9d0d1d3b03bde
Author: hsheinblatt <hs...@hotmail.com>
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 502928891..bbf396dd6 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";