This is an automated email from the ASF dual-hosted git repository.

lmccay pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/knox.git


The following commit(s) were added to refs/heads/master by this push:
     new f54608484 KNOX-3321 - KnoxToken Support for RFC 8693 Token Exchange 
act Claim (#1229)
f54608484 is described below

commit f54608484dac74bc76863a3651cc9f0c8be55c40
Author: lmccay <[email protected]>
AuthorDate: Wed May 13 09:25:48 2026 -0400

    KNOX-3321 - KnoxToken Support for RFC 8693 Token Exchange act Claim (#1229)
---
 .../gateway/service/knoxtoken/TokenResource.java   |  16 +++
 .../service/knoxtoken/TokenServiceMessages.java    |   3 +
 .../knoxtoken/TokenServiceResourceTest.java        | 127 +++++++++++++++++++++
 .../services/security/token/JWTokenAttributes.java |  11 ++
 .../security/token/JWTokenAttributesBuilder.java   |   8 +-
 .../gateway/services/security/token/impl/JWT.java  |   2 +
 .../services/security/token/impl/JWTToken.java     |  29 +++++
 7 files changed, 195 insertions(+), 1 deletion(-)

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 6fec10e0f..c0ec90b9d 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
@@ -146,6 +146,7 @@ public class TokenResource {
   static final String KNOX_TOKEN_USER_LIMIT_PER_USER = TOKEN_PARAM_PREFIX + 
"limit.per.user";
   static final String KNOX_TOKEN_USER_LIMIT_EXCEEDED_ACTION = 
TOKEN_PARAM_PREFIX + "user.limit.exceeded.action";
   private static final String METADATA_QUERY_PARAM_PREFIX = "md_";
+  private static final String TOKEN_ENABLE_DELEGATED_AUTH = TOKEN_PARAM_PREFIX 
+ "enable.delegated.auth";
   private static final long TOKEN_TTL_DEFAULT = 30000L;
   static final String TOKEN_API_PATH = "knoxtoken/api/v1";
   static final String RESOURCE_PATH = TOKEN_API_PATH + "/token";
@@ -188,6 +189,7 @@ public class TokenResource {
   private int tokenLimitPerUser;
   private boolean includeGroupsInTokenAllowed;
   private String tokenIssuer;
+  private boolean enableDelegatedAuth;
 
   enum UserLimitExceededAction {REMOVE_OLDEST, RETURN_ERROR};
 
@@ -270,6 +272,9 @@ public class TokenResource {
             ? true
             : Boolean.parseBoolean(includeGroupsInTokenAllowedParam);
 
+    String enableDelegatedAuthParam = 
context.getInitParameter(TOKEN_ENABLE_DELEGATED_AUTH);
+    enableDelegatedAuth = enableDelegatedAuthParam != null && 
Boolean.parseBoolean(enableDelegatedAuthParam);
+
     this.tokenIssuer = 
StringUtils.isBlank(context.getInitParameter(KNOX_TOKEN_ISSUER))
             ? JWTokenAttributes.DEFAULT_ISSUER
             : context.getInitParameter(KNOX_TOKEN_ISSUER);
@@ -1100,6 +1105,17 @@ public class TokenResource {
       if (!tokenIdPrincipals.isEmpty()) {
         
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);
+        }
+      }
     }
 
     jwtAttributes = jwtAttributesBuilder.build();
diff --git 
a/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/TokenServiceMessages.java
 
b/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/TokenServiceMessages.java
index 87b647943..278e42434 100644
--- 
a/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/TokenServiceMessages.java
+++ 
b/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/TokenServiceMessages.java
@@ -99,4 +99,7 @@ public interface TokenServiceMessages {
   @Message( level = MessageLevel.DEBUG, text = "Token impersonation failed: 
{0}" )
   void tokenImpersonationFailed(@StackTrace Throwable t);
 
+  @Message( level = MessageLevel.DEBUG, text = "Adding RFC 8693 'act' claim to 
token: actor={0}, subject={1}" )
+  void addingActorClaimToToken(String actor, String subject);
+
 }
diff --git 
a/gateway-service-knoxtoken/src/test/java/org/apache/knox/gateway/service/knoxtoken/TokenServiceResourceTest.java
 
b/gateway-service-knoxtoken/src/test/java/org/apache/knox/gateway/service/knoxtoken/TokenServiceResourceTest.java
index 8d64377b6..281ea90ea 100644
--- 
a/gateway-service-knoxtoken/src/test/java/org/apache/knox/gateway/service/knoxtoken/TokenServiceResourceTest.java
+++ 
b/gateway-service-knoxtoken/src/test/java/org/apache/knox/gateway/service/knoxtoken/TokenServiceResourceTest.java
@@ -2093,4 +2093,131 @@ public class TokenServiceResourceTest {
       return false;
     }
   }
+
+  /**
+   * Helper method to setup test configuration for delegated auth tests.
+   */
+  private Map<String, String> createDelegatedAuthContextExpectations(boolean 
enableDelegatedAuth, boolean enableImpersonation) {
+    final Map<String, String> contextExpectations = new HashMap<>();
+    contextExpectations.put("knox.token.audiences", "recipient1,recipient2");
+    contextExpectations.put("knox.token.ttl", "60000");
+    if (enableImpersonation) {
+      
contextExpectations.put(ContextAttributes.IMPERSONATION_ENABLED_ATTRIBUTE, 
"true");
+    }
+    if (enableDelegatedAuth) {
+      contextExpectations.put("knox.token.enable.delegated.auth", "true");
+    }
+    return contextExpectations;
+  }
+
+  /**
+   * Helper method to create a Subject with optional impersonation.
+   */
+  private Subject createSubjectWithOptionalImpersonation(String primaryUser, 
String impersonatedUser) {
+    final Subject subject = new Subject();
+    subject.getPrincipals().add(new PrimaryPrincipal(primaryUser));
+    if (impersonatedUser != null && !primaryUser.equals(impersonatedUser)) {
+      subject.getPrincipals().add(new ImpersonatedPrincipal(impersonatedUser));
+    }
+    return subject;
+  }
+
+  /**
+   * Helper method to get a token with the given subject context.
+   */
+  @SuppressForbidden
+  private JWTToken getTokenWithSubject(Subject subject) throws Exception {
+    TokenResource tr = new TokenResource();
+    tr.request = request;
+    tr.context = context;
+    tr.init();
+
+    Response retResponse = Subject.doAs(
+        subject,
+        (PrivilegedAction<Response>) tr::doGet);
+
+    assertEquals(200, retResponse.getStatus());
+
+    String retString = retResponse.getEntity().toString();
+    String accessToken = getTagValue(retString, "access_token");
+    assertNotNull(accessToken);
+
+    return new JWTToken(accessToken);
+  }
+
+  /**
+   * Test RFC 8693 Token Exchange delegated authentication with 'act' claim.
+   * When delegated auth is enabled and impersonation is occurring, the token 
should include
+   * an 'act' claim with the actor (primary principal) identity.
+   */
+  @Test
+  @SuppressForbidden
+  public void testDelegatedAuthWithActClaim() throws Exception {
+    final String primaryUser = "admin";
+    final String impersonatedUser = "alice";
+
+    configureCommonExpectations(createDelegatedAuthContextExpectations(true, 
true), true);
+    Subject subject = createSubjectWithOptionalImpersonation(primaryUser, 
impersonatedUser);
+    JWTToken parsedToken = getTokenWithSubject(subject);
+
+    // Verify the subject is the impersonated user
+    assertEquals(impersonatedUser, parsedToken.getSubject());
+
+    // Verify the 'act' claim is present and contains the actor (primary user)
+    Object actClaim = parsedToken.getClaimAsObject(JWTToken.ACT_CLAIM);
+    assertNotNull("RFC 8693 'act' claim should be present when delegated auth 
is enabled", actClaim);
+
+    // The 'act' claim should be a Map containing a "sub" field with the 
actor's identity
+    assertTrue("'act' claim should be a Map", actClaim instanceof Map);
+    @SuppressWarnings("unchecked")
+    Map<String, Object> actClaimMap = (Map<String, Object>) actClaim;
+    assertEquals("'act' claim should contain the actor's subject", 
primaryUser, actClaimMap.get("sub"));
+
+    EasyMock.verify(request, context);
+  }
+
+  /**
+   * Test that 'act' claim is NOT added when delegated auth is disabled.
+   */
+  @Test
+  @SuppressForbidden
+  public void testNoActClaimWhenDelegatedAuthDisabled() throws Exception {
+    final String primaryUser = "admin";
+    final String impersonatedUser = "alice";
+
+    configureCommonExpectations(createDelegatedAuthContextExpectations(false, 
true), true);
+    Subject subject = createSubjectWithOptionalImpersonation(primaryUser, 
impersonatedUser);
+    JWTToken parsedToken = getTokenWithSubject(subject);
+
+    // Verify the subject is the impersonated user
+    assertEquals(impersonatedUser, parsedToken.getSubject());
+
+    // Verify the 'act' claim is NOT present when delegated auth is disabled
+    Object actClaim = parsedToken.getClaimAsObject(JWTToken.ACT_CLAIM);
+    assertNull("'act' claim should NOT be present when delegated auth is 
disabled", actClaim);
+
+    EasyMock.verify(request, context);
+  }
+
+  /**
+   * Test that 'act' claim is NOT added when there is no impersonation.
+   */
+  @Test
+  @SuppressForbidden
+  public void testNoActClaimWithoutImpersonation() throws Exception {
+    final String user = "alice";
+
+    configureCommonExpectations(createDelegatedAuthContextExpectations(true, 
false), true);
+    Subject subject = createSubjectWithOptionalImpersonation(user, null);
+    JWTToken parsedToken = getTokenWithSubject(subject);
+
+    // Verify the subject
+    assertEquals(user, parsedToken.getSubject());
+
+    // Verify the 'act' claim is NOT present when there's no impersonation
+    Object actClaim = parsedToken.getClaimAsObject(JWTToken.ACT_CLAIM);
+    assertNull("'act' claim should NOT be present when there is no 
impersonation", actClaim);
+
+    EasyMock.verify(request, context);
+  }
 }
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 2df7cf53b..1d19a846d 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
@@ -40,6 +40,7 @@ public class JWTokenAttributes {
   private final String issuer;
   private String kid;
   private final String clientId;
+  private final String actor;
 
   JWTokenAttributes(String userName, List<String> audiences, String algorithm, 
long expires, String signingKeystoreName, String signingKeystoreAlias,
       char[] signingKeystorePassphrase, boolean managed, String jku, String 
type, Set<String> groups, String kid, String issuer) {
@@ -48,6 +49,11 @@ public class JWTokenAttributes {
 
   JWTokenAttributes(String userName, List<String> audiences, String algorithm, 
long expires, String signingKeystoreName, String signingKeystoreAlias,
       char[] signingKeystorePassphrase, boolean managed, String jku, String 
type, Set<String> groups, String kid, String issuer, String clientId) {
+    this(userName, audiences, algorithm, expires, signingKeystoreName, 
signingKeystoreAlias, signingKeystorePassphrase, managed, jku, type, groups, 
kid, issuer, clientId, null);
+  }
+
+  JWTokenAttributes(String userName, List<String> audiences, String algorithm, 
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) {
     this.userName = userName;
     this.audiences = audiences;
     this.algorithm = algorithm;
@@ -62,6 +68,7 @@ public class JWTokenAttributes {
     this.kid = kid;
     this.issuer = issuer;
     this.clientId = clientId;
+    this.actor = actor;
   }
 
   public String getUserName() {
@@ -135,4 +142,8 @@ public class JWTokenAttributes {
   public String getClientId() {
     return clientId;
   }
+
+  public String getActor() {
+    return actor;
+  }
 }
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 91f4acddf..50896e61a 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
@@ -38,6 +38,7 @@ public class JWTokenAttributesBuilder {
   private String kid;
   private String issuer = JWTokenAttributes.DEFAULT_ISSUER;
   private String clientId;
+  private String actor;
 
   public JWTokenAttributesBuilder setUserName(String userName) {
     this.userName = userName;
@@ -113,8 +114,13 @@ public class JWTokenAttributesBuilder {
     return this;
   }
 
+  public JWTokenAttributesBuilder setActor(String actor) {
+    this.actor = actor;
+    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);
+        signingKeystorePassphrase, managed, jku, type, groups, kid, issuer, 
clientId, actor);
   }
 }
diff --git 
a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/impl/JWT.java
 
b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/impl/JWT.java
index e416e63aa..4cb4d151e 100644
--- 
a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/impl/JWT.java
+++ 
b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/impl/JWT.java
@@ -41,6 +41,8 @@ public interface JWT {
 
   String getClaim(String claimName);
 
+  Object getClaimAsObject(String claimName);
+
   String getPrincipal();
 
   String getIssuer();
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 22d8dc16f..27f8f6ced 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
@@ -43,6 +43,7 @@ public class JWTToken implements JWT {
   public static final String KNOX_JKU_CLAIM = "jku";
   public static final String KNOX_GROUPS_CLAIM = "knox.groups";
   public static final String CLIENT_ID_CLAIM = "client_id";
+  public static final String ACT_CLAIM = "act";
 
   SignedJWT jwt;
 
@@ -99,6 +100,15 @@ 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());
+    }
 
     // Add a private UUID claim for uniqueness
     builder.claim(KNOX_ID_CLAIM, String.valueOf(UUID.randomUUID()));
@@ -183,6 +193,25 @@ public class JWTToken implements JWT {
     return claim;
   }
 
+  /**
+   * Get a claim value as an Object. Useful for nested claims like 'act' which 
is a JSON object.
+   *
+   * @param claimName The name of the claim to retrieve
+   * @return The claim value as an Object, or null if the claim doesn't exist 
or cannot be parsed
+   */
+    @Override
+    public Object getClaimAsObject(String claimName) {
+    Object claim = null;
+
+    try {
+      claim = jwt.getJWTClaimsSet().getClaim(claimName);
+    } catch (ParseException e) {
+      log.unableToParseToken(e);
+    }
+
+    return claim;
+  }
+
   @Override
   public String getSubject() {
     return getClaim(JWT.SUBJECT);

Reply via email to