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

pzampino pushed a commit to branch knox_idf
in repository https://gitbox.apache.org/repos/asf/knox.git


The following commit(s) were added to refs/heads/knox_idf by this push:
     new e9c2ab00b KNOX-3408 - Allow no actor token in 
JWTFederationFilter.handleTokenExchange (#1339)
e9c2ab00b is described below

commit e9c2ab00b6070d70f5fc99729b868961ca46bbbf
Author: hsheinblatt <[email protected]>
AuthorDate: Thu Aug 6 10:50:22 2026 -0700

    KNOX-3408 - Allow no actor token in JWTFederationFilter.handleTokenExchange 
(#1339)
    
    * KNOX-3408 - Regression tests for subject handling in 
JWTFederationFilter.handleTokenExchange and
    TokenExchangePrincipal handling in 
AbstractIdentityAssertionFilter#continueChainAsPrincipal handling
    
    Only unit tests are added for existing functionality.
    
    * KNOX-3408 - Allow no actor token in 
JWTFederationFilter.handleTokenExchange.
    
    ---------
    
    Co-authored-by: Harrison <[email protected]>
---
 ...ctIdentityAssertionFilterTokenExchangeTest.java | 279 +++++++++++++++++++++
 .../federation/jwt/filter/JWTFederationFilter.java | 104 ++++----
 ...JWTFederationFilterHandleTokenExchangeTest.java | 262 +++++++++++++++++++
 3 files changed, 603 insertions(+), 42 deletions(-)

diff --git 
a/gateway-provider-identity-assertion-common/src/test/java/org/apache/knox/gateway/identityasserter/common/filter/AbstractIdentityAssertionFilterTokenExchangeTest.java
 
b/gateway-provider-identity-assertion-common/src/test/java/org/apache/knox/gateway/identityasserter/common/filter/AbstractIdentityAssertionFilterTokenExchangeTest.java
new file mode 100644
index 000000000..f74ddd55f
--- /dev/null
+++ 
b/gateway-provider-identity-assertion-common/src/test/java/org/apache/knox/gateway/identityasserter/common/filter/AbstractIdentityAssertionFilterTokenExchangeTest.java
@@ -0,0 +1,279 @@
+/*
+ * 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.identityasserter.common.filter;
+
+import org.apache.knox.gateway.audit.log4j.audit.Log4jAuditService;
+import org.apache.knox.gateway.context.ContextAttributes;
+import org.apache.knox.gateway.security.ActorChainPrincipal;
+import org.apache.knox.gateway.security.ActorChainPrincipalImpl;
+import org.apache.knox.gateway.security.ImpersonatedPrincipal;
+import org.apache.knox.gateway.security.PrimaryPrincipal;
+import org.apache.knox.gateway.security.SubjectUtils;
+import org.apache.knox.gateway.security.TokenExchangePrincipalImpl;
+import org.apache.knox.gateway.services.GatewayServices;
+import org.apache.logging.log4j.ThreadContext;
+import org.easymock.EasyMock;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+import javax.security.auth.Subject;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletContext;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.security.PrivilegedExceptionAction;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Regression tests for the RFC 8693 token-exchange processing pipeline:
+ * {@link AbstractIdentityAssertionFilter#continueChainAsPrincipal} handling of
+ * {@code TokenExchangePrincipal} (TEP) and {@code ActorChainPrincipal}.
+ *
+ * <p>Each test constructs a Subject directly (bypassing the JWT filter) and 
runs it through
+ * a minimal anonymous subclass of {@link CommonIdentityAssertionFilter} with 
identity
+ * {@code mapUserPrincipal} (returns input unchanged) and null {@code 
mapGroupPrincipals}
+ * (no group mapping). A {@link SubjectCapturingChain} captures the Subject 
visible to
+ * downstream filters inside whatever doAs context is active at chain 
invocation time.
+ *
+ * <p>Abbreviations used: AIAF for AbstractIdentityAssertionFilter and
+ * TEP for TokenExchangePrincipal.
+ *
+ */
+public class AbstractIdentityAssertionFilterTokenExchangeTest {
+
+  private CommonIdentityAssertionFilter filter;
+  private FilterConfig filterConfig;
+
+  @Before
+  public void setUp() throws Exception {
+    filter = new CommonIdentityAssertionFilter() {
+      @Override
+      public String mapUserPrincipal(String principalName) {
+        return principalName;
+      }
+
+      @Override
+      public String[] mapGroupPrincipals(String name, Subject subject,
+          ServletRequest request) {
+        return null;
+      }
+    };
+
+    ServletContext ctx = EasyMock.createNiceMock(ServletContext.class);
+    
EasyMock.expect(ctx.getAttribute(GatewayServices.GATEWAY_CLUSTER_ATTRIBUTE))
+        .andReturn("test-topology").anyTimes();
+    ctx.setAttribute(
+        EasyMock.eq(ContextAttributes.IMPERSONATION_ENABLED_ATTRIBUTE),
+        EasyMock.anyObject());
+    EasyMock.expectLastCall().anyTimes();
+    EasyMock.replay(ctx);
+
+    filterConfig = EasyMock.createNiceMock(FilterConfig.class);
+    
EasyMock.expect(filterConfig.getServletContext()).andReturn(ctx).anyTimes();
+    EasyMock.expect(filterConfig.getInitParameter(
+        
CommonIdentityAssertionFilter.PRINCIPAL_MAPPING)).andReturn(null).anyTimes();
+    EasyMock.expect(filterConfig.getInitParameter(
+        
CommonIdentityAssertionFilter.GROUP_PRINCIPAL_MAPPING)).andReturn(null).anyTimes();
+    EasyMock.expect(filterConfig.getInitParameter(
+        CommonIdentityAssertionFilter.ADVANCED_PRINCIPAL_MAPPING))
+        .andReturn("username").anyTimes();
+    EasyMock.expect(filterConfig.getInitParameterNames())
+        .andReturn(Collections.emptyEnumeration()).anyTimes();
+    EasyMock.replay(filterConfig);
+
+    filter.init(filterConfig);
+    ThreadContext.put(Log4jAuditService.MDC_AUDIT_CONTEXT_KEY, "dummy");
+  }
+
+  /**
+   * When TEP identifies different actor and subject, AIAF creates a new doAs 
Subject with an
+   * ImpersonatedPrincipal set to the subject identity and PrimaryPrincipal 
preserved as the actor.
+   */
+  @Test
+  public void testTEPWithDifferentActorAndSubjectSetsUpImpersonation() throws 
Exception {
+    Subject subject = buildSubject(
+        new PrimaryPrincipal("sa-actor"),
+        new TokenExchangePrincipalImpl("end-user", null, "sa-actor", null));
+
+    SubjectCapturingChain chain = runFilterWithSubject(subject);
+
+    Assert.assertTrue("chain should have been called", chain.called);
+    Set<ImpersonatedPrincipal> impersonated = 
chain.subject.getPrincipals(ImpersonatedPrincipal.class);
+    Assert.assertEquals("Expected exactly one ImpersonatedPrincipal", 1, 
impersonated.size());
+    Assert.assertEquals("ImpersonatedPrincipal should be end-user", "end-user",
+        impersonated.iterator().next().getName());
+    Set<PrimaryPrincipal> primary = 
chain.subject.getPrincipals(PrimaryPrincipal.class);
+    Assert.assertEquals("Expected exactly one PrimaryPrincipal", 1, 
primary.size());
+    Assert.assertEquals("PrimaryPrincipal should be sa-actor", "sa-actor",
+        primary.iterator().next().getName());
+  }
+
+  /**
+   * When TEP actor and subject are the same identity, no impersonation is 
needed and AIAF
+   * proceeds without adding an ImpersonatedPrincipal to the downstream 
Subject.
+   */
+  @Test
+  public void testTEPWithSameActorAndSubjectSkipsImpersonation() throws 
Exception {
+    Subject subject = buildSubject(
+        new PrimaryPrincipal("alice"),
+        new TokenExchangePrincipalImpl("alice", null, "alice", null));
+
+    SubjectCapturingChain chain = runFilterWithSubject(subject);
+
+    Assert.assertTrue("chain should have been called", chain.called);
+    Assert.assertTrue("ImpersonatedPrincipal set should be empty",
+        chain.subject.getPrincipals(ImpersonatedPrincipal.class).isEmpty());
+  }
+
+  /**
+   * When no TEP is present, AIAF proceeds normally without creating an 
ImpersonatedPrincipal
+   * and the downstream Subject contains no TokenExchangePrincipal.
+   */
+  @Test
+  public void testNoTEPProceedsNormally() throws Exception {
+    Subject subject = buildSubject(new PrimaryPrincipal("alice"));
+
+    SubjectCapturingChain chain = runFilterWithSubject(subject);
+
+    Assert.assertTrue("chain should have been called", chain.called);
+    Assert.assertTrue("ImpersonatedPrincipal set should be empty",
+        chain.subject.getPrincipals(ImpersonatedPrincipal.class).isEmpty());
+    Assert.assertNull("No TokenExchangePrincipal expected",
+        SubjectUtils.getTokenExchangePrincipal(chain.subject));
+  }
+
+  /**
+   * Principal mapping is applied to the subject identity from TEP (not to the 
actor identity).
+   * AIAF calls {@code mapUserPrincipal} on {@code 
tep.getSubjectPrincipalName()} and uses the
+   * mapped result as the ImpersonatedPrincipal; the actor (PrimaryPrincipal) 
is unchanged.
+   */
+  @Test
+  public void testTEPAppliesPrincipalMappingToSubjectNotActor() throws 
Exception {
+    CommonIdentityAssertionFilter mappingFilter = new 
CommonIdentityAssertionFilter() {
+      @Override
+      public String mapUserPrincipal(String principalName) {
+        return "user@external".equals(principalName) ? "localuser" : 
principalName;
+      }
+
+      @Override
+      public String[] mapGroupPrincipals(String name, Subject subject,
+          ServletRequest request) {
+        return null;
+      }
+    };
+    mappingFilter.init(filterConfig);
+
+    Subject subject = buildSubject(
+        new PrimaryPrincipal("sa-actor"),
+        new TokenExchangePrincipalImpl("user@external", null, "sa-actor", 
null));
+
+    SubjectCapturingChain chain = runFilterWithSubject(subject, mappingFilter);
+
+    Set<ImpersonatedPrincipal> impersonated = 
chain.subject.getPrincipals(ImpersonatedPrincipal.class);
+    Assert.assertEquals("Expected exactly one ImpersonatedPrincipal", 1, 
impersonated.size());
+    Assert.assertEquals("ImpersonatedPrincipal should be mapped value", 
"localuser",
+        impersonated.iterator().next().getName());
+    Set<PrimaryPrincipal> primary = 
chain.subject.getPrincipals(PrimaryPrincipal.class);
+    Assert.assertEquals("Expected exactly one PrimaryPrincipal", 1, 
primary.size());
+    Assert.assertEquals("PrimaryPrincipal should be actor (unmapped)", 
"sa-actor",
+        primary.iterator().next().getName());
+  }
+
+  /**
+   * The TokenExchangePrincipal is preserved in the new doAs Subject built by 
AIAF when
+   * impersonation is needed, so downstream filters can still read the 
delegation metadata.
+   */
+  @Test
+  public void testTEPPreservedInDoAsSubject() throws Exception {
+    Subject subject = buildSubject(
+        new PrimaryPrincipal("sa-actor"),
+        new TokenExchangePrincipalImpl("end-user", null, "sa-actor", null));
+
+    SubjectCapturingChain chain = runFilterWithSubject(subject);
+
+    Assert.assertNotNull("TokenExchangePrincipal should be preserved in 
downstream Subject",
+        SubjectUtils.getTokenExchangePrincipal(chain.subject));
+  }
+
+  /**
+   * The ActorChainPrincipal is preserved in the new doAs Subject built by 
AIAF when
+   * impersonation is needed, so the full delegation chain history is 
available downstream.
+   */
+  @Test
+  public void testActorChainPrincipalPreservedInDoAsSubject() throws Exception 
{
+    List<Map<String, Object>> chain = List.of(Map.of("sub", "prior-actor"));
+    Subject subject = buildSubject(
+        new PrimaryPrincipal("sa-actor"),
+        new TokenExchangePrincipalImpl("end-user", null, "sa-actor", null),
+        new ActorChainPrincipalImpl(chain));
+
+    SubjectCapturingChain capturingChain = runFilterWithSubject(subject);
+
+    Set<ActorChainPrincipal> actorChainPrincipals =
+        capturingChain.subject.getPrincipals(ActorChainPrincipal.class);
+    Assert.assertFalse("ActorChainPrincipal should be preserved", 
actorChainPrincipals.isEmpty());
+    Assert.assertEquals("getCurrentActor should be prior-actor", "prior-actor",
+        actorChainPrincipals.iterator().next().getCurrentActor());
+  }
+
+  // ---- Helpers ----
+
+  private static Subject buildSubject(java.security.Principal... principals) {
+    Subject s = new Subject();
+    for (java.security.Principal p : principals) {
+      s.getPrincipals().add(p);
+    }
+    return s;
+  }
+
+  /** Runs the filter inside {@code Subject.doAs(subjectToRun, ...)} using the 
default filter. */
+  private SubjectCapturingChain runFilterWithSubject(Subject subjectToRun) 
throws Exception {
+    return runFilterWithSubject(subjectToRun, filter);
+  }
+
+  /** Runs the filter inside {@code Subject.doAs(subjectToRun, ...)} using the 
given filter. */
+  private SubjectCapturingChain runFilterWithSubject(Subject subjectToRun,
+      CommonIdentityAssertionFilter f) throws Exception {
+    SubjectCapturingChain chain = new SubjectCapturingChain();
+    HttpServletRequest request = 
EasyMock.createNiceMock(HttpServletRequest.class);
+    HttpServletResponse response = 
EasyMock.createNiceMock(HttpServletResponse.class);
+    EasyMock.replay(request, response);
+    Subject.doAs(subjectToRun, (PrivilegedExceptionAction<Void>) () -> {
+      f.doFilter(request, response, chain);
+      return null;
+    });
+    return chain;
+  }
+
+  private static class SubjectCapturingChain implements FilterChain {
+    Subject subject;
+    boolean called;
+
+    @Override
+    public void doFilter(ServletRequest req, ServletResponse resp) {
+      called = true;
+      subject = SubjectUtils.getCurrentSubject();
+    }
+  }
+}
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 378c16a98..3202328ee 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
@@ -23,7 +23,6 @@ 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;
@@ -436,14 +435,20 @@ public class JWTFederationFilter extends 
AbstractJWTFilter {
   /**
    * 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>Validates the required subject_token and, when present, the optional 
actor_token.
+   * Builds a Subject carrying the appropriate principals and establishes the 
security
+   * context for downstream filters.</p>
    *
-   * <p>The TokenExchangePrincipal signals to the identity assertion layer that
+   * <p>When actor_token is present, the Subject has the actor as 
PrimaryPrincipal and a
+   * TokenExchangePrincipal that signals 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
+   * <p>When actor_token is absent, the Subject has the subject itself as 
PrimaryPrincipal
+   * with no TokenExchangePrincipal. RFC 8693 requires the actor token to be 
optional.
+   * Note that headless delegation using ImpersonatedPrincipal is currently 
not represented
+   * in this path.</p>
+   *
+   * @param request the HTTP request containing subject_token and optional 
actor_token parameters
    * @param response the HTTP response
    * @param chain the filter chain
    * @throws IOException if an I/O error occurs
@@ -459,13 +464,18 @@ public class JWTFederationFilter extends 
AbstractJWTFilter {
       return;
     }
 
-    // Extract actor_token (required for proper token exchange)
+    // actor_token is optional per RFC 8693 ยง2.1. When absent, the exchange is 
either
+    // a same-subject exchange (no delegation) or a headless delegation 
exchange where
+    // the actor is the subject itself and the target subject is in 
requested_subject.
+    // Downstream processing determines the exchange type from request 
parameters.
+    //
+    // If a future generic Knox topology uses grant_type=token-exchange for
+    // Hadoop-proxy delegation, headless delegation
+    // (actor_token absent, requested_subject != subject_token.sub) would also 
need
+    // a TokenExchangePrincipal so that AbstractIdentityAssertionFilter can 
set up
+    // Hadoop doAs impersonation. The filter would need to read 
requested_subject here
+    // and compare it to subject_token.sub to detect this case.
     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
@@ -476,13 +486,15 @@ public class JWTFederationFilter extends 
AbstractJWTFilter {
       }
 
       // Parse and validate actor_token
-      JWT actorToken = parseAndValidateJWT(request, response, chain, 
actorTokenValue);
-      if (actorToken == null) {
-        // Validation failed, error response already sent
-        return;
+      JWT actorToken = null;
+      if (actorTokenValue != null && !actorTokenValue.isEmpty()) {
+        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);
@@ -520,36 +532,44 @@ public class JWTFederationFilter extends 
AbstractJWTFilter {
   /**
    * 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
+   * <p>When actorToken is non-null (delegated exchange), the Subject has the 
actor as
+   * PrimaryPrincipal and a TokenExchangePrincipal carrying both subject and 
actor identities.
+   * The TokenExchangePrincipal signals the identity assertion layer to set up 
doAs
+   * impersonation with the subject as the delegated identity.</p>
+   *
+   * <p>When actorToken is null (same-subject or headless delegation 
exchange), the Subject
+   * has the subject itself as PrimaryPrincipal with no 
TokenExchangePrincipal, so the
+   * identity assertion layer performs no impersonation for this exchange.</p>
+   *
+   * <p>In both cases, if the subject_token carries an {@code act} claim, the 
delegation
+   * chain is preserved as an ActorChainPrincipal.</p>
+   *
+   * @param subjectToken the validated subject token (required)
+   * @param actorToken the validated actor token, or null if actor_token was 
not provided
+   * @return a Subject configured for the 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 (actorToken != null) {
+      // Delegated exchange: actor acts on behalf of subject.
+      // PrimaryPrincipal is the actor (the authenticated party performing the 
exchange).
+      // TokenExchangePrincipal carries both identities for the identity 
assertion layer.
+      String subjectIssuer = subjectToken.getIssuer();
+      String actorPrincipalName = actorToken.getSubject();
+      String actorIssuer = actorToken.getIssuer();
+      principals.add(new PrimaryPrincipal(actorPrincipalName));
+      principals.add(new TokenExchangePrincipalImpl(subjectPrincipalName, 
subjectIssuer, actorPrincipalName, actorIssuer));
+    } else {
+      // No actor_token: same-subject or headless delegation exchange.
+      // PrimaryPrincipal is the subject itself; no TokenExchangePrincipal is 
created,
+      // so the identity assertion layer does not set up doAs impersonation.
+      principals.add(new PrimaryPrincipal(subjectPrincipalName));
+    }
+
+    // Preserve the delegation chain from the subject_token act claim, if 
present.
+    List<Map<String, Object>> actorChain = 
TokenUtils.extractActorChain(subjectToken);
     if (!actorChain.isEmpty()) {
       principals.add(new ActorChainPrincipalImpl(actorChain));
     }
diff --git 
a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/JWTFederationFilterHandleTokenExchangeTest.java
 
b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/JWTFederationFilterHandleTokenExchangeTest.java
new file mode 100644
index 000000000..ca23a5ae7
--- /dev/null
+++ 
b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/JWTFederationFilterHandleTokenExchangeTest.java
@@ -0,0 +1,262 @@
+/*
+ * 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.crypto.RSASSASigner;
+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.security.ActorChainPrincipal;
+import org.apache.knox.gateway.security.CommonTokenConstants;
+import org.apache.knox.gateway.security.ImpersonatedPrincipal;
+import org.apache.knox.gateway.security.PrimaryPrincipal;
+import org.apache.knox.gateway.security.SubjectUtils;
+import org.apache.knox.gateway.security.TokenExchangePrincipal;
+import 
org.apache.knox.gateway.services.security.token.JWTokenAttributesBuilder;
+import org.apache.knox.gateway.services.security.token.impl.JWTToken;
+import org.easymock.EasyMock;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.security.Principal;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+
+import static 
org.apache.knox.gateway.provider.federation.jwt.filter.AbstractJWTFilter.JWT_DEFAULT_ISSUER;
+
+/**
+ * Unit tests for the {@link JWTFederationFilter#handleTokenExchange} method 
(OIDC
+ * delegation path). Each test verifies the Subject constructed.
+ *
+ * <p>These tests use {@link TestJWTFederationFilter} with {@link 
TestJWTokenAuthority} (static key,
+ * no mocking needed โ€” both tokens use Knox issuer {@code JWT_DEFAULT_ISSUER} 
which is in the
+ * static expected-issuers list).
+ *
+ * <p>The filter's {@code continueWithEstablishedSecurityContext} runs
+ * {@code Subject.doAs(subject, () -> chain.doFilter(request, response))}. The
+ * {@link AbstractJWTFilterTest.TestFilterChain} captures {@code 
SubjectUtils.getCurrentSubject()}
+ * from within that doAs context, which is exactly the Subject built. All 
principal
+ * assertions use {@code chain.subject.getPrincipals(XxxPrincipal.class)}.
+ */
+public class JWTFederationFilterHandleTokenExchangeTest extends 
AbstractJWTFilterTest {
+
+  static final String ACTOR_ISSUER = "https://actor.oidc.example.com";;
+
+  @Before
+  public void setUp() throws Exception {
+    handler = new TestJWTFederationFilter();
+    ((TestJWTFederationFilter) handler).setTokenService(new 
TestJWTokenAuthority(publicKey));
+    handler.init(new TestFilterConfig(getProperties()));
+  }
+
+  @Override
+  protected void setTokenOnRequest(HttpServletRequest request, SignedJWT jwt) {
+    EasyMock.expect(request.getHeader("Authorization"))
+        .andReturn(JWTFederationFilter.BEARER + jwt.serialize()).anyTimes();
+  }
+
+  @Override
+  protected void setGarbledTokenOnRequest(HttpServletRequest request, 
SignedJWT jwt) {
+    EasyMock.expect(request.getHeader("Authorization"))
+        .andReturn(JWTFederationFilter.BEARER + "ljm" + 
jwt.serialize()).anyTimes();
+  }
+
+  @Override
+  protected String getAudienceProperty() {
+    return JWTFederationFilter.KNOX_TOKEN_AUDIENCES;
+  }
+
+  @Override
+  protected String getVerificationPemProperty() {
+    return JWTFederationFilter.TOKEN_VERIFICATION_PEM;
+  }
+
+  /**
+   * When both subject_token and actor_token are present, the filter 
establishes the actor
+   * (from actor_token.sub) as the PrimaryPrincipal in the resulting Subject.
+   */
+  @Test
+  public void testActorAndSubjectTokensSetActorAsPrimaryPrincipal() throws 
Exception {
+    SignedJWT subjectJwt = getJWT(JWT_DEFAULT_ISSUER, "k8s-sa",
+        new Date(System.currentTimeMillis() + 60000), privateKey);
+    SignedJWT actorJwt = getJWT(JWT_DEFAULT_ISSUER, "actor-svc",
+        new Date(System.currentTimeMillis() + 60000), privateKey);
+
+    HttpServletRequest request = 
buildTokenExchangeRequest(subjectJwt.serialize(), actorJwt.serialize());
+    EasyMock.replay(request);
+    HttpServletResponse response = 
EasyMock.createNiceMock(HttpServletResponse.class);
+    EasyMock.replay(response);
+
+    TestFilterChain chain = new TestFilterChain();
+    handler.doFilter(request, response, chain);
+
+    Assert.assertTrue("doFilterCalled should be true", chain.doFilterCalled);
+    Set<PrimaryPrincipal> principals = 
chain.subject.getPrincipals(PrimaryPrincipal.class);
+    Assert.assertEquals("Expected exactly one PrimaryPrincipal", 1, 
principals.size());
+    Assert.assertEquals("Expected actor as PrimaryPrincipal", "actor-svc",
+        ((Principal) principals.toArray()[0]).getName());
+  }
+
+  /**
+   * When subject_token and actor_token have different issuers, the filter 
creates a
+   * TokenExchangePrincipal that carries the subject and actor identities with 
their respective
+   * issuers. Using different issuers ensures that all four TEP fields can be 
asserted
+   * unambiguously.
+   *
+   * <p>Both issuers are added to the static {@code jwt.expected.issuer} 
whitelist โ€”
+   * {@code TestJWTokenAuthority} accepts either token because they are signed 
with
+   * the same test key.
+   */
+  @Test
+  public void testActorAndSubjectTokensCreateTokenExchangePrincipal() throws 
Exception {
+    Properties props = getProperties();
+    props.setProperty(AbstractJWTFilter.JWT_EXPECTED_ISSUER, 
JWT_DEFAULT_ISSUER + "," + ACTOR_ISSUER);
+    handler.init(new TestFilterConfig(props));
+
+    SignedJWT subjectJwt = getJWT(JWT_DEFAULT_ISSUER, "k8s-sa",
+        new Date(System.currentTimeMillis() + 60000), privateKey);
+    SignedJWT actorJwt = getJWT(ACTOR_ISSUER, "actor-svc",
+        new Date(System.currentTimeMillis() + 60000), privateKey);
+
+    HttpServletRequest request = 
buildTokenExchangeRequest(subjectJwt.serialize(), actorJwt.serialize());
+    EasyMock.replay(request);
+    HttpServletResponse response = 
EasyMock.createNiceMock(HttpServletResponse.class);
+    EasyMock.replay(response);
+
+    TestFilterChain chain = new TestFilterChain();
+    handler.doFilter(request, response, chain);
+
+    Assert.assertTrue("doFilterCalled should be true", chain.doFilterCalled);
+    TokenExchangePrincipal tep = 
SubjectUtils.getTokenExchangePrincipal(chain.subject);
+    Assert.assertNotNull("TokenExchangePrincipal should be present", tep);
+    Assert.assertEquals("Subject principal name", "k8s-sa", 
tep.getSubjectPrincipalName());
+    Assert.assertEquals("Actor principal name", "actor-svc", 
tep.getActorPrincipalName());
+    Assert.assertEquals("Subject issuer", JWT_DEFAULT_ISSUER, 
tep.getSubjectIssuer());
+    Assert.assertEquals("Actor issuer", ACTOR_ISSUER, tep.getActorIssuer());
+  }
+
+  /**
+   * When subject_token carries an {@code act} claim (a prior delegation 
chain), the filter
+   * extracts it and creates an {@code ActorChainPrincipal} in the resulting 
Subject so that
+   * the delegation history is preserved through the filter pipeline.
+   */
+  @Test
+  public void testSubjectTokenWithActClaimCreatesActorChainPrincipal() throws 
Exception {
+    List<Map<String, Object>> actorChainData = List.of(Map.of("sub", 
"prior-actor"));
+    JWTToken subjectToken = new JWTToken(new JWTokenAttributesBuilder()
+        .setUserName("k8s-sa")
+        .setIssuer(JWT_DEFAULT_ISSUER)
+        .setAlgorithm("RS256")
+        .setExpires(System.currentTimeMillis() + 60000)
+        .setActorChain(actorChainData)
+        .build());
+    subjectToken.sign(new RSASSASigner(privateKey));
+
+    SignedJWT actorJwt = getJWT(JWT_DEFAULT_ISSUER, "actor-svc",
+        new Date(System.currentTimeMillis() + 60000), privateKey);
+
+    HttpServletRequest request = 
buildTokenExchangeRequest(subjectToken.toString(), actorJwt.serialize());
+    EasyMock.replay(request);
+    HttpServletResponse response = 
EasyMock.createNiceMock(HttpServletResponse.class);
+    EasyMock.replay(response);
+
+    TestFilterChain chain = new TestFilterChain();
+    handler.doFilter(request, response, chain);
+
+    Assert.assertTrue("doFilterCalled should be true", chain.doFilterCalled);
+    Set<ActorChainPrincipal> actorChainPrincipals = 
chain.subject.getPrincipals(ActorChainPrincipal.class);
+    Assert.assertFalse("ActorChainPrincipal should be present", 
actorChainPrincipals.isEmpty());
+    ActorChainPrincipal acp = actorChainPrincipals.iterator().next();
+    Assert.assertEquals("Expected current actor from act claim", 
"prior-actor", acp.getCurrentActor());
+  }
+
+  /**
+   * With no actor_token provided, the filter proceeds successfully and the 
resulting Subject
+   * has the subject itself as PrimaryPrincipal, no TokenExchangePrincipal, 
and no
+   * ImpersonatedPrincipal.
+   */
+  @Test
+  public void testSubjectTokenOnlySucceeds() throws Exception {
+    SignedJWT subjectJwt = getJWT(JWT_DEFAULT_ISSUER, "k8s-sa",
+        new Date(System.currentTimeMillis() + 60000), privateKey);
+
+    HttpServletRequest request = 
buildTokenExchangeRequestSubjectOnly(subjectJwt.serialize());
+    EasyMock.replay(request);
+    HttpServletResponse response = 
EasyMock.createNiceMock(HttpServletResponse.class);
+    EasyMock.replay(response);
+
+    TestFilterChain chain = new TestFilterChain();
+    handler.doFilter(request, response, chain);
+
+    Assert.assertTrue("doFilterCalled should be true", chain.doFilterCalled);
+    Set<PrimaryPrincipal> principals = 
chain.subject.getPrincipals(PrimaryPrincipal.class);
+    Assert.assertEquals("Expected exactly one PrimaryPrincipal", 1, 
principals.size());
+    Assert.assertEquals("Subject should be its own PrimaryPrincipal", "k8s-sa",
+        ((java.security.Principal) principals.toArray()[0]).getName());
+    Assert.assertNull("No TokenExchangePrincipal expected for subject-only 
exchange",
+        SubjectUtils.getTokenExchangePrincipal(chain.subject));
+    Assert.assertTrue("ImpersonatedPrincipal set should be empty",
+        chain.subject.getPrincipals(ImpersonatedPrincipal.class).isEmpty());
+  }
+
+  /**
+   * Builds a token-exchange request mock with both subject_token and 
actor_token parameters.
+   * The caller must call {@code EasyMock.replay(request)} before using the 
returned mock.
+   *
+   * @param subjectToken serialized subject JWT
+   * @param actorToken   serialized actor JWT
+   * @return a NiceMock HttpServletRequest configured for a token-exchange 
grant
+   */
+  private HttpServletRequest buildTokenExchangeRequest(String subjectToken, 
String actorToken) {
+    HttpServletRequest request = 
EasyMock.createNiceMock(HttpServletRequest.class);
+    EasyMock.expect(request.getRequestURL())
+        .andReturn(new StringBuffer(SERVICE_URL)).anyTimes();
+    EasyMock.expect(request.getParameter(CommonTokenConstants.GRANT_TYPE))
+        .andReturn(JWTFederationFilter.TOKEN_EXCHANGE).anyTimes();
+    EasyMock.expect(request.getParameter(JWTFederationFilter.SUBJECT_TOKEN))
+        .andReturn(subjectToken).anyTimes();
+    EasyMock.expect(request.getParameter(JWTFederationFilter.ACTOR_TOKEN))
+        .andReturn(actorToken).anyTimes();
+    return request;
+  }
+
+  /**
+   * Builds a token-exchange request mock with subject_token only. The 
actor_token parameter
+   * is not mocked, so the NiceMock returns null for {@code 
getParameter(ACTOR_TOKEN)}.
+   * The caller must call {@code EasyMock.replay(request)} before using the 
returned mock.
+   *
+   * @param subjectToken serialized subject JWT
+   * @return a NiceMock HttpServletRequest configured for a subject-only 
token-exchange grant
+   */
+  private HttpServletRequest buildTokenExchangeRequestSubjectOnly(String 
subjectToken) {
+    HttpServletRequest request = 
EasyMock.createNiceMock(HttpServletRequest.class);
+    EasyMock.expect(request.getRequestURL())
+        .andReturn(new StringBuffer(SERVICE_URL)).anyTimes();
+    EasyMock.expect(request.getParameter(CommonTokenConstants.GRANT_TYPE))
+        .andReturn(JWTFederationFilter.TOKEN_EXCHANGE).anyTimes();
+    EasyMock.expect(request.getParameter(JWTFederationFilter.SUBJECT_TOKEN))
+        .andReturn(subjectToken).anyTimes();
+    // ACTOR_TOKEN not mocked โ€” NiceMock returns null for 
getParameter(ACTOR_TOKEN)
+    return request;
+  }
+}

Reply via email to