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 2b4de329d9..4c212a5345 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.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 @@ protected void continueChainAsPrincipal(HttpServletRequestWrapper request, Servl final Set 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 actorChainPrincipals = SubjectUtils.getActorChainPrincipal(currentSubject, subject); + subject.getPrincipals().addAll(actorChainPrincipals); + doAs(request, response, chain, subject); } else { @@ -154,7 +160,7 @@ protected void continueChainAsPrincipal(HttpServletRequestWrapper request, Servl } } - 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 f3963c5232..de4987caa1 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.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.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 @@ protected Subject createSubjectFromToken(final JWT token) throws UnknownTokenExc if (expectedPrincipalClaim != null) { claimvalue = token.getClaim(expectedPrincipalClaim); } + // Extract actor chain from the JWT token if present (RFC 8693) + List> 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 @@ protected Subject createSubjectFromToken(final JWT token) throws UnknownTokenExc // 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 Subject createSubjectFromTokenIdentifier(final String tokenId) throws Unk @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> actorChain) { String claimValue = (expectedPrincipalClaimValue != null) ? expectedPrincipalClaimValue.toLowerCase(Locale.ROOT) : null; @@ -435,6 +447,11 @@ protected Subject createSubjectFromTokenData(final String principal, final Strin 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 c0ec90b9dd..8da6f137b1 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.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; @@ -1106,21 +1107,41 @@ protected JWT getJWT(String userName, long expires, String jku) throws TokenServ 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)) { + // RFC 8693 Token Exchange: Build the actor chain if delegated auth is enabled + handleDelegatedAuthentication(subject, jwtAttributesBuilder); + } + + jwtAttributes = jwtAttributesBuilder.build(); + token = ts.issueToken(jwtAttributes); + 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 actorChainPrincipals = subject.getPrincipals(ActorChainPrincipal.class); + List> 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)) { - // The primary principal (the one doing the impersonation) becomes the actor - jwtAttributesBuilder.setActor(primaryPrincipalName); + // Build the new actor chain by adding the current actor (primary principal) to the existing chain + List> 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"); } } - - jwtAttributes = jwtAttributesBuilder.build(); - token = ts.issueToken(jwtAttributes); - return token; } private boolean shouldIncludeGroups() { 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 0000000000..22467696db --- /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> initialChain = new ArrayList<>(); + + Map actor1 = new LinkedHashMap<>(); + actor1.put("sub", "service-a"); + actor1.put("iss", "https://issuer.example.com"); + initialChain.add(actor1); + + Map 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> 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> 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> 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> chain = new ArrayList<>(); + + Map actor1 = new LinkedHashMap<>(); + actor1.put("sub", "actor1"); + chain.add(actor1); + + Map actor2 = new LinkedHashMap<>(); + actor2.put("sub", "actor2"); + chain.add(actor2); + + Map 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 0000000000..3d2d99fef4 --- /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. + * + *

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.

+ * + *

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:

+ *
    + *
  • Identity claims such as 'sub' (subject) and 'iss' (issuer) should be used to identify actors
  • + *
  • The combination of 'iss' and 'sub' may be necessary to uniquely identify an actor
  • + *
  • Non-identity claims (e.g., 'exp', 'nbf', 'aud') are NOT meaningful within 'act' claims + * and should not be used
  • + *
+ * + *

Example chain structure:

