From 978242e6ee47936698d2ff1c7f679a552d04782f Mon Sep 17 00:00:00 2001
From: Sandor Molnar
Date: Wed, 2 Sep 2026 10:09:32 +0200
Subject: [PATCH 1/5] KNOX-3424: Replace the audience query param with a
URL-validated resource param
The KNOXTOKEN service now takes the requested token audience via the RFC 8707
`resource` query parameter instead of `audience`. Per RFC 8707 section 2 (and
RFC 3986 section 4.3) each `resource` value must be an absolute URI without a
fragment component; a query component is allowed. Invalid values are rejected
with 400 (new ErrorCode.INVALID_RESOURCE). The pluggable audience validators
(static/whitelist/passthrough) are unchanged and still resolve the `aud` claim;
only their input source changed. Docs and tests updated accordingly.
Co-Authored-By: Claude Opus 4.8
---
.../service/knoxtoken/TokenResource.java | 43 +++++++---
.../knoxtoken/TokenServiceMessages.java | 2 +-
.../knoxtoken/WhitelistAudienceValidator.java | 2 +-
.../knoxtoken/TokenServiceResourceTest.java | 79 ++++++++++++++-----
knox-site/docs/config_knox_token.md | 30 +++----
5 files changed, 107 insertions(+), 49 deletions(-)
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 818dddd2c..1a3db3ef2 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
@@ -17,6 +17,8 @@
*/
package org.apache.knox.gateway.service.knoxtoken;
+import java.net.URI;
+import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.security.KeyStoreException;
import java.security.cert.Certificate;
@@ -125,7 +127,7 @@ public class TokenResource {
protected static final String TOKEN_TTL_PARAM = TOKEN_PARAM_PREFIX + "ttl";
public static final String TOKEN_TYPE_PARAM = TOKEN_PARAM_PREFIX + "type";
private static final String TOKEN_AUDIENCES_PARAM = TOKEN_PARAM_PREFIX + "audiences";
- static final String AUDIENCE_QUERY_PARAM = "audience";
+ static final String RESOURCE_QUERY_PARAM = "resource";
private static final String TOKEN_AUDIENCE_VALIDATOR_PARAM = TOKEN_PARAM_PREFIX + "audience.validator";
public static final String TOKEN_INCLUDE_GROUPS_IN_JWT_ALLOWED = TOKEN_PARAM_PREFIX + "include.groups.allowed";
private static final String TOKEN_TARGET_URL = TOKEN_PARAM_PREFIX + "target.url";
@@ -221,7 +223,8 @@ public enum ErrorCode {
ALREADY_ENABLED(70),
DISABLED_KNOXSSO_COOKIE(80),
TOKEN_EXPIRED(90),
- INVALID_AUDIENCE(100);
+ INVALID_AUDIENCE(100),
+ INVALID_RESOURCE(110);
private final int code;
@@ -920,7 +923,7 @@ protected TokenResponseContext getTokenResponse(UserContext context) {
final List audiences;
try {
- audiences = audienceValidator.validateAndResolve(new AudienceValidationContext(parseRequestedAudiences(), targetAudiences));
+ audiences = audienceValidator.validateAndResolve(new AudienceValidationContext(parseRequestedResources(), targetAudiences));
} catch (RequestedAudienceValidationException e) {
log.rejectedAudienceRequest(e.getMessage());
return new TokenResponseContext(null,
@@ -1163,26 +1166,42 @@ public ResponseMap(String accessToken, String tokenId, Map map,
}
}
- private List parseRequestedAudiences() {
+ private List parseRequestedResources() throws RequestedAudienceValidationException {
final Map parameterMap = request.getParameterMap();
- final String[] rawValues = parameterMap == null ? null : parameterMap.get(AUDIENCE_QUERY_PARAM);
+ final String[] rawValues = parameterMap == null ? null : parameterMap.get(RESOURCE_QUERY_PARAM);
final List requested = new ArrayList<>();
if (rawValues != null) {
for (String rawValue : rawValues) {
- if (rawValue == null) {
- continue;
- }
for (String value : rawValue.split(",")) {
- final String trimmed = value.trim();
- if (!trimmed.isEmpty()) {
- requested.add(trimmed);
- }
+ requested.add(validateResourceUri(value.trim()));
}
}
}
return requested;
}
+ /**
+ * Validates that a requested {@code resource} value is an absolute URI without a fragment, as
+ * required by RFC 8707 (Resource Indicators for OAuth 2.0) section 2 and RFC 3986 section 4.3.
+ * A query component is permitted; a fragment component is not. Returns the value unchanged when
+ * it is valid.
+ */
+ private String validateResourceUri(String value) throws RequestedAudienceValidationException {
+ final URI uri;
+ try {
+ uri = new URI(value);
+ } catch (URISyntaxException e) {
+ throw new RequestedAudienceValidationException(
+ "The requested resource '" + value + "' is not a valid URI.", ErrorCode.INVALID_RESOURCE);
+ }
+ if (!uri.isAbsolute() || uri.getFragment() != null) {
+ throw new RequestedAudienceValidationException(
+ "The requested resource '" + value + "' must be an absolute URI without a fragment.",
+ ErrorCode.INVALID_RESOURCE);
+ }
+ return value;
+ }
+
private JWT getJWT(UserContext userContext, long issueTime, long expires, String jku, List audiences) throws TokenServiceException {
JWTokenAttributes jwtAttributes;
JWT token;
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 ad532f089..f05fad2cb 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
@@ -105,7 +105,7 @@ void invalidToken(String topologyName,
@Message( level = MessageLevel.DEBUG, text = "Adding RFC 8693 'act' claim to token: actor={0}, subject={1}" )
void addingActorClaimToToken(String actor, String subject);
- @Message( level = MessageLevel.WARN, text = "Rejected token request due to invalid audience: {0}" )
+ @Message( level = MessageLevel.WARN, text = "Rejected token request due to invalid requested resource: {0}" )
void rejectedAudienceRequest(String reason);
}
diff --git a/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/WhitelistAudienceValidator.java b/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/WhitelistAudienceValidator.java
index 251ea0dd8..a02420c68 100644
--- a/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/WhitelistAudienceValidator.java
+++ b/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/WhitelistAudienceValidator.java
@@ -48,7 +48,7 @@ public List validateAndResolve(AudienceValidationContext context) throws
for (String audience : requested) {
if (!configured.contains(audience)) {
throw new RequestedAudienceValidationException(
- "The requested audience '" + audience + "' is not allowed.", ErrorCode.INVALID_AUDIENCE);
+ "The requested resource '" + audience + "' is not allowed.", ErrorCode.INVALID_AUDIENCE);
}
}
return requested;
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 6875f13b4..4a03a24e6 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
@@ -133,7 +133,7 @@ public class TokenServiceResourceTest {
private ServletContext context;
private HttpServletRequest request;
- private String[] audienceParamValues;
+ private String[] resourceParamValues;
private JWTokenAuthority authority;
private TestTokenStateService tss = new TestTokenStateService();
private char[] hmacSecret;
@@ -200,8 +200,8 @@ private void configureCommonExpectations(Map contextExpectations
}
EasyMock.expect(request.getParameterNames()).andReturn(Collections.emptyEnumeration()).anyTimes();
final Map parameterMap = new HashMap<>();
- if (audienceParamValues != null) {
- parameterMap.put(TokenResource.AUDIENCE_QUERY_PARAM, audienceParamValues);
+ if (resourceParamValues != null) {
+ parameterMap.put(TokenResource.RESOURCE_QUERY_PARAM, resourceParamValues);
}
EasyMock.expect(request.getParameterMap()).andReturn(parameterMap).anyTimes();
@@ -437,7 +437,7 @@ public void testAudiencesWhitespace() throws Exception {
}
@Test
- public void testDynamicAudienceNoParamUsesConfigured() throws Exception {
+ public void testDynamicResourceNoParamUsesConfigured() throws Exception {
final Map contextExpectations = new HashMap<>();
contextExpectations.put("knox.token.audience.validator", "whitelist");
contextExpectations.put("knox.token.audiences", "recipient1,recipient2");
@@ -459,10 +459,10 @@ public void testDynamicAudienceNoParamUsesConfigured() throws Exception {
}
@Test
- public void testDynamicAudienceIgnoredWithDefaultStaticValidator() throws Exception {
- // With no validator configured the default 'static' validator is used: a requested audience must
+ public void testDynamicResourceIgnoredWithDefaultStaticValidator() throws Exception {
+ // With no validator configured the default 'static' validator is used: a requested resource must
// be ignored and the configured audiences used unchanged (historical behavior).
- audienceParamValues = new String[] { "recipient1" };
+ resourceParamValues = new String[] { "https://recipient1" };
final Map contextExpectations = new HashMap<>();
contextExpectations.put("knox.token.audiences", "recipient1,recipient2");
configureCommonExpectations(contextExpectations);
@@ -510,11 +510,11 @@ public void testDeploymentFailsWhenUnknownAudienceValidatorConfigured() throws E
}
@Test
- public void testDynamicAudienceAllowedWhenWhitelisted() throws Exception {
- audienceParamValues = new String[] { "recipient1" };
+ public void testDynamicResourceAllowedWhenWhitelisted() throws Exception {
+ resourceParamValues = new String[] { "https://recipient1" };
final Map contextExpectations = new HashMap<>();
contextExpectations.put("knox.token.audience.validator", "whitelist");
- contextExpectations.put("knox.token.audiences", "recipient1,recipient2");
+ contextExpectations.put("knox.token.audiences", "https://recipient1,https://recipient2");
configureCommonExpectations(contextExpectations);
TokenResource tr = new TokenResource();
@@ -528,16 +528,34 @@ public void testDynamicAudienceAllowedWhenWhitelisted() throws Exception {
JWT parsedToken = new JWTToken(getTagValue(retResponse.getEntity().toString(), "access_token"));
List audiences = Arrays.asList(parsedToken.getAudienceClaims());
assertEquals(1, audiences.size());
- assertTrue(audiences.contains("recipient1"));
- assertFalse(audiences.contains("recipient2"));
+ assertTrue(audiences.contains("https://recipient1"));
+ assertFalse(audiences.contains("https://recipient2"));
}
@Test
- public void testDynamicAudienceRejectedWhenNotWhitelisted() throws Exception {
- audienceParamValues = new String[] { "recipient1", "intruder" };
+ public void testDynamicResourceRejectedWhenNotWhitelisted() throws Exception {
+ resourceParamValues = new String[] { "https://recipient1", "https://intruder" };
final Map contextExpectations = new HashMap<>();
contextExpectations.put("knox.token.audience.validator", "whitelist");
- contextExpectations.put("knox.token.audiences", "recipient1,recipient2");
+ contextExpectations.put("knox.token.audiences", "https://recipient1,https://recipient2");
+ configureCommonExpectations(contextExpectations);
+
+ TokenResource tr = new TokenResource();
+ tr.request = request;
+ tr.context = context;
+ tr.init();
+
+ Response retResponse = tr.doGet();
+ assertEquals(400, retResponse.getStatus());
+ }
+
+ @Test
+ public void testDynamicResourceRejectedWhenNotAValidUri() throws Exception {
+ // A resource that is present but not an absolute URI (RFC 8707 section 2) must be rejected,
+ // regardless of the selected validator.
+ resourceParamValues = new String[] { "not-a-uri" };
+ final Map contextExpectations = new HashMap<>();
+ contextExpectations.put("knox.token.audience.validator", "passthrough");
configureCommonExpectations(contextExpectations);
TokenResource tr = new TokenResource();
@@ -547,14 +565,33 @@ public void testDynamicAudienceRejectedWhenNotWhitelisted() throws Exception {
Response retResponse = tr.doGet();
assertEquals(400, retResponse.getStatus());
+ assertTrue(retResponse.getEntity().toString().contains("\"code\": " + TokenResource.ErrorCode.INVALID_RESOURCE.toInt()));
}
@Test
- public void testDynamicAudienceMultipleValuesAndCommaSeparated() throws Exception {
- audienceParamValues = new String[] { "recipient1", " recipient2 , recipient3" };
+ public void testDynamicResourceRejectedWhenUriHasFragment() throws Exception {
+ // RFC 8707 section 2: the resource URI MUST NOT include a fragment component.
+ resourceParamValues = new String[] { "https://recipient1#fragment" };
+ final Map contextExpectations = new HashMap<>();
+ contextExpectations.put("knox.token.audience.validator", "passthrough");
+ configureCommonExpectations(contextExpectations);
+
+ TokenResource tr = new TokenResource();
+ tr.request = request;
+ tr.context = context;
+ tr.init();
+
+ Response retResponse = tr.doGet();
+ assertEquals(400, retResponse.getStatus());
+ assertTrue(retResponse.getEntity().toString().contains("\"code\": " + TokenResource.ErrorCode.INVALID_RESOURCE.toInt()));
+ }
+
+ @Test
+ public void testDynamicResourceMultipleValuesAndCommaSeparated() throws Exception {
+ resourceParamValues = new String[] { "https://recipient1", " https://recipient2 , https://recipient3" };
final Map contextExpectations = new HashMap<>();
contextExpectations.put("knox.token.audience.validator", "whitelist");
- contextExpectations.put("knox.token.audiences", "recipient1,recipient2,recipient3");
+ contextExpectations.put("knox.token.audiences", "https://recipient1,https://recipient2,https://recipient3");
configureCommonExpectations(contextExpectations);
TokenResource tr = new TokenResource();
@@ -568,9 +605,9 @@ public void testDynamicAudienceMultipleValuesAndCommaSeparated() throws Exceptio
JWT parsedToken = new JWTToken(getTagValue(retResponse.getEntity().toString(), "access_token"));
List audiences = Arrays.asList(parsedToken.getAudienceClaims());
assertEquals(3, audiences.size());
- assertTrue(audiences.contains("recipient1"));
- assertTrue(audiences.contains("recipient2"));
- assertTrue(audiences.contains("recipient3"));
+ assertTrue(audiences.contains("https://recipient1"));
+ assertTrue(audiences.contains("https://recipient2"));
+ assertTrue(audiences.contains("https://recipient3"));
}
@Test
diff --git a/knox-site/docs/config_knox_token.md b/knox-site/docs/config_knox_token.md
index 98fc38f09..f37b0dbc5 100644
--- a/knox-site/docs/config_knox_token.md
+++ b/knox-site/docs/config_knox_token.md
@@ -47,7 +47,7 @@ The Knox Token Service configuration can be configured in any descriptor/topolog
Parameter | Description | Default |
-------------------------------- |------------ |----------- |
knox.token.ttl | This indicates the lifespan (milliseconds) of the token. Once it expires a new token must be acquired from KnoxToken service. The 36000000 in the topology above gives you 10 hrs. | 30000 (30 seconds) |
-knox.token.audiences | This is a comma-separated list of audiences to add to the JWT token. This is used to ensure that a token received by a participating application knows that the token was intended for use with that application. It is optional. In the event that an endpoint has expected audiences and they are not present the token must be rejected. In the event where the token has audiences and the endpoint has none expected then the token is accepted. This list additionally acts as the whitelist of audiences a caller is permitted to request per request via the `audience` query parameter (see below).| empty |
+knox.token.audiences | This is a comma-separated list of audiences to add to the JWT token. This is used to ensure that a token received by a participating application knows that the token was intended for use with that application. It is optional. In the event that an endpoint has expected audiences and they are not present the token must be rejected. In the event where the token has audiences and the endpoint has none expected then the token is accepted. This list additionally acts as the whitelist of audiences a caller is permitted to request per request via the `resource` query parameter (see below).| empty |
knox.token.target.url | This is an optional configuration parameter to indicate the intended endpoint for which the token may be used. The KnoxShell token credential collector can pull this URL from a knoxtokencache file to be used in scripts. This eliminates the need to prompt for or hardcode endpoints in your scripts. | n/a |
knox.token.exp.server-managed | This is an optional configuration parameter to enable/disable server-managed token state, to support the associated token renewal and revocation APIs. | false |
knox.token.renewer.whitelist | This is an optional configuration parameter to authorize the comma-separated list of users to invoke the associated token renewal and revocation APIs. | |
@@ -101,17 +101,19 @@ This feature is enabled by default. If you want to disable it, add the following
#### Requesting a token audience dynamically
-By default the `aud` claim of an issued token is fixed to the value(s) configured in `knox.token.audiences`, and the per-request `audience` query parameter is ignored. To let a caller request the audience(s) per request instead, select an *audience validator* that honors the parameter. Multiple audiences may be supplied either comma-separated in a single parameter or as repeated parameters; surrounding whitespace is trimmed.
+By default the `aud` claim of an issued token is fixed to the value(s) configured in `knox.token.audiences`, and the per-request `resource` query parameter is ignored. To let a caller request the audience(s) per request instead, select an *audience validator* that honors the parameter. This follows [RFC 8707 (Resource Indicators for OAuth 2.0)](https://www.rfc-editor.org/rfc/rfc8707): the caller names the target service via the `resource` parameter and the gateway maps it into the token's `aud` claim.
- curl -u admin:admin-password -k "https://localhost:8443/gateway/homepage/knoxtoken/api/v1/token?audience=service-a"
+Per RFC 8707 section 2, each `resource` value MUST be an absolute URI (RFC 3986 section 4.3) and MUST NOT include a fragment component; a query component is permitted. A `resource` value that is not a valid absolute URI, or that includes a fragment, is rejected with `400 Bad Request`. Multiple resources may be supplied either comma-separated in a single parameter or as repeated parameters; surrounding whitespace is trimmed.
-Which requested audiences are allowed is decided by a pluggable *audience validator*, selected with `knox.token.audience.validator`:
+ curl -u admin:admin-password -k "https://localhost:8443/gateway/homepage/knoxtoken/api/v1/token?resource=https://service-a"
-* `static` (the default) preserves the historical behavior: the `audience` query parameter is ignored and the statically configured `knox.token.audiences` are always used. No configuration beyond `knox.token.audiences` is needed and nothing new is exposed to callers.
-* `whitelist` validates requested audiences against `knox.token.audiences`, treating it as a whitelist.
-* `passthrough` accepts whatever audience(s) the caller requests without any local whitelist. It requires no configured `knox.token.audiences`; when the request contains no `audience` parameter the token is issued with no `aud` claim (it does not fall back to `knox.token.audiences`).
+Which requested resources are allowed is decided by a pluggable *audience validator*, selected with `knox.token.audience.validator`:
-Selecting the `whitelist` validator enables per-request audiences:
+* `static` (the default) preserves the historical behavior: the `resource` query parameter is ignored and the statically configured `knox.token.audiences` are always used. No configuration beyond `knox.token.audiences` is needed and nothing new is exposed to callers.
+* `whitelist` validates requested resources against `knox.token.audiences`, treating it as a whitelist.
+* `passthrough` accepts whatever resource(s) the caller requests without any local whitelist. It requires no configured `knox.token.audiences`; when the request contains no `resource` parameter the token is issued with no `aud` claim (it does not fall back to `knox.token.audiences`).
+
+Selecting the `whitelist` validator enables per-request resources:
knox.token.audience.validator
@@ -120,13 +122,13 @@ Selecting the `whitelist` validator enables per-request audiences:
With the `whitelist` validator its behavior is:
-* the request does not contain an `audience` parameter -> the statically configured `knox.token.audiences` are used, exactly as before (unchanged default behavior)
-* the request contains an `audience` parameter and every requested audience is present in `knox.token.audiences` -> only the requested audience(s) are placed in the token's `aud` claim
-* the request contains an `audience` parameter and any requested audience is not present in `knox.token.audiences` -> the request is rejected with `400 Bad Request`
+* the request does not contain a `resource` parameter -> the statically configured `knox.token.audiences` are used, exactly as before (unchanged default behavior)
+* the request contains a `resource` parameter and every requested resource is present in `knox.token.audiences` -> only the requested resource(s) are placed in the token's `aud` claim
+* the request contains a `resource` parameter and any requested resource is not present in `knox.token.audiences` -> the request is rejected with `400 Bad Request`
-Only exact matches against the whitelist are honored.
+Only exact matches against the whitelist are honored, so the values configured in `knox.token.audiences` must be the same absolute URIs the callers request.
-The `passthrough` validator instead stamps the requested audience(s) into the token's `aud` claim verbatim, without any whitelist check, and rejects nothing. If the request contains no `audience` parameter the token is issued with no `aud` claim. Because it performs no local authorization, `passthrough` relies on a downstream JWTProvider to reject tokens whose `aud` does not match the consumer topology's expected audiences, deferring enforcement to the point of consumption. Use it only when such consumption-time validation is in place.
+The `passthrough` validator instead stamps the requested resource(s) into the token's `aud` claim verbatim, without any whitelist check, and rejects nothing (beyond the URI validation above). If the request contains no `resource` parameter the token is issued with no `aud` claim. Because it performs no local authorization, `passthrough` relies on a downstream JWTProvider to reject tokens whose `aud` does not match the consumer topology's expected audiences, deferring enforcement to the point of consumption. Use it only when such consumption-time validation is in place.
To avoid deploying a topology that cannot authorize any request, the deployment fails at startup when the `whitelist` validator is selected but no `knox.token.audiences` are configured. This guard is specific to validators that require a configured audience list (the `whitelist` validator cannot authorize anything without one); it is expressed through the validator's own `requiresConfiguredAudiences()` contract rather than by inspecting the topology. The `static` and `passthrough` validators have no such requirement.
@@ -136,7 +138,7 @@ Audience validator configuration parameters:
Parameter | Description | Default |
-------------------------------- |------------ |----------- |
-knox.token.audience.validator | Selects the audience validator strategy. `static` ignores the per-request `audience` parameter and uses `knox.token.audiences`; `whitelist` honors requested audiences that appear in `knox.token.audiences`; `passthrough` accepts any requested audience without a whitelist (no `aud` when the parameter is absent). | static |
+knox.token.audience.validator | Selects the audience validator strategy. `static` ignores the per-request `resource` parameter and uses `knox.token.audiences`; `whitelist` honors requested resources that appear in `knox.token.audiences`; `passthrough` accepts any requested resource without a whitelist (no `aud` when the parameter is absent). | static |
#### KnoxToken Renewal, Revocation and Enable/Disable actions
From 658b1b0e0d65f2e4c62fc8a5fcb8ab317b5a6787 Mon Sep 17 00:00:00 2001
From: Sandor Molnar
Date: Wed, 2 Sep 2026 11:21:48 +0200
Subject: [PATCH 2/5] KNOX-3424: Wire RFC 8693 resource/audience body params
into token exchange
The JWTProvider's TokenExchangeHandler now reads the optional RFC 8693
section 2.1 resource/audience body parameters from the token-exchange
request and conveys them to the downstream KNOXTOKEN service, which mints
the token and resolves the aud claim through its configured audience
validator.
- resource values are validated as absolute URIs without a fragment
(RFC 8707 section 2 / RFC 3986 section 4.3); a malformed value is
rejected with the invalid_target error code.
- audience values are logical service names and are taken verbatim.
- Both parameters may be repeated and may carry comma-separated lists.
- The parsed values are stashed on the request via the new
CommonTokenConstants.REQUESTED_AUDIENCES_REQUEST_ATTR attribute; when
present they take precedence over the resource query parameter that
KNOXTOKEN would otherwise honor.
Co-Authored-By: Claude Opus 4.8
---
.../jwt/filter/TokenExchangeHandler.java | 88 +++++++++++++++
.../jwt/filter/TokenExchangeHandlerTest.java | 105 ++++++++++++++++++
.../service/knoxtoken/TokenResource.java | 13 ++-
.../knoxtoken/TokenServiceResourceTest.java | 56 ++++++++++
.../security/CommonTokenConstants.java | 20 ++++
5 files changed, 281 insertions(+), 1 deletion(-)
diff --git a/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandler.java b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandler.java
index 2810c4af0..7ff5353fd 100644
--- a/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandler.java
+++ b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandler.java
@@ -17,6 +17,7 @@
package org.apache.knox.gateway.provider.federation.jwt.filter;
import org.apache.knox.gateway.security.ActorChainPrincipalImpl;
+import org.apache.knox.gateway.security.CommonTokenConstants;
import org.apache.knox.gateway.security.PrimaryPrincipal;
import org.apache.knox.gateway.security.TokenExchangePrincipal;
import org.apache.knox.gateway.security.TokenExchangePrincipalImpl;
@@ -32,8 +33,11 @@
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
import java.security.Principal;
import java.text.ParseException;
+import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
@@ -57,6 +61,14 @@
* {@code actor_token} is present the request is treated as delegation (on-behalf-of): the actor is
* the authenticated party and the subject is the impersonated party; otherwise the subject_token is
* simply exchanged for a token representing the subject.
+ *
+ *
The optional RFC 8693 section 2.1 {@code resource} and {@code audience} body parameters are
+ * read here (from the same {@code x-www-form-urlencoded} body) and conveyed to the downstream
+ * KNOXTOKEN service via {@link CommonTokenConstants#REQUESTED_AUDIENCES_REQUEST_ATTR} so they land
+ * in the minted token's {@code aud} claim. {@code resource} values must be absolute URIs without a
+ * fragment (RFC 8707 section 2 / RFC 3986 section 4.3); {@code audience} values are logical service
+ * names and are not URI-constrained. When present, these body values take precedence over the
+ * {@code resource} query parameter that KNOXTOKEN would otherwise honor.
*/
class TokenExchangeHandler {
@@ -124,6 +136,19 @@ void handle(HttpServletRequest request, HttpServletResponse response, FilterChai
return;
}
+ // RFC 8693 section 2.1: the optional resource/audience parameters identify the target service(s)
+ // the returned token is intended for. They are parsed here and conveyed to the KNOXTOKEN service,
+ // which mints the token and resolves the aud claim through its configured audience validator.
+ final List requestedAudiences;
+ try {
+ requestedAudiences = parseRequestedAudiences(bodyRequest);
+ } catch (InvalidResourceException e) {
+ // RFC 8707 section 2: a malformed resource yields the invalid_target error code.
+ filter.handleValidationError(request, response, HttpServletResponse.SC_BAD_REQUEST,
+ "invalid_target", e.getMessage());
+ return;
+ }
+
try {
final JWT subjectToken = filter.parseAndValidateJWT(request, response, chain, subjectTokenValue);
if (subjectToken == null) {
@@ -145,6 +170,13 @@ void handle(HttpServletRequest request, HttpServletResponse response, FilterChai
subject = filter.createSubjectFromToken(subjectToken);
}
+ // Convey the requested resource/audience to the downstream KNOXTOKEN service. Set only when
+ // present so that KNOXTOKEN falls back to its resource query parameter otherwise; when set,
+ // the body value takes precedence over the query parameter.
+ if (!requestedAudiences.isEmpty()) {
+ request.setAttribute(CommonTokenConstants.REQUESTED_AUDIENCES_REQUEST_ATTR, requestedAudiences);
+ }
+
filter.continueWithEstablishedSecurityContext(subject, request, response, chain);
} catch (ParseException | UnknownTokenException e) {
filter.handleValidationError(request, response, HttpServletResponse.SC_UNAUTHORIZED,
@@ -199,4 +231,60 @@ private Subject createSubjectForTokenExchange(JWT subjectToken, JWT actorToken)
final HashSet emptySet = new HashSet();
return new Subject(true, principals, emptySet, emptySet);
}
+
+ /**
+ * Parse the optional RFC 8693 section 2.1 {@code resource} and {@code audience} body parameters
+ * into the list of requested audiences for the token being minted. Both parameters may be repeated
+ * and may also carry a comma-separated list of values. {@code resource} values are validated as
+ * absolute URIs; {@code audience} values are logical names and are taken verbatim.
+ *
+ * @param bodyRequest the unwrapped request exposing the form body
+ * @return the requested audiences, in the order resource-then-audience; never null
+ * @throws InvalidResourceException if a {@code resource} value is not an absolute URI without a fragment
+ */
+ private List parseRequestedAudiences(HttpServletRequest bodyRequest) throws InvalidResourceException {
+ final List requested = new ArrayList<>();
+ addValues(bodyRequest.getParameterValues(CommonTokenConstants.RESOURCE), requested, true);
+ addValues(bodyRequest.getParameterValues(CommonTokenConstants.AUDIENCE), requested, false);
+ return requested;
+ }
+
+ private void addValues(String[] rawValues, List target, boolean validateAsUri) throws InvalidResourceException {
+ if (rawValues == null) {
+ return;
+ }
+ for (String rawValue : rawValues) {
+ for (String value : rawValue.split(",")) {
+ final String trimmed = value.trim();
+ if (validateAsUri) {
+ validateResourceUri(trimmed);
+ }
+ target.add(trimmed);
+ }
+ }
+ }
+
+ /**
+ * Validate that a {@code resource} value is an absolute URI without a fragment, as required by
+ * RFC 8707 section 2 and RFC 3986 section 4.3. A query component is permitted; a fragment is not.
+ */
+ private void validateResourceUri(String value) throws InvalidResourceException {
+ final URI uri;
+ try {
+ uri = new URI(value);
+ } catch (URISyntaxException e) {
+ throw new InvalidResourceException("the requested resource '" + value + "' is not a valid URI");
+ }
+ if (!uri.isAbsolute() || uri.getFragment() != null) {
+ throw new InvalidResourceException(
+ "the requested resource '" + value + "' must be an absolute URI without a fragment");
+ }
+ }
+
+ /** Signals an RFC 8707 {@code invalid_target}: a malformed {@code resource} indicator. */
+ private static final class InvalidResourceException extends Exception {
+ InvalidResourceException(String message) {
+ super(message);
+ }
+ }
}
diff --git a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandlerTest.java b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandlerTest.java
index f4e326237..85a7d9927 100644
--- a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandlerTest.java
+++ b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandlerTest.java
@@ -22,10 +22,12 @@
import static org.junit.Assert.assertTrue;
import org.apache.knox.gateway.security.ActorChainPrincipal;
+import org.apache.knox.gateway.security.CommonTokenConstants;
import org.apache.knox.gateway.security.PrimaryPrincipal;
import org.apache.knox.gateway.security.TokenExchangePrincipal;
import org.apache.knox.gateway.services.security.token.impl.JWT;
import org.apache.knox.gateway.services.security.token.impl.JWTToken;
+import org.easymock.Capture;
import org.easymock.EasyMock;
import org.junit.Before;
import org.junit.Test;
@@ -38,6 +40,7 @@
import java.io.IOException;
import java.text.ParseException;
+import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
@@ -58,6 +61,7 @@ public class TokenExchangeHandlerTest {
private TokenExchangeHandler handler;
private HttpServletResponse response;
private FilterChain chain;
+ private Capture