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 2e1f178065325c2e70e8df984c37484d68da095b Author: lmccay <[email protected]> AuthorDate: Fri Jun 12 14:43:20 2026 -0400 KNOX-3334 - Introduce ActorChainPrincipal for RFC 8693 instead of ImpersonatedPrincipal (#1257) * KNOX-3334 - Introduce ActorChainPrincipal for RFC 8693 instead of ImpersonatedPrincipal (cherry picked from commit 0c8838511f2918b6b13b8293f4db04febe2f5fa0) --- .../filter/AbstractIdentityAssertionFilter.java | 8 +- .../federation/jwt/filter/AbstractJWTFilter.java | 21 +- .../gateway/service/knoxtoken/TokenResource.java | 41 +++- .../gateway/service/knoxtoken/ActorChainTest.java | 154 +++++++++++++ .../knox/gateway/security/ActorChainPrincipal.java | 85 +++++++ .../gateway/security/ActorChainPrincipalImpl.java | 95 ++++++++ .../apache/knox/gateway/security/SubjectUtils.java | 4 + .../services/security/token/JWTokenAttributes.java | 39 +++- .../security/token/JWTokenAttributesBuilder.java | 34 ++- .../services/security/token/TokenUtils.java | 132 ++++++++++- .../services/security/token/impl/JWTToken.java | 16 +- .../services/security/token/TokenUtilsTest.java | 244 +++++++++++++++++++++ 12 files changed, 837 insertions(+), 36 deletions(-) diff --git a/gateway-provider-identity-assertion-common/src/main/java/org/apache/knox/gateway/identityasserter/common/filter/AbstractIdentityAssertionFilter.java b/gateway-provider-identity-assertion-common/src/main/java/org/apache/knox/gateway/identityasserter/common/filter/AbstractIdentityAssertionFilter.java index 2b4de329d..4c212a534 100644 --- a/gateway-provider-identity-assertion-common/src/main/java/org/apache/knox/gateway/identityasserter/common/filter/AbstractIdentityAssertionFilter.java +++ b/gateway-provider-identity-assertion-common/src/main/java/org/apache/knox/gateway/identityasserter/common/filter/AbstractIdentityAssertionFilter.java @@ -46,6 +46,7 @@ import org.apache.knox.gateway.filter.security.AbstractIdentityAssertionBase; import org.apache.knox.gateway.i18n.GatewaySpiResources; import org.apache.knox.gateway.i18n.messages.MessagesFactory; import org.apache.knox.gateway.i18n.resources.ResourcesFactory; +import org.apache.knox.gateway.security.ActorChainPrincipal; import org.apache.knox.gateway.security.GroupPrincipal; import org.apache.knox.gateway.security.ImpersonatedPrincipal; import org.apache.knox.gateway.security.PrimaryPrincipal; @@ -147,6 +148,11 @@ public abstract class AbstractIdentityAssertionFilter extends final Set<TokenIdPrincipal> tokenIdPrincipals = SubjectUtils.getTokenIdPrincipals(currentSubject); subject.getPrincipals().addAll(tokenIdPrincipals); + // RFC 8693 Token Exchange: Preserve ActorChainPrincipal from the current subject + // This ensures the delegation chain is maintained through identity assertion + final Set<ActorChainPrincipal> actorChainPrincipals = SubjectUtils.getActorChainPrincipal(currentSubject, subject); + subject.getPrincipals().addAll(actorChainPrincipals); + doAs(request, response, chain, subject); } else { @@ -154,7 +160,7 @@ public abstract class AbstractIdentityAssertionFilter extends } } - private void doAs(final ServletRequest request, final ServletResponse response, final FilterChain chain, Subject subject) + private void doAs(final ServletRequest request, final ServletResponse response, final FilterChain chain, Subject subject) throws IOException, ServletException { try { Subject.doAs( diff --git a/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/AbstractJWTFilter.java b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/AbstractJWTFilter.java index f3963c523..de4987caa 100644 --- a/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/AbstractJWTFilter.java +++ b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/AbstractJWTFilter.java @@ -35,6 +35,7 @@ import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -67,6 +68,7 @@ import org.apache.knox.gateway.config.GatewayConfig; import org.apache.knox.gateway.filter.AbstractGatewayFilter; 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.SubjectUtils; import org.apache.knox.gateway.security.TokenIdPrincipal; @@ -386,6 +388,8 @@ public abstract class AbstractJWTFilter implements Filter { if (expectedPrincipalClaim != null) { claimvalue = token.getClaim(expectedPrincipalClaim); } + // Extract actor chain from the JWT token if present (RFC 8693) + List<Map<String, Object>> actorChain = TokenUtils.extractActorChain(token); // The newly constructed Sets check whether this Subject has been set read-only // before permitting subsequent modifications. The newly created Sets also prevent // illegal modifications by ensuring that callers have sufficient permissions. @@ -393,7 +397,7 @@ public abstract class AbstractJWTFilter implements Filter { // To modify the Principals Set, the caller must have AuthPermission("modifyPrincipals"). // To modify the public credential Set, the caller must have AuthPermission("modifyPublicCredentials"). // To modify the private credential Set, the caller must have AuthPermission("modifyPrivateCredentials"). - return createSubjectFromTokenData(principal, claimvalue); + return createSubjectFromTokenData(principal, claimvalue, null, actorChain); } public Subject createSubjectFromTokenIdentifier(final String tokenId) throws UnknownTokenException { @@ -419,11 +423,19 @@ public abstract class AbstractJWTFilter implements Filter { @SuppressWarnings("rawtypes") protected Subject createSubjectFromTokenData(final String principal, final String expectedPrincipalClaimValue) { - return createSubjectFromTokenData(principal, expectedPrincipalClaimValue, null); + return createSubjectFromTokenData(principal, expectedPrincipalClaimValue, null, null); } @SuppressWarnings("rawtypes") protected Subject createSubjectFromTokenData(final String principal, final String expectedPrincipalClaimValue, final String tokenId) { + return createSubjectFromTokenData(principal, expectedPrincipalClaimValue, tokenId, null); + } + + @SuppressWarnings("rawtypes") + protected Subject createSubjectFromTokenData(final String principal, + final String expectedPrincipalClaimValue, + final String tokenId, + final List<Map<String, Object>> actorChain) { String claimValue = (expectedPrincipalClaimValue != null) ? expectedPrincipalClaimValue.toLowerCase(Locale.ROOT) : null; @@ -435,6 +447,11 @@ public abstract class AbstractJWTFilter implements Filter { principals.add(new TokenIdPrincipal(tokenId)); } + // Add ActorChainPrincipal if an actor chain is present (RFC 8693 token exchange) + if (actorChain != null && !actorChain.isEmpty()) { + principals.add(new ActorChainPrincipalImpl(actorChain)); + } + // The newly constructed Sets check whether this Subject has been set read-only // before permitting subsequent modifications. The newly created Sets also prevent // illegal modifications by ensuring that callers have sufficient permissions. diff --git a/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/TokenResource.java b/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/TokenResource.java index 969eb4543..56ab3b3f8 100644 --- a/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/TokenResource.java +++ b/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/TokenResource.java @@ -70,6 +70,7 @@ import org.apache.commons.lang3.StringUtils; import org.apache.knox.gateway.config.GatewayConfig; import org.apache.knox.gateway.context.ContextAttributes; import org.apache.knox.gateway.i18n.messages.MessagesFactory; +import org.apache.knox.gateway.security.ActorChainPrincipal; import org.apache.knox.gateway.security.GroupPrincipal; import org.apache.knox.gateway.security.SubjectUtils; import org.apache.knox.gateway.security.TokenIdPrincipal; @@ -1125,16 +1126,8 @@ public class TokenResource { jwtAttributesBuilder.setClientId(tokenIdPrincipals.iterator().next().getName()); } - // RFC 8693 Token Exchange: Add the "act" claim if delegated auth is enabled and impersonation occurred - if (enableDelegatedAuth && SubjectUtils.isImpersonating(subject)) { - String primaryPrincipalName = SubjectUtils.getPrimaryPrincipalName(subject); - String impersonatedPrincipalName = SubjectUtils.getImpersonatedPrincipalName(subject); - if (primaryPrincipalName != null && impersonatedPrincipalName != null && !primaryPrincipalName.equals(impersonatedPrincipalName)) { - // The primary principal (the one doing the impersonation) becomes the actor - jwtAttributesBuilder.setActor(primaryPrincipalName); - log.addingActorClaimToToken(primaryPrincipalName, impersonatedPrincipalName); - } - } + // RFC 8693 Token Exchange: Build the actor chain if delegated auth is enabled + handleDelegatedAuthentication(subject, jwtAttributesBuilder); } if (userContext.userParams != null) { @@ -1150,6 +1143,34 @@ public class TokenResource { return token; } + private void handleDelegatedAuthentication(Subject subject, JWTokenAttributesBuilder jwtAttributesBuilder) { + if (enableDelegatedAuth) { + // First check if there's an existing actor chain from a previous token exchange + Set<ActorChainPrincipal> actorChainPrincipals = subject.getPrincipals(ActorChainPrincipal.class); + List<Map<String, Object>> existingChain = null; + if (!actorChainPrincipals.isEmpty()) { + existingChain = actorChainPrincipals.iterator().next().getActorChain(); + log.generalInfoMessage("Found existing actor chain with " + existingChain.size() + " actors"); + } + + // Check if impersonation is occurring to add a new actor to the chain + if (SubjectUtils.isImpersonating(subject)) { + String primaryPrincipalName = SubjectUtils.getPrimaryPrincipalName(subject); + String impersonatedPrincipalName = SubjectUtils.getImpersonatedPrincipalName(subject); + if (primaryPrincipalName != null && impersonatedPrincipalName != null && !primaryPrincipalName.equals(impersonatedPrincipalName)) { + // Build the new actor chain by adding the current actor (primary principal) to the existing chain + List<Map<String, Object>> newActorChain = TokenUtils.addActorToChain(existingChain, primaryPrincipalName); + jwtAttributesBuilder.setActorChain(newActorChain); + log.addingActorClaimToToken(primaryPrincipalName, impersonatedPrincipalName); + } + } else if (existingChain != null && !existingChain.isEmpty()) { + // No new impersonation, but preserve existing actor chain + jwtAttributesBuilder.setActorChain(existingChain); + log.generalInfoMessage("Preserving existing actor chain without adding new actor"); + } + } + } + private boolean shouldIncludeGroups() { return Boolean.parseBoolean(request.getParameter(KNOX_TOKEN_INCLUDE_GROUPS)); } diff --git a/gateway-service-knoxtoken/src/test/java/org/apache/knox/gateway/service/knoxtoken/ActorChainTest.java b/gateway-service-knoxtoken/src/test/java/org/apache/knox/gateway/service/knoxtoken/ActorChainTest.java new file mode 100644 index 000000000..22467696d --- /dev/null +++ b/gateway-service-knoxtoken/src/test/java/org/apache/knox/gateway/service/knoxtoken/ActorChainTest.java @@ -0,0 +1,154 @@ +/* + * 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.service.knoxtoken; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.text.ParseException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.apache.knox.gateway.security.ActorChainPrincipal; +import org.apache.knox.gateway.security.ActorChainPrincipalImpl; +import org.apache.knox.gateway.services.security.token.JWTokenAttributesBuilder; +import org.apache.knox.gateway.services.security.token.TokenUtils; +import org.apache.knox.gateway.services.security.token.impl.JWT; +import org.apache.knox.gateway.services.security.token.impl.JWTToken; +import org.junit.Test; + +/** + * Test class to verify RFC 8693 actor chain functionality end-to-end. + */ +public class ActorChainTest { + + @Test + public void testActorChainPreservation() throws Exception { + // Step 1: Create initial token with actor chain (simulating token from previous exchange) + List<Map<String, Object>> initialChain = new ArrayList<>(); + + Map<String, Object> actor1 = new LinkedHashMap<>(); + actor1.put("sub", "service-a"); + actor1.put("iss", "https://issuer.example.com"); + initialChain.add(actor1); + + Map<String, Object> actor2 = new LinkedHashMap<>(); + actor2.put("sub", "service-b"); + initialChain.add(actor2); + + JWTokenAttributesBuilder builder1 = new JWTokenAttributesBuilder(); + builder1.setUserName("end-user") + .setAlgorithm("RS256") + .setExpires(System.currentTimeMillis() + 30000) + .setActorChain(initialChain); + + JWTToken token1 = new JWTToken(builder1.build()); + + // Step 2: Extract actor chain from token (simulating JWT validation) + List<Map<String, Object>> extractedChain = TokenUtils.extractActorChain(token1); + assertNotNull("Extracted chain should not be null", extractedChain); + assertEquals("Should have 2 actors", 2, extractedChain.size()); + assertEquals("First actor should be service-a", "service-a", extractedChain.get(0).get("sub")); + assertEquals("Second actor should be service-b", "service-b", extractedChain.get(1).get("sub")); + + // Step 3: Create ActorChainPrincipal (simulating what AbstractJWTFilter does) + ActorChainPrincipal actorChainPrincipal = new ActorChainPrincipalImpl(extractedChain); + assertEquals("Current actor should be service-a", "service-a", actorChainPrincipal.getCurrentActor()); + assertEquals("Original delegator should be service-b", "service-b", actorChainPrincipal.getOriginalDelegator()); + + // Step 4: Add new actor to chain (simulating what TokenResource does) + String newActor = "service-c"; + List<Map<String, Object>> newChain = TokenUtils.addActorToChain(extractedChain, newActor); + assertEquals("Should have 3 actors", 3, newChain.size()); + assertEquals("New actor should be first", newActor, newChain.get(0).get("sub")); + assertEquals("Previous first actor should be second", "service-a", newChain.get(1).get("sub")); + assertEquals("Previous second actor should be third", "service-b", newChain.get(2).get("sub")); + + // Step 5: Create new token with extended chain + JWTokenAttributesBuilder builder2 = new JWTokenAttributesBuilder(); + builder2.setUserName("end-user") + .setAlgorithm("RS256") + .setExpires(System.currentTimeMillis() + 30000) + .setActorChain(newChain); + + JWTToken token2 = new JWTToken(builder2.build()); + + // Step 6: Verify the new token has the complete chain + List<Map<String, Object>> finalChain = TokenUtils.extractActorChain(token2); + assertNotNull("Final chain should not be null", finalChain); + assertEquals("Should have 3 actors in final token", 3, finalChain.size()); + assertEquals("First actor should be service-c", "service-c", finalChain.get(0).get("sub")); + assertEquals("Second actor should be service-a", "service-a", finalChain.get(1).get("sub")); + assertEquals("Third actor should be service-b", "service-b", finalChain.get(2).get("sub")); + + // Verify issuer is preserved for actor1 + assertEquals("Issuer should be preserved", "https://issuer.example.com", finalChain.get(1).get("iss")); + } + + @Test + public void testActorChainInNestedStructure() throws ParseException { + // Create a token with nested actor structure + List<Map<String, Object>> chain = new ArrayList<>(); + + Map<String, Object> actor1 = new LinkedHashMap<>(); + actor1.put("sub", "actor1"); + chain.add(actor1); + + Map<String, Object> actor2 = new LinkedHashMap<>(); + actor2.put("sub", "actor2"); + chain.add(actor2); + + Map<String, Object> actor3 = new LinkedHashMap<>(); + actor3.put("sub", "actor3"); + chain.add(actor3); + + JWTokenAttributesBuilder builder = new JWTokenAttributesBuilder(); + builder.setUserName("testuser") + .setAlgorithm("RS256") + .setExpires(System.currentTimeMillis() + 30000) + .setActorChain(chain); + + JWT token = new JWTToken(builder.build()); + + // Verify the act claim is present + Object actClaim = token.getClaimAsObject(JWTToken.ACT_CLAIM); + assertNotNull("Act claim should be present", actClaim); + assertTrue("Act claim should be a Map", actClaim instanceof Map); + + // Verify nested structure + Map<?, ?> level1 = (Map<?, ?>) actClaim; + assertEquals("Level 1 sub should be actor1", "actor1", level1.get("sub")); + + Object level2Act = level1.get(JWTToken.ACT_CLAIM); + assertNotNull("Level 2 act should be present", level2Act); + assertTrue("Level 2 act should be a Map", level2Act instanceof Map); + + Map<?, ?> level2 = (Map<?, ?>) level2Act; + assertEquals("Level 2 sub should be actor2", "actor2", level2.get("sub")); + + Object level3Act = level2.get(JWTToken.ACT_CLAIM); + assertNotNull("Level 3 act should be present", level3Act); + assertTrue("Level 3 act should be a Map", level3Act instanceof Map); + + Map<?, ?> level3 = (Map<?, ?>) level3Act; + assertEquals("Level 3 sub should be actor3", "actor3", level3.get("sub")); + } +} diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/security/ActorChainPrincipal.java b/gateway-spi/src/main/java/org/apache/knox/gateway/security/ActorChainPrincipal.java new file mode 100644 index 000000000..3d2d99fef --- /dev/null +++ b/gateway-spi/src/main/java/org/apache/knox/gateway/security/ActorChainPrincipal.java @@ -0,0 +1,85 @@ +/* + * 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.security; + +import java.security.Principal; +import java.util.List; +import java.util.Map; + +/** + * A Principal that represents the chain of actors (delegation chain) from RFC 8693 token exchange. + * + * <p>This principal is used to represent the 'act' claim chain from a JWT, which provides + * a means to express that delegation has occurred and identify the acting parties to whom + * authority has been delegated.</p> + * + * <p>The actor chain is ordered from most recent (current actor) to oldest (original delegator). + * Each actor in the chain is represented as a Map containing identity claims. According to + * RFC 8693 Section 4.1:</p> + * <ul> + * <li>Identity claims such as 'sub' (subject) and 'iss' (issuer) should be used to identify actors</li> + * <li>The combination of 'iss' and 'sub' may be necessary to uniquely identify an actor</li> + * <li>Non-identity claims (e.g., 'exp', 'nbf', 'aud') are NOT meaningful within 'act' claims + * and should not be used</li> + * </ul> + * + * <p>Example chain structure:</p> + * <pre> + * [ + * {"sub": "service-c", "iss": "https://issuer.example.com"}, // Most recent actor + * {"sub": "service-b", "iss": "https://issuer.example.com"}, // Previous actor + * {"sub": "service-a"} // Original delegator + * ] + * </pre> + * + * @see <a href="https://datatracker.ietf.org/doc/html/rfc8693#section-4.1">RFC 8693 Section 4.1 - Actor Claim</a> + */ +public interface ActorChainPrincipal extends Principal { + + /** + * Returns the chain of actors in order from most recent to oldest. + * + * <p>Each Map in the list represents an actor's claims, with at minimum a 'sub' claim. + * The first element in the list is the most recent actor (the one who directly + * performed the current delegation), and the last element is the original delegator.</p> + * + * @return an immutable list of actor claim maps, never null but may be empty + */ + List<Map<String, Object>> getActorChain(); + + /** + * Returns the subject (identity) of the most recent actor in the chain. + * + * <p>This is equivalent to calling {@code getActorChain().get(0).get("sub")} + * if the chain is not empty.</p> + * + * @return the subject of the most recent actor, or null if the chain is empty + */ + String getCurrentActor(); + + /** + * Returns the subject (identity) of the original delegator (the first actor in the chain). + * + * <p>This is the actor who initiated the delegation chain. It is equivalent to calling + * {@code getActorChain().get(getActorChain().size() - 1).get("sub")} + * if the chain is not empty.</p> + * + * @return the subject of the original delegator, or null if the chain is empty + */ + String getOriginalDelegator(); +} diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/security/ActorChainPrincipalImpl.java b/gateway-spi/src/main/java/org/apache/knox/gateway/security/ActorChainPrincipalImpl.java new file mode 100644 index 000000000..36989ad94 --- /dev/null +++ b/gateway-spi/src/main/java/org/apache/knox/gateway/security/ActorChainPrincipalImpl.java @@ -0,0 +1,95 @@ +/* + * 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.security; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Implementation of ActorChainPrincipal that holds the RFC 8693 actor delegation chain. + */ +public class ActorChainPrincipalImpl implements ActorChainPrincipal { + private final List<Map<String, Object>> actorChain; + + /** + * Creates a new ActorChainPrincipal with the specified actor chain. + * + * @param actorChain the list of actor claim maps, ordered from most recent to oldest + */ + public ActorChainPrincipalImpl(List<Map<String, Object>> actorChain) { + this.actorChain = actorChain == null + ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList<>(actorChain)); + } + + @Override + public List<Map<String, Object>> getActorChain() { + return actorChain; + } + + @Override + public String getCurrentActor() { + if (actorChain.isEmpty()) { + return null; + } + Object sub = actorChain.get(0).get("sub"); + return sub != null ? sub.toString() : null; + } + + @Override + public String getOriginalDelegator() { + if (actorChain.isEmpty()) { + return null; + } + Object sub = actorChain.get(actorChain.size() - 1).get("sub"); + return sub != null ? sub.toString() : null; + } + + @Override + public String getName() { + // Return the current actor's identity as the principal name + String currentActor = getCurrentActor(); + return currentActor != null ? currentActor : ""; + } + + @Override + public String toString() { + return "ActorChainPrincipal[actors=" + actorChain.size() + ", current=" + getCurrentActor() + "]"; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (!(o instanceof ActorChainPrincipal)) { + return false; + } + + ActorChainPrincipal that = (ActorChainPrincipal) o; + return actorChain.equals(that.getActorChain()); + } + + @Override + public int hashCode() { + return actorChain.hashCode(); + } +} diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/security/SubjectUtils.java b/gateway-spi/src/main/java/org/apache/knox/gateway/security/SubjectUtils.java index 27cfaea29..11db9113f 100644 --- a/gateway-spi/src/main/java/org/apache/knox/gateway/security/SubjectUtils.java +++ b/gateway-spi/src/main/java/org/apache/knox/gateway/security/SubjectUtils.java @@ -101,4 +101,8 @@ public class SubjectUtils { public static Set<TokenIdPrincipal> getTokenIdPrincipals(Subject subject) { return subject.getPrincipals(TokenIdPrincipal.class); } + + public static Set<ActorChainPrincipal> getActorChainPrincipal(Subject currentSubject, Subject subject) { + return currentSubject.getPrincipals(ActorChainPrincipal.class); + } } diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/JWTokenAttributes.java b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/JWTokenAttributes.java index 7eb890e29..6e2f9350e 100644 --- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/JWTokenAttributes.java +++ b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/JWTokenAttributes.java @@ -42,11 +42,12 @@ public class JWTokenAttributes { private final String issuer; private String kid; private final String clientId; - private final String actor; - private final Map<String, Object> customAttributes; + private final List<Map<String, Object>> actorChain; + private final Map<String, Object> customAttributes; JWTokenAttributes(String userName, List<String> audiences, String algorithm, long issueTime, long expires, String signingKeystoreName, String signingKeystoreAlias, - char[] signingKeystorePassphrase, boolean managed, String jku, String type, Set<String> groups, String kid, String issuer, String clientId, String actor, Map<String, Object> customAttributes) { + char[] signingKeystorePassphrase, boolean managed, String jku, String type, Set<String> groups, String kid, String issuer, String clientId, List<Map<String, Object>> actorChain, + Map<String, Object> customAttributes) { this.userName = userName; this.audiences = audiences; this.algorithm = algorithm; @@ -62,7 +63,7 @@ public class JWTokenAttributes { this.kid = kid; this.issuer = issuer; this.clientId = clientId; - this.actor = actor; + this.actorChain = actorChain; this.customAttributes = customAttributes; } public String getUserName() { @@ -141,11 +142,31 @@ public class JWTokenAttributes { return clientId; } - public String getActor() { - return actor; + /** + * Get the actor chain for RFC 8693 token exchange. + * + * <p>The actor chain represents the complete delegation history for this token. + * Each element in the list is a Map of identity claims (such as 'sub' and 'iss') + * that identify an actor in the delegation chain. The list is ordered from most + * recent actor (first element) to oldest actor (last element).</p> + * + * <p>According to RFC 8693 Section 4.1, identity claims within the 'act' claim + * identify the actor. Common claims include:</p> + * <ul> + * <li>'sub' - the subject/identity of the actor</li> + * <li>'iss' - the issuer of the actor's identity</li> + * </ul> + * + * <p>Non-identity claims (e.g., 'exp', 'nbf', 'aud') should NOT be used within + * actor claims as they are not relevant to the validity of the containing JWT.</p> + * + * @return the actor chain, or null if no delegation has occurred + */ + public List<Map<String, Object>> getActorChain() { + return actorChain; } - public Map<String, Object> getCustomAttributes() { - return customAttributes; - } + public Map<String, Object> getCustomAttributes() { + return customAttributes; + } } diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/JWTokenAttributesBuilder.java b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/JWTokenAttributesBuilder.java index 6c2e4ab76..c7bf1adb4 100644 --- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/JWTokenAttributesBuilder.java +++ b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/JWTokenAttributesBuilder.java @@ -40,7 +40,7 @@ public class JWTokenAttributesBuilder { private String kid; private String issuer = JWTokenAttributes.DEFAULT_ISSUER; private String clientId; - private String actor; + private List<Map<String, Object>> actorChain; private Map<String, Object> customAttributes; public JWTokenAttributesBuilder setUserName(String userName) { @@ -122,8 +122,32 @@ public class JWTokenAttributesBuilder { return this; } - public JWTokenAttributesBuilder setActor(String actor) { - this.actor = actor; + /** + * Set the actor chain for RFC 8693 token exchange. + * + * <p>The actor chain represents the complete delegation history. Each element in the list + * is a Map of identity claims that identify an actor. The list should be ordered from most + * recent actor (first element) to oldest actor (last element).</p> + * + * <p>According to RFC 8693 Section 4.1, each actor should be identified using identity claims + * such as 'sub' (subject) and 'iss' (issuer). Non-identity claims (e.g., 'exp', 'nbf', 'aud') + * should NOT be included.</p> + * + * <p>Example:</p> + * <pre> + * List<Map<String, Object>> chain = new ArrayList<>(); + * Map<String, Object> actor1 = new HashMap<>(); + * actor1.put("sub", "service-a"); + * actor1.put("iss", "https://issuer.example.com"); + * chain.add(actor1); + * builder.setActorChain(chain); + * </pre> + * + * @param actorChain the actor chain, ordered from most recent to oldest + * @return this builder + */ + public JWTokenAttributesBuilder setActorChain(List<Map<String, Object>> actorChain) { + this.actorChain = actorChain; return this; } @@ -133,7 +157,7 @@ public class JWTokenAttributesBuilder { } public JWTokenAttributes build() { - return new JWTokenAttributes(userName, (audiences == null ? new ArrayList<>() : audiences), algorithm, issueTime, expires, signingKeystoreName, signingKeystoreAlias, - signingKeystorePassphrase, managed, jku, type, groups, kid, issuer, clientId, actor, customAttributes); + return new JWTokenAttributes(userName, (audiences == null ? new ArrayList<>() : audiences), algorithm, issueTime, expires, signingKeystoreName, signingKeystoreAlias, + signingKeystorePassphrase, managed, jku, type, groups, kid, issuer, clientId, actorChain, customAttributes); } } diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenUtils.java b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenUtils.java index 73d84f96b..5709888a8 100644 --- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenUtils.java +++ b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenUtils.java @@ -31,8 +31,12 @@ import javax.servlet.FilterConfig; import javax.servlet.ServletContext; import java.security.interfaces.RSAPublicKey; import java.text.ParseException; -import java.util.LinkedHashMap; import java.util.UUID; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; public class TokenUtils { public static final String ATTR_CURRENT_KNOXSSO_COOKIE_TOKEN_ID = "currentKnoxSsoCookieTokenId"; @@ -131,4 +135,130 @@ public class TokenUtils { return hmacSecret != null && StringUtils.isBlank(signingKeystoreName); } + /** + * Extract the actor chain from an RFC 8693 'act' claim in a JWT token. + * + * <p>The 'act' claim in RFC 8693 represents a delegation chain where each actor + * delegated authority to the next. The claim is structured as a nested JSON object, + * where each level contains identity claims (such as 'sub' and 'iss') and potentially + * another nested 'act' claim.</p> + * + * <p>According to RFC 8693 Section 4.1, the 'act' claim contains identity claims that + * identify the actor. Common identity claims include:</p> + * <ul> + * <li>'sub' - the subject/identity of the actor</li> + * <li>'iss' - the issuer of the actor's identity</li> + * </ul> + * + * <p>Non-identity claims (e.g., 'exp', 'nbf', 'aud') are not relevant to the validity + * of the containing JWT and should not be used within 'act' claims.</p> + * + * <p>Example JWT 'act' claim structure:</p> + * <pre> + * { + * "sub": "service-a", + * "iss": "https://issuer.example.com", + * "act": { + * "sub": "service-b", + * "act": { + * "sub": "service-c" + * } + * } + * } + * </pre> + * + * <p>This method flattens this nested structure into a list ordered from most recent + * actor (service-a) to oldest (service-c).</p> + * + * @param token The JWT token to extract the actor chain from + * @return A list of actor claim maps, ordered from most recent to oldest; empty list if no 'act' claim exists + */ + @SuppressWarnings("unchecked") + public static List<Map<String, Object>> extractActorChain(JWT token) { + Object currentAct = token == null ? null : token.getClaimAsObject(JWTToken.ACT_CLAIM); + final List<Map<String, Object>> actorChain = new ArrayList<>(); + + while (currentAct instanceof Map<?, ?> actorMap) { + actorChain.add(new LinkedHashMap<>((Map<String, Object>) actorMap)); + currentAct = actorMap.get(JWTToken.ACT_CLAIM); + } + + return Collections.unmodifiableList(actorChain); + } + + /** + * Build a new actor chain by adding a new actor to an existing chain. + * + * <p>This creates a new list with the new actor as the first element (most recent), + * followed by all actors from the existing chain.</p> + * + * @param existingChain The existing actor chain (may be null or empty) + * @param newActor The new actor to add to the chain + * @return A new immutable list with the complete actor chain + */ + public static List<Map<String, Object>> addActorToChain(List<Map<String, Object>> existingChain, String newActor) { + if (newActor == null || newActor.isEmpty()) { + return existingChain == null ? Collections.emptyList() : existingChain; + } + + List<Map<String, Object>> newChain = new ArrayList<>(); + + // Add the new actor as the first element + Map<String, Object> newActorClaim = new LinkedHashMap<>(); + newActorClaim.put("sub", newActor); + newChain.add(newActorClaim); + + // Add all existing actors + if (existingChain != null && !existingChain.isEmpty()) { + newChain.addAll(existingChain); + } + + return Collections.unmodifiableList(newChain); + } + + /** + * Convert an actor chain list into the nested structure required for the JWT 'act' claim. + * + * <p>This method takes a flat list of actor claim maps (ordered from most recent to oldest) + * and converts it into the nested structure required by RFC 8693.</p> + * + * <p>Example transformation:</p> + * <pre> + * Input: [{"sub": "actor1"}, {"sub": "actor2"}, {"sub": "actor3"}] + * Output: { + * "sub": "actor1", + * "act": { + * "sub": "actor2", + * "act": { + * "sub": "actor3" + * } + * } + * } + * </pre> + * + * @param actorChain The flat list of actor claim maps, ordered from most recent to oldest + * @return The nested structure for the 'act' claim, or null if the chain is empty + */ + @SuppressWarnings("unchecked") + public static Map<String, Object> buildNestedActClaim(List<Map<String, Object>> actorChain) { + if (actorChain == null || actorChain.isEmpty()) { + return null; + } + + // Start from the last actor (oldest) and work backwards + Map<String, Object> nestedAct = null; + for (int i = actorChain.size() - 1; i >= 0; i--) { + Map<String, Object> currentActor = new LinkedHashMap<>(actorChain.get(i)); + + if (nestedAct != null) { + // Add the previously built nested structure as the 'act' claim + currentActor.put(JWTToken.ACT_CLAIM, nestedAct); + } + + nestedAct = currentActor; + } + + return nestedAct; + } + } diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/impl/JWTToken.java b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/impl/JWTToken.java index 2b779ab66..78ad648f0 100644 --- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/impl/JWTToken.java +++ b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/impl/JWTToken.java @@ -20,6 +20,7 @@ import java.net.URISyntaxException; import java.text.ParseException; import java.time.Instant; import java.util.Date; +import java.util.Map; import java.util.UUID; import com.nimbusds.jose.JOSEException; @@ -34,6 +35,7 @@ import com.nimbusds.jwt.JWTClaimsSet; import com.nimbusds.jwt.SignedJWT; import org.apache.knox.gateway.i18n.messages.MessagesFactory; import org.apache.knox.gateway.services.security.token.JWTokenAttributes; +import org.apache.knox.gateway.services.security.token.TokenUtils; public class JWTToken implements JWT { private static JWTProviderMessages log = MessagesFactory.get( JWTProviderMessages.class ); @@ -102,14 +104,12 @@ public class JWTToken implements JWT { if (jwtAttributes.getClientId() != null) { builder.claim(CLIENT_ID_CLAIM, jwtAttributes.getClientId()); } - if (jwtAttributes.getActor() != null) { - // RFC 8693 Token Exchange: The "act" (actor) claim provides a means within a JWT to express - // that delegation has occurred and identify the acting party to whom authority has been delegated. - // The act claim value is a JSON object containing a "sub" claim with the identity of the actor. - JWTClaimsSet actClaims = new JWTClaimsSet.Builder() - .subject(jwtAttributes.getActor()) - .build(); - builder.claim(ACT_CLAIM, actClaims.toJSONObject()); + // RFC 8693 Token Exchange: The "act" (actor) claim provides a means within a JWT to express + // that delegation has occurred and identify the acting party to whom authority has been delegated. + // The actor chain is converted to the nested structure required by RFC 8693. + Map<String, Object> nestedAct = TokenUtils.buildNestedActClaim(jwtAttributes.getActorChain()); + if (nestedAct != null) { + builder.claim(ACT_CLAIM, nestedAct); } // Add a private UUID claim for uniqueness diff --git a/gateway-spi/src/test/java/org/apache/knox/gateway/services/security/token/TokenUtilsTest.java b/gateway-spi/src/test/java/org/apache/knox/gateway/services/security/token/TokenUtilsTest.java new file mode 100644 index 000000000..a0967e3c9 --- /dev/null +++ b/gateway-spi/src/test/java/org/apache/knox/gateway/services/security/token/TokenUtilsTest.java @@ -0,0 +1,244 @@ +/* + * 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.services.security.token; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.apache.knox.gateway.services.security.token.impl.JWTToken; +import org.junit.Test; + +/** + * Test class for TokenUtils, focusing on RFC 8693 actor chain functionality. + */ +public class TokenUtilsTest { + + @Test + public void testExtractActorChain_NoActClaim() throws Exception { + // Create a JWT without an 'act' claim + JWTokenAttributesBuilder builder = new JWTokenAttributesBuilder(); + builder.setUserName("testuser") + .setAlgorithm("RS256") + .setExpires(System.currentTimeMillis() + 30000); + + JWTokenAttributes attributes = builder.build(); + JWTToken token = new JWTToken(attributes); + + List<Map<String, Object>> actorChain = TokenUtils.extractActorChain(token); + assertNotNull(actorChain); + assertTrue(actorChain.isEmpty()); + } + + @Test + public void testExtractActorChain_SingleActor() throws Exception { + // Create a JWT with a single actor in the chain + List<Map<String, Object>> chain = new ArrayList<>(); + Map<String, Object> actor1 = new LinkedHashMap<>(); + actor1.put("sub", "actor1"); + chain.add(actor1); + + JWTokenAttributesBuilder builder = new JWTokenAttributesBuilder(); + builder.setUserName("testuser") + .setAlgorithm("RS256") + .setExpires(System.currentTimeMillis() + 30000) + .setActorChain(chain); + + JWTokenAttributes attributes = builder.build(); + JWTToken token = new JWTToken(attributes); + + List<Map<String, Object>> actorChain = TokenUtils.extractActorChain(token); + assertNotNull(actorChain); + assertEquals(1, actorChain.size()); + assertEquals("actor1", actorChain.get(0).get("sub")); + } + + @Test + public void testExtractActorChain_MultipleActors() throws Exception { + // Create an actor chain + List<Map<String, Object>> originalChain = new ArrayList<>(); + Map<String, Object> actor1 = new LinkedHashMap<>(); + actor1.put("sub", "actor1"); + originalChain.add(actor1); + + Map<String, Object> actor2 = new LinkedHashMap<>(); + actor2.put("sub", "actor2"); + originalChain.add(actor2); + + Map<String, Object> actor3 = new LinkedHashMap<>(); + actor3.put("sub", "actor3"); + originalChain.add(actor3); + + // Create a JWT with the actor chain + JWTokenAttributesBuilder builder = new JWTokenAttributesBuilder(); + builder.setUserName("testuser") + .setAlgorithm("RS256") + .setExpires(System.currentTimeMillis() + 30000) + .setActorChain(originalChain); + + JWTokenAttributes attributes = builder.build(); + JWTToken token = new JWTToken(attributes); + + // Extract and verify the chain + List<Map<String, Object>> extractedChain = TokenUtils.extractActorChain(token); + assertNotNull(extractedChain); + assertEquals(3, extractedChain.size()); + assertEquals("actor1", extractedChain.get(0).get("sub")); + assertEquals("actor2", extractedChain.get(1).get("sub")); + assertEquals("actor3", extractedChain.get(2).get("sub")); + } + + @Test + public void testAddActorToChain_EmptyChain() { + List<Map<String, Object>> result = TokenUtils.addActorToChain(null, "newActor"); + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals("newActor", result.get(0).get("sub")); + } + + @Test + public void testAddActorToChain_ExistingChain() { + List<Map<String, Object>> existingChain = new ArrayList<>(); + Map<String, Object> actor1 = new LinkedHashMap<>(); + actor1.put("sub", "actor1"); + existingChain.add(actor1); + + Map<String, Object> actor2 = new LinkedHashMap<>(); + actor2.put("sub", "actor2"); + existingChain.add(actor2); + + List<Map<String, Object>> result = TokenUtils.addActorToChain(existingChain, "newActor"); + assertNotNull(result); + assertEquals(3, result.size()); + // New actor should be first (most recent) + assertEquals("newActor", result.get(0).get("sub")); + assertEquals("actor1", result.get(1).get("sub")); + assertEquals("actor2", result.get(2).get("sub")); + } + + @Test + public void testAddActorToChain_NullActor() { + List<Map<String, Object>> existingChain = new ArrayList<>(); + Map<String, Object> actor1 = new LinkedHashMap<>(); + actor1.put("sub", "actor1"); + existingChain.add(actor1); + + List<Map<String, Object>> result = TokenUtils.addActorToChain(existingChain, null); + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals("actor1", result.get(0).get("sub")); + } + + @Test + public void testBuildNestedActClaim_EmptyChain() { + Map<String, Object> result = TokenUtils.buildNestedActClaim(null); + assertNull(result); + + result = TokenUtils.buildNestedActClaim(new ArrayList<>()); + assertNull(result); + } + + @Test + public void testBuildNestedActClaim_SingleActor() { + List<Map<String, Object>> chain = new ArrayList<>(); + Map<String, Object> actor1 = new LinkedHashMap<>(); + actor1.put("sub", "actor1"); + chain.add(actor1); + + Map<String, Object> result = TokenUtils.buildNestedActClaim(chain); + assertNotNull(result); + assertEquals("actor1", result.get("sub")); + assertNull(result.get("act")); // No nested act claim for single actor + } + + @Test + public void testBuildNestedActClaim_MultipleActors() { + List<Map<String, Object>> chain = new ArrayList<>(); + Map<String, Object> actor1 = new LinkedHashMap<>(); + actor1.put("sub", "actor1"); + chain.add(actor1); + + Map<String, Object> actor2 = new LinkedHashMap<>(); + actor2.put("sub", "actor2"); + chain.add(actor2); + + Map<String, Object> actor3 = new LinkedHashMap<>(); + actor3.put("sub", "actor3"); + chain.add(actor3); + + Map<String, Object> result = TokenUtils.buildNestedActClaim(chain); + assertNotNull(result); + + // Verify the nested structure + // Level 1: actor1 + assertEquals("actor1", result.get("sub")); + Map<String, Object> level2 = (Map<String, Object>) result.get("act"); + assertNotNull(level2); + + // Level 2: actor2 + assertEquals("actor2", level2.get("sub")); + Map<String, Object> level3 = (Map<String, Object>) level2.get("act"); + assertNotNull(level3); + + // Level 3: actor3 (deepest, no more nesting) + assertEquals("actor3", level3.get("sub")); + assertNull(level3.get("act")); + } + + @Test + public void testActorChainRoundTrip() throws Exception { + // Create an actor chain + List<Map<String, Object>> originalChain = new ArrayList<>(); + Map<String, Object> actor1 = new LinkedHashMap<>(); + actor1.put("sub", "actor1"); + actor1.put("iss", "issuer1"); + originalChain.add(actor1); + + Map<String, Object> actor2 = new LinkedHashMap<>(); + actor2.put("sub", "actor2"); + originalChain.add(actor2); + + Map<String, Object> actor3 = new LinkedHashMap<>(); + actor3.put("sub", "actor3"); + originalChain.add(actor3); + + // Create a JWT with the actor chain + JWTokenAttributesBuilder builder = new JWTokenAttributesBuilder(); + builder.setUserName("testuser") + .setAlgorithm("RS256") + .setExpires(System.currentTimeMillis() + 30000) + .setActorChain(originalChain); + + JWTokenAttributes attributes = builder.build(); + JWTToken token = new JWTToken(attributes); + + // Extract the chain and verify it matches the original + List<Map<String, Object>> extractedChain = TokenUtils.extractActorChain(token); + assertNotNull(extractedChain); + assertEquals(3, extractedChain.size()); + assertEquals("actor1", extractedChain.get(0).get("sub")); + assertEquals("issuer1", extractedChain.get(0).get("iss")); + assertEquals("actor2", extractedChain.get(1).get("sub")); + assertEquals("actor3", extractedChain.get(2).get("sub")); + } +}
