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

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


The following commit(s) were added to refs/heads/v3.0.0 by this push:
     new f078ade94 KNOX-3399 - Make RFC 8693 token exchange work for 
form-encoded requests and align it with the spec (#1334)
f078ade94 is described below

commit f078ade94b20b266e5d589b1507921132662a973
Author: Sandor Molnar <[email protected]>
AuthorDate: Thu Jul 30 16:34:05 2026 +0200

    KNOX-3399 - Make RFC 8693 token exchange work for form-encoded requests and 
align it with the spec (#1334)
---
 .../federation/jwt/filter/JWTFederationFilter.java | 150 +++----------
 .../jwt/filter/TokenExchangeHandler.java           | 208 ++++++++++++++++++
 .../federation/OAuthFlowsFederationFilterTest.java |  14 --
 ...WTFederationFilterTokenExchangeRoutingTest.java | 145 +++++++++++++
 .../jwt/filter/TokenExchangeHandlerTest.java       | 234 +++++++++++++++++++++
 5 files changed, 611 insertions(+), 140 deletions(-)

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 557e81571..4e1dc8749 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
@@ -21,11 +21,7 @@ import org.apache.commons.lang3.StringUtils;
 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.ActorChainPrincipalImpl;
 import org.apache.knox.gateway.security.PrimaryPrincipal;
-import org.apache.knox.gateway.security.TokenExchangePrincipal;
-import org.apache.knox.gateway.security.TokenExchangePrincipalImpl;
-import org.apache.knox.gateway.services.security.token.TokenUtils;
 import org.apache.knox.gateway.services.security.token.UnknownTokenException;
 import org.apache.knox.gateway.services.security.token.impl.JWT;
 import org.apache.knox.gateway.services.security.token.impl.JWTToken;
@@ -44,13 +40,11 @@ import javax.servlet.http.Cookie;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import java.io.IOException;
-import java.security.Principal;
 import java.text.ParseException;
 import java.util.Base64;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Locale;
-import java.util.Map;
 import java.util.Set;
 
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -69,15 +63,12 @@ public class JWTFederationFilter extends AbstractJWTFilter {
   public static final String MISMATCHING_CLIENT_ID_AND_CLIENT_SECRET = "Client 
credentials flow with mismatching client_id and client_secret";
   public static final String REFRESH_TOKEN = "refresh_token";
   public static final String REFRESH_TOKEN_PARAM = "refresh_token";
-  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 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";
 
   public enum TokenType {
-    JWT, Passcode;
+    JWT, Passcode, TokenExchange;
   }
 
   public static final String KNOX_TOKEN_AUDIENCES = "knox.token.audiences";
@@ -101,6 +92,9 @@ public class JWTFederationFilter extends AbstractJWTFilter {
   private String paramName;
   private Set<String> unAuthenticatedPaths = new HashSet<>(20);
 
+  // Handles RFC 8693 token exchange requests (see doFilter).
+  private TokenExchangeHandler tokenExchangeHandler = new 
TokenExchangeHandler(this);
+
   @Override
   public void init( FilterConfig filterConfig ) throws ServletException {
     super.init(filterConfig);
@@ -179,15 +173,6 @@ public class JWTFederationFilter extends AbstractJWTFilter 
{
       }
     }
 
-    // RFC 8693: Check if this is a token exchange request
-    HttpServletRequest httpRequest = (HttpServletRequest) request;
-    String grantType = httpRequest.getParameter(GRANT_TYPE);
-    if (TOKEN_EXCHANGE.equals(grantType)) {
-      // Handle RFC 8693 token exchange with subject_token and actor_token
-      handleTokenExchange(httpRequest, (HttpServletResponse) response, chain);
-      return;
-    }
-
     Pair<TokenType, String> wireToken = null;
     try {
       wireToken = getWireToken(request);
@@ -196,6 +181,15 @@ public class JWTFederationFilter extends AbstractJWTFilter 
{
       throw e;
     }
 
+    // RFC 8693 token exchange: getWireToken flags this via 
TokenType.TokenExchange when the
+    // grant_type is in the request body. The subject_token/actor_token are 
read from the unwrapped
+    // request by the handler. Reading the body only happens on this 
(header-less) grant-flow path,
+    // so a proxied backend's body is never consumed by the 
header-authenticated path.
+    if (wireToken != null && 
TokenType.TokenExchange.equals(wireToken.getLeft())) {
+      tokenExchangeHandler.handle((HttpServletRequest) request, 
(HttpServletResponse) response, chain);
+      return;
+    }
+
     if (wireToken != null && wireToken.getLeft() != null && 
wireToken.getRight() != null) {
       TokenType tokenType  = wireToken.getLeft();
       String    tokenValue = wireToken.getRight();
@@ -351,9 +345,10 @@ 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 (TOKEN_EXCHANGE.equals(grantType)) {
-          // token_exchange flow: the subject_token parameter contains the 
token to be exchanged
-          return getClientTokenFromParams(unwrappedRequest, SUBJECT_TOKEN);
+        } else if (TokenExchangeHandler.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);
         }
       return null;
     }
@@ -421,67 +416,6 @@ public class JWTFederationFilter extends AbstractJWTFilter 
{
     return false;
   }
 
-  /**
-   * Handle RFC 8693 token exchange flow.
-   *
-   * <p>This method validates both the subject_token and actor_token 
parameters,
-   * creates a TokenExchangePrincipal with the identity information from both 
tokens,
-   * and establishes a Subject with the actor as the PrimaryPrincipal.</p>
-   *
-   * <p>The TokenExchangePrincipal signals to the identity assertion layer that
-   * impersonation should be established with the subject as the 
ImpersonatedPrincipal.</p>
-   *
-   * @param request the HTTP request containing subject_token and actor_token 
parameters
-   * @param response the HTTP response
-   * @param chain the filter chain
-   * @throws IOException if an I/O error occurs
-   * @throws ServletException if a servlet error occurs
-   */
-  private void handleTokenExchange(HttpServletRequest request, 
HttpServletResponse response, FilterChain chain)
-      throws IOException, ServletException {
-    // Extract subject_token (required)
-    String subjectTokenValue = request.getParameter(SUBJECT_TOKEN);
-    if (subjectTokenValue == null || subjectTokenValue.isEmpty()) {
-      handleValidationError(request, response, 
HttpServletResponse.SC_BAD_REQUEST,
-          "RFC 8693 token exchange requires subject_token parameter");
-      return;
-    }
-
-    // Extract actor_token (required for proper token exchange)
-    String actorTokenValue = request.getParameter(ACTOR_TOKEN);
-    if (actorTokenValue == null || actorTokenValue.isEmpty()) {
-      handleValidationError(request, response, 
HttpServletResponse.SC_BAD_REQUEST,
-          "RFC 8693 token exchange requires actor_token parameter");
-      return;
-    }
-
-    try {
-      // Parse and validate subject_token
-      JWT subjectToken = parseAndValidateJWT(request, response, chain, 
subjectTokenValue);
-      if (subjectToken == null) {
-        // Validation failed, error response already sent
-        return;
-      }
-
-      // Parse and validate actor_token
-      JWT actorToken = parseAndValidateJWT(request, response, chain, 
actorTokenValue);
-      if (actorToken == null) {
-        // Validation failed, error response already sent
-        return;
-      }
-
-      // Create Subject with actor as PrimaryPrincipal and 
TokenExchangePrincipal
-      Subject subject = createSubjectForTokenExchange(subjectToken, 
actorToken);
-
-      continueWithEstablishedSecurityContext(subject, request, response, 
chain);
-
-    } catch (ParseException e) {
-      LOGGER.failedToParsePasscodeToken(e);
-      handleValidationError(request, response, 
HttpServletResponse.SC_UNAUTHORIZED,
-          "Failed to parse token in token exchange: " + e.getMessage());
-    }
-  }
-
   /**
    * Parse and validate a JWT token.
    *
@@ -494,7 +428,8 @@ public class JWTFederationFilter extends AbstractJWTFilter {
    * @throws IOException if an I/O error occurs during validation
    * @throws ServletException if a servlet error occurs during validation
    */
-  private JWT parseAndValidateJWT(HttpServletRequest request, 
HttpServletResponse response,
+  // package-private: also invoked by TokenExchangeHandler
+  JWT parseAndValidateJWT(HttpServletRequest request, HttpServletResponse 
response,
                                   FilterChain chain, String tokenValue)
       throws ParseException, IOException, ServletException {
     JWT token = new JWTToken(tokenValue);
@@ -505,48 +440,6 @@ public class JWTFederationFilter extends AbstractJWTFilter 
{
     return null;
   }
 
-  /**
-   * Create a Subject for RFC 8693 token exchange with proper principal setup.
-   *
-   * @param subjectToken the validated subject token
-   * @param actorToken the validated actor token
-   * @return a Subject configured for token exchange
-   */
-  private Subject createSubjectForTokenExchange(JWT subjectToken, JWT 
actorToken) {
-    // Extract identities from the tokens
-    String subjectPrincipalName = subjectToken.getSubject();
-    String subjectIssuer = subjectToken.getIssuer();
-    String actorPrincipalName = actorToken.getSubject();
-    String actorIssuer = actorToken.getIssuer();
-    // Create principals for the Subject
-    // PrimaryPrincipal is the ACTOR (the authenticated party)
-    PrimaryPrincipal primaryPrincipal =
-        new PrimaryPrincipal(actorPrincipalName);
-
-    // TokenExchangePrincipal carries metadata for identity assertion layer
-    TokenExchangePrincipal tokenExchangePrincipal =
-        new TokenExchangePrincipalImpl(
-            subjectPrincipalName, subjectIssuer, actorPrincipalName, 
actorIssuer);
-
-    // Extract actor chain from subject_token (if present) using existing logic
-    List<Map<String, Object>> actorChain =
-        TokenUtils.extractActorChain(subjectToken);
-
-    // Create Subject with all necessary principals
-    Set<Principal> principals = new HashSet<>();
-    principals.add(primaryPrincipal);
-    principals.add(tokenExchangePrincipal);
-
-    // Add ActorChainPrincipal if actor chain exists in subject_token
-    if (!actorChain.isEmpty()) {
-      principals.add(new ActorChainPrincipalImpl(actorChain));
-    }
-
-    @SuppressWarnings("rawtypes")
-    HashSet emptySet = new HashSet();
-    return new Subject(true, principals, emptySet, emptySet);
-  }
-
   @Override
   protected void handleValidationError(HttpServletRequest request, 
HttpServletResponse response, int status,
                                        String error) throws IOException {
@@ -595,4 +488,9 @@ public class JWTFederationFilter extends AbstractJWTFilter {
       super("None of the presented cookies are valid.");
     }
   }
+
+  // Test seam: allows a mock/recording handler to be injected.
+  void setTokenExchangeHandler(TokenExchangeHandler tokenExchangeHandler) {
+    this.tokenExchangeHandler = tokenExchangeHandler;
+  }
 }
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
new file mode 100644
index 000000000..99bd3de5f
--- /dev/null
+++ 
b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandler.java
@@ -0,0 +1,208 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.knox.gateway.provider.federation.jwt.filter;
+
+import org.apache.knox.gateway.security.ActorChainPrincipalImpl;
+import org.apache.knox.gateway.security.PrimaryPrincipal;
+import org.apache.knox.gateway.security.TokenExchangePrincipal;
+import org.apache.knox.gateway.security.TokenExchangePrincipalImpl;
+import org.apache.knox.gateway.services.security.token.TokenUtils;
+import org.apache.knox.gateway.services.security.token.UnknownTokenException;
+import org.apache.knox.gateway.services.security.token.impl.JWT;
+import org.apache.knox.gateway.util.ServletRequestUtils;
+
+import javax.security.auth.Subject;
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import java.io.IOException;
+import java.security.Principal;
+import java.text.ParseException;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Handles RFC 8693 (OAuth 2.0 Token Exchange) requests on behalf of {@link 
JWTFederationFilter}.
+ *
+ * <p>The exchange parameters are sent in the {@code 
application/x-www-form-urlencoded} body and are
+ * therefore read from the <em>unwrapped</em> request (the filter chain wraps 
the request in a form
+ * that hides the body from {@code getParameter()}). The owning filter is used 
for JWT validation
+ * and for establishing the resulting security context.</p>
+ *
+ * <p>Per RFC 8693 section 2.1: {@code subject_token} and {@code 
subject_token_type} are required;
+ * {@code actor_token} is optional, and {@code actor_token_type} is required 
when {@code actor_token}
+ * is present and must not be present otherwise. Only JWT-family token types 
are supported. When an
+ * {@code actor_token} is present the request is treated as delegation 
(on-behalf-of): the actor is
+ * the authenticated party and the subject is the impersonated party; 
otherwise the subject_token is
+ * simply exchanged for a token representing the subject.</p>
+ */
+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) {
+    this.filter = filter;
+  }
+
+  /**
+   * Handle a token-exchange request that has already been identified by its 
grant type.
+   *
+   * @param request  the HTTP request (wrapped; passed through to downstream 
processing)
+   * @param response the HTTP response
+   * @param chain    the filter chain
+   * @throws IOException      if an I/O error occurs
+   * @throws ServletException if a servlet error occurs
+   */
+  void handle(HttpServletRequest request, HttpServletResponse response, 
FilterChain chain)
+      throws IOException, ServletException {
+    // The parameters live in the x-www-form-urlencoded body, which is only 
readable on the
+    // unwrapped request. The wrapped request is still used below so 
downstream processing is
+    // unchanged.
+    final HttpServletRequest bodyRequest = 
ServletRequestUtils.unwrapHttpServletRequest(request);
+
+    final String subjectTokenValue = bodyRequest.getParameter(SUBJECT_TOKEN);
+    final String subjectTokenType = 
bodyRequest.getParameter(SUBJECT_TOKEN_TYPE);
+    final String actorTokenValue = bodyRequest.getParameter(ACTOR_TOKEN);
+    final String actorTokenType = bodyRequest.getParameter(ACTOR_TOKEN_TYPE);
+    final boolean hasActorToken = actorTokenValue != null && 
!actorTokenValue.isEmpty();
+    final boolean hasActorTokenType = actorTokenType != null && 
!actorTokenType.isEmpty();
+
+    // RFC 8693 section 2.1: subject_token and subject_token_type are REQUIRED.
+    if (subjectTokenValue == null || subjectTokenValue.isEmpty()) {
+      filter.handleValidationError(request, response, 
HttpServletResponse.SC_BAD_REQUEST,
+          "invalid_request: the subject_token parameter is required");
+      return;
+    }
+    if (subjectTokenType == null || subjectTokenType.isEmpty()) {
+      filter.handleValidationError(request, response, 
HttpServletResponse.SC_BAD_REQUEST,
+          "invalid_request: the subject_token_type parameter is required");
+      return;
+    }
+    // RFC 8693 section 2.1: actor_token_type is REQUIRED when actor_token is 
present and MUST NOT
+    // be present otherwise.
+    if (hasActorToken && !hasActorTokenType) {
+      filter.handleValidationError(request, response, 
HttpServletResponse.SC_BAD_REQUEST,
+          "invalid_request: actor_token_type is required when actor_token is 
present");
+      return;
+    }
+    if (!hasActorToken && hasActorTokenType) {
+      filter.handleValidationError(request, response, 
HttpServletResponse.SC_BAD_REQUEST,
+          "invalid_request: actor_token_type must not be present without 
actor_token");
+      return;
+    }
+    // Only JWT-family token types are supported.
+    if (isNotSupportedTokenType(subjectTokenType)) {
+      filter.handleValidationError(request, response, 
HttpServletResponse.SC_BAD_REQUEST,
+          "unsupported_token_type: unsupported subject_token_type " + 
subjectTokenType);
+      return;
+    }
+    if (hasActorToken && isNotSupportedTokenType(actorTokenType)) {
+      filter.handleValidationError(request, response, 
HttpServletResponse.SC_BAD_REQUEST,
+          "unsupported_token_type: unsupported actor_token_type " + 
actorTokenType);
+      return;
+    }
+
+    try {
+      final JWT subjectToken = filter.parseAndValidateJWT(request, response, 
chain, subjectTokenValue);
+      if (subjectToken == null) {
+        // Validation failed, error response already sent
+        return;
+      }
+
+      final Subject subject;
+      if (hasActorToken) {
+        final JWT actorToken = filter.parseAndValidateJWT(request, response, 
chain, actorTokenValue);
+        if (actorToken == null) {
+          // Validation failed, error response already sent
+          return;
+        }
+        // Delegation (OBO): actor as PrimaryPrincipal, subject as the 
impersonated party
+        subject = createSubjectForTokenExchange(subjectToken, actorToken);
+      } else {
+        // No actor_token: exchange the subject_token for a token representing 
the subject itself
+        subject = filter.createSubjectFromToken(subjectToken);
+      }
+
+      filter.continueWithEstablishedSecurityContext(subject, request, 
response, chain);
+    } catch (ParseException | UnknownTokenException e) {
+      filter.handleValidationError(request, response, 
HttpServletResponse.SC_UNAUTHORIZED,
+          "Failed to parse token in token exchange: " + e.getMessage());
+    }
+  }
+
+  /**
+   * Token exchange only supports JWT-family token types. The access_token URN 
is accepted as an
+   * alias for jwt because Knox labels its issued (JWT) access tokens with 
that type.
+   *
+   * @param tokenType the RFC 8693 token type identifier
+   * @return true if the type does NOT map to a Knox JWT
+   */
+  private boolean isNotSupportedTokenType(String tokenType) {
+    return !TOKEN_TYPE_JWT.equals(tokenType) && 
!TOKEN_TYPE_ACCESS_TOKEN.equals(tokenType);
+  }
+
+  /**
+   * Create a Subject for a delegation (on-behalf-of) token exchange: the 
actor is the primary
+   * (authenticated) principal, and the subject is carried for the identity 
assertion layer, along
+   * with any pre-existing actor chain from the subject_token.
+   *
+   * @param subjectToken the validated subject token
+   * @param actorToken   the validated actor token
+   * @return a Subject configured for token exchange
+   */
+  private Subject createSubjectForTokenExchange(JWT subjectToken, JWT 
actorToken) {
+    final String subjectPrincipalName = subjectToken.getSubject();
+    final String subjectIssuer = subjectToken.getIssuer();
+    final String actorPrincipalName = actorToken.getSubject();
+    final String actorIssuer = actorToken.getIssuer();
+
+    // PrimaryPrincipal is the ACTOR (the authenticated party)
+    final PrimaryPrincipal primaryPrincipal = new 
PrimaryPrincipal(actorPrincipalName);
+
+    // TokenExchangePrincipal carries metadata for the identity assertion layer
+    final TokenExchangePrincipal tokenExchangePrincipal =
+        new TokenExchangePrincipalImpl(subjectPrincipalName, subjectIssuer, 
actorPrincipalName, actorIssuer);
+
+    // Extract actor chain from subject_token (if present) using existing logic
+    final List<Map<String, Object>> actorChain = 
TokenUtils.extractActorChain(subjectToken);
+
+    final Set<Principal> principals = new HashSet<>();
+    principals.add(primaryPrincipal);
+    principals.add(tokenExchangePrincipal);
+    if (!actorChain.isEmpty()) {
+      principals.add(new ActorChainPrincipalImpl(actorChain));
+    }
+
+    @SuppressWarnings("rawtypes")
+    final HashSet emptySet = new HashSet();
+    return new Subject(true, principals, emptySet, emptySet);
+  }
+}
diff --git 
a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/OAuthFlowsFederationFilterTest.java
 
b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/OAuthFlowsFederationFilterTest.java
index 515d173f3..5fc257e05 100644
--- 
a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/OAuthFlowsFederationFilterTest.java
+++ 
b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/OAuthFlowsFederationFilterTest.java
@@ -393,12 +393,6 @@ public class OAuthFlowsFederationFilterTest extends 
TokenIDAsHTTPBasicCredsFeder
       testGetWireTokenWithGrant(JWTFederationFilter.REFRESH_TOKEN, 
JWTFederationFilter.REFRESH_TOKEN_PARAM, refreshToken);
     }
 
-    @Test
-    public void testGetWireTokenUsingTokenExchangeFlow() throws Exception {
-      final String subjectToken = 
"WTJ4cFpXNTBMV2xrTFRFeU16UTE2OlkyeHBaVzUwTFhObFkzSmxkQzB4TWpNME5RPT0=";
-      testGetWireTokenWithGrant(JWTFederationFilter.TOKEN_EXCHANGE, 
JWTFederationFilter.SUBJECT_TOKEN, subjectToken);
-    }
-
     @Test
     public void testVerifyRefreshTokenFlow() throws Exception {
         final String tokenId = "4e0c548b-6568-4061-a3dc-62908087650b";
@@ -407,14 +401,6 @@ public class OAuthFlowsFederationFilterTest extends 
TokenIDAsHTTPBasicCredsFeder
         testVerifyTokenWithGrant(tokenId, passcode, passcodeToken, 
JWTFederationFilter.REFRESH_TOKEN, JWTFederationFilter.REFRESH_TOKEN_PARAM);
     }
 
-    @Test
-    public void testVerifyTokenExchangeFlow() throws Exception {
-        final String tokenId = "4e0c548b-6568-4061-a3dc-62908087650c";
-        final String passcode = "0138aaed-ca2a-47f1-8ed8-e0c397596f97";
-        final String passcodeToken = 
"TkdVd1l6VTBPR0l0TmpVMk9DMDBNRFl4TFdFelpHTXROakk1TURnd09EYzJOVEJqOjpNREV6T0dGaFpXUXRZMkV5WVMwME4yWXhMVGhsWkRndFpUQmpNemszTlRrMlpqazM=";
-        testVerifyTokenWithGrant(tokenId, passcode, passcodeToken, 
JWTFederationFilter.TOKEN_EXCHANGE, JWTFederationFilter.SUBJECT_TOKEN);
-    }
-
     private Pair<TokenStateService, TokenMetadata> 
createMockTokenStateService(String tokenId, String passcodeToken) throws 
UnknownTokenException {
         final TokenStateService tokenStateService = 
EasyMock.createNiceMock(TokenStateService.class);
         
EasyMock.expect(tokenStateService.getTokenExpiration(tokenId)).andReturn(Long.MAX_VALUE).anyTimes();
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
new file mode 100644
index 000000000..665f6fc9d
--- /dev/null
+++ 
b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/JWTFederationFilterTokenExchangeRoutingTest.java
@@ -0,0 +1,145 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.knox.gateway.provider.federation.jwt.filter;
+
+import static org.apache.knox.gateway.security.CommonTokenConstants.GRANT_TYPE;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.easymock.EasyMock;
+import org.junit.Before;
+import org.junit.Test;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletRequestWrapper;
+import javax.servlet.http.HttpServletResponse;
+
+import java.io.IOException;
+
+/**
+ * Verifies that {@link JWTFederationFilter#doFilter} routes RFC 8693 
token-exchange requests to the
+ * {@link TokenExchangeHandler}. The handler itself is replaced by a recording 
stub so this test
+ * only asserts the dispatch decision (the handler's own logic is covered by
+ * {@link TokenExchangeHandlerTest}).
+ *
+ * <p>The grant type lives in the {@code x-www-form-urlencoded} body, which is 
only visible on the
+ * unwrapped request; {@link BodyHidingRequestWrapper} reproduces that (its 
{@code getParameter}
+ * returns {@code null}, mirroring the production wrapper).</p>
+ */
+public class JWTFederationFilterTokenExchangeRoutingTest {
+
+  private JWTFederationFilter filter;
+  private RecordingTokenExchangeHandler recordingHandler;
+  private HttpServletResponse response;
+  private RecordingFilterChain chain;
+
+  @Before
+  public void setUp() {
+    filter = new JWTFederationFilter();
+    recordingHandler = new RecordingTokenExchangeHandler(filter);
+    filter.setTokenExchangeHandler(recordingHandler);
+    response = EasyMock.createNiceMock(HttpServletResponse.class);
+    EasyMock.replay(response);
+    chain = new RecordingFilterChain();
+  }
+
+  @Test
+  public void testTokenExchangeGrantRoutesToHandler() throws Exception {
+    // grant_type in the body (unwrapped), no Authorization header
+    final HttpServletRequest request = 
wrapped(bodyRequest(TokenExchangeHandler.TOKEN_EXCHANGE, null));
+
+    filter.doFilter(request, response, chain);
+
+    assertTrue("token-exchange grant should be dispatched to the handler", 
recordingHandler.called);
+    assertFalse("the filter chain must not continue when the handler takes 
over", chain.called);
+  }
+
+  @Test
+  public void testNonTokenExchangeGrantDoesNotRouteToHandler() throws 
Exception {
+    // no grant_type at all -> not a token exchange
+    final HttpServletRequest request = wrapped(bodyRequest(null, null));
+
+    filter.doFilter(request, response, chain);
+
+    assertFalse("non-exchange requests must not reach the handler", 
recordingHandler.called);
+  }
+
+  private static HttpServletRequest wrapped(HttpServletRequest inner) {
+    return new BodyHidingRequestWrapper(inner);
+  }
+
+  private static HttpServletRequest bodyRequest(String grantType, String 
authorizationHeader) {
+    final HttpServletRequest request = 
EasyMock.createNiceMock(HttpServletRequest.class);
+    
EasyMock.expect(request.getHeader("Authorization")).andReturn(authorizationHeader).anyTimes();
+    
EasyMock.expect(request.getParameter(GRANT_TYPE)).andReturn(grantType).anyTimes();
+    EasyMock.expect(request.getQueryString()).andReturn(null).anyTimes();
+    EasyMock.expect(request.getPathInfo()).andReturn(null).anyTimes();
+    EasyMock.replay(request);
+    return request;
+  }
+
+  /** Mirrors the production request wrapper: parameters are hidden, the body 
is only on getRequest(). */
+  private static final class BodyHidingRequestWrapper extends 
HttpServletRequestWrapper {
+    private final HttpServletRequest inner;
+
+    BodyHidingRequestWrapper(HttpServletRequest inner) {
+      super(inner);
+      this.inner = inner;
+    }
+
+    @Override
+    public String getParameter(String name) {
+      return null; // hide body parameters
+    }
+
+    @Override
+    public String getHeader(String name) {
+      return inner.getHeader(name);
+    }
+
+    @Override
+    public HttpServletRequest getRequest() {
+      return inner;
+    }
+  }
+
+  private static final class RecordingTokenExchangeHandler extends 
TokenExchangeHandler {
+    private boolean called;
+
+    RecordingTokenExchangeHandler(JWTFederationFilter filter) {
+      super(filter);
+    }
+
+    @Override
+    void handle(HttpServletRequest request, HttpServletResponse response, 
FilterChain chain) {
+      this.called = true;
+    }
+  }
+
+  private static final class RecordingFilterChain implements FilterChain {
+    private boolean called;
+
+    @Override
+    public void doFilter(ServletRequest request, ServletResponse response) 
throws IOException, ServletException {
+      this.called = true;
+    }
+  }
+}
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
new file mode 100644
index 000000000..9b7608828
--- /dev/null
+++ 
b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandlerTest.java
@@ -0,0 +1,234 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.knox.gateway.provider.federation.jwt.filter;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import org.apache.knox.gateway.security.PrimaryPrincipal;
+import org.apache.knox.gateway.security.TokenExchangePrincipal;
+import org.apache.knox.gateway.services.security.token.impl.JWT;
+import org.easymock.EasyMock;
+import org.junit.Before;
+import org.junit.Test;
+
+import javax.security.auth.Subject;
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import java.io.IOException;
+import java.text.ParseException;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Unit tests for {@link TokenExchangeHandler} covering the RFC 8693 
request-validation and
+ * subject-construction business logic. The owning {@link 
JWTFederationFilter}'s callbacks
+ * (JWT validation and security-context establishment) are stubbed by {@link 
RecordingFilter}.
+ */
+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 SAML2_TYPE = 
"urn:ietf:params:oauth:token-type:saml2";
+
+  private RecordingFilter filter;
+  private TokenExchangeHandler handler;
+  private HttpServletResponse response;
+  private FilterChain chain;
+
+  @Before
+  public void setUp() {
+    filter = new RecordingFilter();
+    handler = new TokenExchangeHandler(filter);
+    response = EasyMock.createNiceMock(HttpServletResponse.class);
+    chain = EasyMock.createNiceMock(FilterChain.class);
+    EasyMock.replay(response, chain);
+  }
+
+  @Test
+  public void testSubjectTokenRequired() throws Exception {
+    handler.handle(request(null, JWT_TYPE, null, null), response, chain);
+    assertEquals(HttpServletResponse.SC_BAD_REQUEST, filter.errorStatus);
+    assertTrue(filter.errorMessage.contains("subject_token"));
+    assertFalse(filter.continued);
+  }
+
+  @Test
+  public void testSubjectTokenTypeRequired() throws Exception {
+    handler.handle(request("subtok", null, null, null), response, chain);
+    assertEquals(HttpServletResponse.SC_BAD_REQUEST, filter.errorStatus);
+    assertTrue(filter.errorMessage.contains("subject_token_type"));
+    assertFalse(filter.continued);
+  }
+
+  @Test
+  public void testActorTokenTypeRequiredWhenActorPresent() throws Exception {
+    filter.valid.put("subtok", jwt("alice", "KNOXSSO"));
+    handler.handle(request("subtok", JWT_TYPE, "acttok", null), response, 
chain);
+    assertEquals(HttpServletResponse.SC_BAD_REQUEST, filter.errorStatus);
+    assertTrue(filter.errorMessage.contains("actor_token_type is required"));
+    assertFalse(filter.continued);
+  }
+
+  @Test
+  public void testActorTokenTypeForbiddenWithoutActor() throws Exception {
+    handler.handle(request("subtok", JWT_TYPE, null, JWT_TYPE), response, 
chain);
+    assertEquals(HttpServletResponse.SC_BAD_REQUEST, filter.errorStatus);
+    assertTrue(filter.errorMessage.contains("must not be present"));
+    assertFalse(filter.continued);
+  }
+
+  @Test
+  public void testUnsupportedSubjectTokenType() throws Exception {
+    handler.handle(request("subtok", SAML2_TYPE, null, null), response, chain);
+    assertEquals(HttpServletResponse.SC_BAD_REQUEST, filter.errorStatus);
+    assertTrue(filter.errorMessage.contains("unsupported_token_type"));
+    assertFalse(filter.continued);
+  }
+
+  @Test
+  public void testUnsupportedActorTokenType() throws Exception {
+    filter.valid.put("subtok", jwt("alice", "KNOXSSO"));
+    handler.handle(request("subtok", JWT_TYPE, "acttok", SAML2_TYPE), 
response, chain);
+    assertEquals(HttpServletResponse.SC_BAD_REQUEST, filter.errorStatus);
+    assertTrue(filter.errorMessage.contains("unsupported_token_type"));
+    assertFalse(filter.continued);
+  }
+
+  @Test
+  public void testAccessTokenTypeIsAcceptedAsJwt() throws Exception {
+    filter.valid.put("subtok", jwt("alice", "KNOXSSO"));
+    handler.handle(request("subtok", ACCESS_TOKEN_TYPE, null, null), response, 
chain);
+    // access_token URN is accepted (no unsupported_token_type error) and the 
exchange proceeds
+    assertEquals(-1, filter.errorStatus);
+    assertTrue(filter.continued);
+  }
+
+  @Test
+  public void testSubjectOnlyExchangeEstablishesSubjectIdentity() throws 
Exception {
+    filter.valid.put("subtok", jwt("alice", "KNOXSSO"));
+    handler.handle(request("subtok", JWT_TYPE, null, null), response, chain);
+
+    assertTrue(filter.continued);
+    assertNotNull(filter.establishedSubject);
+    // Plain subject exchange: subject is the primary identity, no delegation 
principal
+    assertEquals("alice", primaryName(filter.establishedSubject));
+    
assertTrue(filter.establishedSubject.getPrincipals(TokenExchangePrincipal.class).isEmpty());
+  }
+
+  @Test
+  public void 
testDelegationExchangeMakesActorPrimaryWithTokenExchangePrincipal() throws 
Exception {
+    filter.valid.put("subtok", jwt("alice", "KNOXSSO"));
+    filter.valid.put("acttok", jwt("svc-dataservice", "https://k8s";));
+    handler.handle(request("subtok", JWT_TYPE, "acttok", JWT_TYPE), response, 
chain);
+
+    assertTrue(filter.continued);
+    assertNotNull(filter.establishedSubject);
+    // OBO: the actor is the authenticated (primary) party ...
+    assertEquals("svc-dataservice", primaryName(filter.establishedSubject));
+    // ... and a TokenExchangePrincipal carries the subject/actor metadata
+    final TokenExchangePrincipal tep =
+        
filter.establishedSubject.getPrincipals(TokenExchangePrincipal.class).iterator().next();
+    assertEquals("alice", tep.getSubjectPrincipalName());
+    assertEquals("svc-dataservice", tep.getActorPrincipalName());
+  }
+
+  @Test
+  public void testSubjectValidationFailureDoesNotEstablishContext() throws 
Exception {
+    // "subtok" is not in the valid map -> parseAndValidateJWT returns null 
(error already sent)
+    handler.handle(request("subtok", JWT_TYPE, null, null), response, chain);
+    assertFalse(filter.continued);
+  }
+
+  @Test
+  public void testActorValidationFailureDoesNotEstablishContext() throws 
Exception {
+    filter.valid.put("subtok", jwt("alice", "KNOXSSO"));
+    // "acttok" is not valid
+    handler.handle(request("subtok", JWT_TYPE, "acttok", JWT_TYPE), response, 
chain);
+    assertFalse(filter.continued);
+  }
+
+  private static String primaryName(Subject subject) {
+    return 
subject.getPrincipals(PrimaryPrincipal.class).iterator().next().getName();
+  }
+
+  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.replay(request);
+    return request;
+  }
+
+  private static JWT jwt(String subject, String issuer) {
+    final JWT jwt = EasyMock.createNiceMock(JWT.class);
+    EasyMock.expect(jwt.getSubject()).andReturn(subject).anyTimes();
+    EasyMock.expect(jwt.getIssuer()).andReturn(issuer).anyTimes();
+    // no actor chain in the token
+    
EasyMock.expect(jwt.getClaimAsObject(EasyMock.anyString())).andReturn(null).anyTimes();
+    EasyMock.replay(jwt);
+    return jwt;
+  }
+
+  /**
+   * A {@link JWTFederationFilter} whose validation and context-establishment 
callbacks are
+   * replaced with recording stubs, so the handler's own logic can be 
exercised in isolation.
+   */
+  private static final class RecordingFilter extends JWTFederationFilter {
+    private final Map<String, JWT> valid = new HashMap<>();
+    private int errorStatus = -1;
+    private String errorMessage;
+    private boolean continued;
+    private Subject establishedSubject;
+
+    @Override
+    JWT parseAndValidateJWT(HttpServletRequest request, HttpServletResponse 
response,
+                            FilterChain chain, String tokenValue)
+        throws ParseException, IOException, ServletException {
+      return valid.get(tokenValue);
+    }
+
+    @Override
+    protected Subject createSubjectFromToken(final JWT token) {
+      final Subject subject = new Subject();
+      subject.getPrincipals().add(new PrimaryPrincipal(token.getSubject()));
+      return subject;
+    }
+
+    @Override
+    protected void continueWithEstablishedSecurityContext(Subject subject, 
HttpServletRequest request,
+                                                          HttpServletResponse 
response, FilterChain chain) {
+      this.continued = true;
+      this.establishedSubject = subject;
+    }
+
+    @Override
+    protected void handleValidationError(HttpServletRequest request, 
HttpServletResponse response,
+                                         int status, String error) {
+      this.errorStatus = status;
+      this.errorMessage = error == null ? "" : error;
+    }
+  }
+}

Reply via email to