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