+ *
+ * [
+ *   {"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
+ * ]
+ * 
+ * + * @see RFC 8693 Section 4.1 - Actor Claim + */ +public interface ActorChainPrincipal extends Principal { + + /** + * Returns the chain of actors in order from most recent to oldest. + * + *

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.

+ * + * @return an immutable list of actor claim maps, never null but may be empty + */ + List> getActorChain(); + + /** + * Returns the subject (identity) of the most recent actor in the chain. + * + *

This is equivalent to calling {@code getActorChain().get(0).get("sub")} + * if the chain is not empty.

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

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.

+ * + * @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 0000000000..36989ad947 --- /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> 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> actorChain) { + this.actorChain = actorChain == null + ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList<>(actorChain)); + } + + @Override + public List> 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 27cfaea297..11db9113fb 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 static Set getGroupPrincipals(Subject subject) { public static Set getTokenIdPrincipals(Subject subject) { return subject.getPrincipals(TokenIdPrincipal.class); } + + public static Set 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 1d19a846d7..c41f983eb7 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 @@ -21,6 +21,7 @@ import java.net.URISyntaxException; import java.util.Date; import java.util.List; +import java.util.Map; import java.util.Set; public class JWTokenAttributes { @@ -40,7 +41,7 @@ public class JWTokenAttributes { private final String issuer; private String kid; private final String clientId; - private final String actor; + private final List> actorChain; JWTokenAttributes(String userName, List audiences, String algorithm, long expires, String signingKeystoreName, String signingKeystoreAlias, char[] signingKeystorePassphrase, boolean managed, String jku, String type, Set groups, String kid, String issuer) { @@ -53,7 +54,7 @@ public class JWTokenAttributes { } JWTokenAttributes(String userName, List audiences, String algorithm, long expires, String signingKeystoreName, String signingKeystoreAlias, - char[] signingKeystorePassphrase, boolean managed, String jku, String type, Set groups, String kid, String issuer, String clientId, String actor) { + char[] signingKeystorePassphrase, boolean managed, String jku, String type, Set groups, String kid, String issuer, String clientId, List> actorChain) { this.userName = userName; this.audiences = audiences; this.algorithm = algorithm; @@ -68,7 +69,7 @@ public class JWTokenAttributes { this.kid = kid; this.issuer = issuer; this.clientId = clientId; - this.actor = actor; + this.actorChain = actorChain; } public String getUserName() { @@ -143,7 +144,27 @@ public String getClientId() { return clientId; } - public String getActor() { - return actor; + /** + * Get the actor chain for RFC 8693 token exchange. + * + *

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

+ * + *

According to RFC 8693 Section 4.1, identity claims within the 'act' claim + * identify the actor. Common claims include:

+ *
    + *
  • 'sub' - the subject/identity of the actor
  • + *
  • 'iss' - the issuer of the actor's identity
  • + *
+ * + *

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.

+ * + * @return the actor chain, or null if no delegation has occurred + */ + public List> getActorChain() { + return actorChain; } } 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 50896e61a9..b70a84e6ef 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 @@ -20,6 +20,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Set; public class JWTokenAttributesBuilder { @@ -38,7 +39,7 @@ public class JWTokenAttributesBuilder { private String kid; private String issuer = JWTokenAttributes.DEFAULT_ISSUER; private String clientId; - private String actor; + private List> actorChain; public JWTokenAttributesBuilder setUserName(String userName) { this.userName = userName; @@ -114,13 +115,37 @@ public JWTokenAttributesBuilder setClientId(String clientId) { return this; } - public JWTokenAttributesBuilder setActor(String actor) { - this.actor = actor; + /** + * Set the actor chain for RFC 8693 token exchange. + * + *

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

+ * + *

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.

+ * + *

Example:

+ *
+   * 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);
+   * 
+ * + * @param actorChain the actor chain, ordered from most recent to oldest + * @return this builder + */ + public JWTokenAttributesBuilder setActorChain(List> actorChain) { + this.actorChain = actorChain; return this; } public JWTokenAttributes build() { return new JWTokenAttributes(userName, (audiences == null ? new ArrayList<>() : audiences), algorithm, expires, signingKeystoreName, signingKeystoreAlias, - signingKeystorePassphrase, managed, jku, type, groups, kid, issuer, clientId, actor); + signingKeystorePassphrase, managed, jku, type, groups, kid, issuer, clientId, actorChain); } } 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 e9620a4258..72fb7a25ff 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 @@ -30,7 +30,11 @@ import javax.servlet.FilterConfig; import javax.servlet.ServletContext; import java.security.interfaces.RSAPublicKey; +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"; @@ -114,4 +118,130 @@ private static boolean useHMAC(char[] hmacSecret, String signingKeystoreName) { return hmacSecret != null && StringUtils.isBlank(signingKeystoreName); } + /** + * Extract the actor chain from an RFC 8693 'act' claim in a JWT token. + * + *

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.

+ * + *

According to RFC 8693 Section 4.1, the 'act' claim contains identity claims that + * identify the actor. Common identity claims include:

+ *
    + *
  • 'sub' - the subject/identity of the actor
  • + *
  • 'iss' - the issuer of the actor's identity
  • + *
+ * + *

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.

+ * + *

Example JWT 'act' claim structure:

+ *
+   * {
+   *   "sub": "service-a",
+   *   "iss": "https://issuer.example.com",
+   *   "act": {
+   *     "sub": "service-b",
+   *     "act": {
+   *       "sub": "service-c"
+   *     }
+   *   }
+   * }
+   * 
+ * + *

This method flattens this nested structure into a list ordered from most recent + * actor (service-a) to oldest (service-c).

+ * + * @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> extractActorChain(JWT token) { + Object currentAct = token == null ? null : token.getClaimAsObject(JWTToken.ACT_CLAIM); + final List> actorChain = new ArrayList<>(); + + while (currentAct instanceof Map actorMap) { + actorChain.add(new LinkedHashMap<>((Map) 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. + * + *

This creates a new list with the new actor as the first element (most recent), + * followed by all actors from the existing chain.

+ * + * @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> addActorToChain(List> existingChain, String newActor) { + if (newActor == null || newActor.isEmpty()) { + return existingChain == null ? Collections.emptyList() : existingChain; + } + + List> newChain = new ArrayList<>(); + + // Add the new actor as the first element + Map 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. + * + *

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.

+ * + *

Example transformation:

+ *
+   * Input:  [{"sub": "actor1"}, {"sub": "actor2"}, {"sub": "actor3"}]
+   * Output: {
+   *   "sub": "actor1",
+   *   "act": {
+   *     "sub": "actor2",
+   *     "act": {
+   *       "sub": "actor3"
+   *     }
+   *   }
+   * }
+   * 
+ * + * @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 buildNestedActClaim(List> actorChain) { + if (actorChain == null || actorChain.isEmpty()) { + return null; + } + + // Start from the last actor (oldest) and work backwards + Map nestedAct = null; + for (int i = actorChain.size() - 1; i >= 0; i--) { + Map 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 27f8f6ced9..b43a0b29a3 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 @@ -19,6 +19,7 @@ import java.net.URISyntaxException; import java.text.ParseException; import java.util.Date; +import java.util.Map; import java.util.UUID; import com.nimbusds.jose.JOSEException; @@ -33,6 +34,7 @@ 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 ); @@ -100,14 +102,12 @@ public JWTToken(JWTokenAttributes jwtAttributes) { 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 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 0000000000..a0967e3c9d --- /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> 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> chain = new ArrayList<>(); + Map 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> 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> originalChain = new ArrayList<>(); + Map actor1 = new LinkedHashMap<>(); + actor1.put("sub", "actor1"); + originalChain.add(actor1); + + Map actor2 = new LinkedHashMap<>(); + actor2.put("sub", "actor2"); + originalChain.add(actor2); + + Map 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> 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> result = TokenUtils.addActorToChain(null, "newActor"); + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals("newActor", result.get(0).get("sub")); + } + + @Test + public void testAddActorToChain_ExistingChain() { + List> existingChain = new ArrayList<>(); + Map actor1 = new LinkedHashMap<>(); + actor1.put("sub", "actor1"); + existingChain.add(actor1); + + Map actor2 = new LinkedHashMap<>(); + actor2.put("sub", "actor2"); + existingChain.add(actor2); + + List> 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> existingChain = new ArrayList<>(); + Map actor1 = new LinkedHashMap<>(); + actor1.put("sub", "actor1"); + existingChain.add(actor1); + + List> result = TokenUtils.addActorToChain(existingChain, null); + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals("actor1", result.get(0).get("sub")); + } + + @Test + public void testBuildNestedActClaim_EmptyChain() { + Map result = TokenUtils.buildNestedActClaim(null); + assertNull(result); + + result = TokenUtils.buildNestedActClaim(new ArrayList<>()); + assertNull(result); + } + + @Test + public void testBuildNestedActClaim_SingleActor() { + List> chain = new ArrayList<>(); + Map actor1 = new LinkedHashMap<>(); + actor1.put("sub", "actor1"); + chain.add(actor1); + + Map 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> chain = new ArrayList<>(); + Map actor1 = new LinkedHashMap<>(); + actor1.put("sub", "actor1"); + chain.add(actor1); + + Map actor2 = new LinkedHashMap<>(); + actor2.put("sub", "actor2"); + chain.add(actor2); + + Map actor3 = new LinkedHashMap<>(); + actor3.put("sub", "actor3"); + chain.add(actor3); + + Map result = TokenUtils.buildNestedActClaim(chain); + assertNotNull(result); + + // Verify the nested structure + // Level 1: actor1 + assertEquals("actor1", result.get("sub")); + Map level2 = (Map) result.get("act"); + assertNotNull(level2); + + // Level 2: actor2 + assertEquals("actor2", level2.get("sub")); + Map level3 = (Map) 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> originalChain = new ArrayList<>(); + Map actor1 = new LinkedHashMap<>(); + actor1.put("sub", "actor1"); + actor1.put("iss", "issuer1"); + originalChain.add(actor1); + + Map actor2 = new LinkedHashMap<>(); + actor2.put("sub", "actor2"); + originalChain.add(actor2); + + Map 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> 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")); + } +}