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";