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;
+ }
+ }
+}