This is an automated email from the ASF dual-hosted git repository. smolnar82 pushed a commit to branch knox_idf in repository https://gitbox.apache.org/repos/asf/knox.git
commit b6f674d42f9da75c3dec492755e3ef00ce57e9b6 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 | 2 + ...JWTFederationFilterHandleTokenExchangeTest.java | 262 +++++++++++++++++++ 3 files changed, 543 insertions(+) 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 30ab50603..f1abb686a 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 @@ -22,6 +22,8 @@ import org.apache.commons.lang3.tuple.Pair; import org.apache.knox.gateway.i18n.messages.MessagesFactory; import org.apache.knox.gateway.provider.federation.jwt.JWTMessages; import org.apache.knox.gateway.security.PrimaryPrincipal; +import org.apache.knox.gateway.security.TokenExchangePrincipalImpl; +import org.apache.knox.gateway.services.security.token.TokenUtils; import org.apache.knox.gateway.services.security.token.UnknownTokenException; import org.apache.knox.gateway.services.security.token.impl.JWT; import org.apache.knox.gateway.services.security.token.impl.JWTToken; 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; + } +}
