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