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 0000000000..f74ddd55f1 --- /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 + *

+ * 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.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}. + * + *

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. + * + *

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 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 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 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 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> 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 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) () -> { + 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 378c16a980..3202328ee7 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.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 @@ private boolean authenticateWithCookies(HttpServletRequest request, HttpServletR /** * Handle RFC 8693 token exchange flow. * - *

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.

+ *

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.

* - *

The TokenExchangePrincipal signals to the identity assertion layer that + *

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.

* - * @param request the HTTP request containing subject_token and actor_token parameters + *

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.

+ * + * @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 @@ private void handleTokenExchange(HttpServletRequest request, HttpServletResponse 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 @@ private void handleTokenExchange(HttpServletRequest request, HttpServletResponse } // 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 @@ private JWT parseAndValidateJWT(HttpServletRequest request, HttpServletResponse /** * 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 + *

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.

+ * + *

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.

+ * + *

In both cases, if the subject_token carries an {@code act} claim, the delegation + * chain is preserved as an ActorChainPrincipal.

+ * + * @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> actorChain = - TokenUtils.extractActorChain(subjectToken); - - // Create Subject with all necessary principals Set 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> 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 0000000000..ca23a5ae70 --- /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 + *

+ * 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; + +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. + * + *

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). + * + *

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 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. + * + *

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