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 requestedAudiencesAttr; @Before public void setUp() { @@ -203,6 +207,86 @@ public void testUnparseableSubjectTokenReturnsInvalidRequest() throws Exception assertFalse(filter.continued); } + @Test + public void testResourceBodyParamConveyedAsRequestedAudiences() throws Exception { + filter.valid.put("subtok", jwt("alice", "KNOXSSO")); + handler.handle(exchangeRequest("subtok", + new String[] {"https://recipient1", "https://recipient2"}, null), response, chain); + + assertTrue(filter.continued); + assertTrue(requestedAudiencesAttr.hasCaptured()); + assertEquals(Arrays.asList("https://recipient1", "https://recipient2"), requestedAudiencesAttr.getValue()); + } + + @Test + public void testAudienceBodyParamConveyedAsRequestedAudiences() throws Exception { + filter.valid.put("subtok", jwt("alice", "KNOXSSO")); + // RFC 8693 audience is a logical service name and is not URI-constrained + handler.handle(exchangeRequest("subtok", null, new String[] {"service-a"}), response, chain); + + assertTrue(filter.continued); + assertEquals(Arrays.asList("service-a"), requestedAudiencesAttr.getValue()); + } + + @Test + public void testResourceAndAudienceCombinedResourceFirst() throws Exception { + filter.valid.put("subtok", jwt("alice", "KNOXSSO")); + handler.handle(exchangeRequest("subtok", + new String[] {"https://recipient1"}, new String[] {"service-a"}), response, chain); + + assertEquals(Arrays.asList("https://recipient1", "service-a"), requestedAudiencesAttr.getValue()); + } + + @Test + public void testCommaSeparatedResourceValuesAreSplit() throws Exception { + filter.valid.put("subtok", jwt("alice", "KNOXSSO")); + handler.handle(exchangeRequest("subtok", + new String[] {"https://recipient1, https://recipient2"}, null), response, chain); + + assertEquals(Arrays.asList("https://recipient1", "https://recipient2"), requestedAudiencesAttr.getValue()); + } + + @Test + public void testInvalidResourceUriRejectedAsInvalidTarget() throws Exception { + filter.valid.put("subtok", jwt("alice", "KNOXSSO")); + handler.handle(exchangeRequest("subtok", new String[] {"not-a-uri"}, null), response, chain); + + assertEquals(HttpServletResponse.SC_BAD_REQUEST, filter.errorStatus); + assertEquals("invalid_target", filter.error); + assertFalse(filter.continued); + } + + @Test + public void testResourceUriWithFragmentRejectedAsInvalidTarget() throws Exception { + filter.valid.put("subtok", jwt("alice", "KNOXSSO")); + handler.handle(exchangeRequest("subtok", new String[] {"https://recipient1#fragment"}, null), response, chain); + + assertEquals(HttpServletResponse.SC_BAD_REQUEST, filter.errorStatus); + assertEquals("invalid_target", filter.error); + assertFalse(filter.continued); + } + + @Test + public void testEmptyResourceValueRejectedAsInvalidTarget() throws Exception { + // An empty resource value (e.g. "resource=") is not an absolute URI and is surfaced as an error + // rather than silently dropped. + filter.valid.put("subtok", jwt("alice", "KNOXSSO")); + handler.handle(exchangeRequest("subtok", new String[] {""}, null), response, chain); + + assertEquals(HttpServletResponse.SC_BAD_REQUEST, filter.errorStatus); + assertEquals("invalid_target", filter.error); + assertFalse(filter.continued); + } + + @Test + public void testNoResourceOrAudienceLeavesRequestAttributeUnset() throws Exception { + filter.valid.put("subtok", jwt("alice", "KNOXSSO")); + handler.handle(exchangeRequest("subtok", null, null), response, chain); + + assertTrue(filter.continued); + assertFalse(requestedAudiencesAttr.hasCaptured()); + } + private static String primaryName(Subject subject) { return subject.getPrincipals(PrimaryPrincipal.class).iterator().next().getName(); } @@ -218,6 +302,27 @@ private HttpServletRequest request(String subjectToken, String subjectTokenType, return request; } + /** + * Build a subject-only token-exchange request carrying the given {@code resource}/{@code audience} + * body parameters, capturing the requested-audiences request attribute the handler stashes for the + * downstream KNOXTOKEN service. + */ + private HttpServletRequest exchangeRequest(String subjectToken, String[] resources, String[] audiences) { + final HttpServletRequest request = EasyMock.createNiceMock(HttpServletRequest.class); + EasyMock.expect(request.getParameter(JWTFederationFilter.SUBJECT_TOKEN)).andReturn(subjectToken).anyTimes(); + EasyMock.expect(request.getParameter(JWTFederationFilter.SUBJECT_TOKEN_TYPE)).andReturn(JWT_TYPE).anyTimes(); + EasyMock.expect(request.getParameter(JWTFederationFilter.ACTOR_TOKEN)).andReturn(null).anyTimes(); + EasyMock.expect(request.getParameter(JWTFederationFilter.ACTOR_TOKEN_TYPE)).andReturn(null).anyTimes(); + EasyMock.expect(request.getParameterValues(CommonTokenConstants.RESOURCE)).andReturn(resources).anyTimes(); + EasyMock.expect(request.getParameterValues(CommonTokenConstants.AUDIENCE)).andReturn(audiences).anyTimes(); + requestedAudiencesAttr = EasyMock.newCapture(); + request.setAttribute(EasyMock.eq(CommonTokenConstants.REQUESTED_AUDIENCES_REQUEST_ATTR), + EasyMock.capture(requestedAudiencesAttr)); + EasyMock.expectLastCall().anyTimes(); + EasyMock.replay(request); + return request; + } + private static JWT jwt(String subject, String issuer) { final JWT jwt = EasyMock.createNiceMock(JWT.class); EasyMock.expect(jwt.getSubject()).andReturn(subject).anyTimes(); 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 1a3db3ef2..190b3d02d 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 @@ -73,6 +73,7 @@ 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.CommonTokenConstants; import org.apache.knox.gateway.security.GroupPrincipal; import org.apache.knox.gateway.security.SubjectUtils; import org.apache.knox.gateway.security.TokenIdPrincipal; @@ -127,7 +128,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 RESOURCE_QUERY_PARAM = "resource"; + static final String RESOURCE_QUERY_PARAM = CommonTokenConstants.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"; @@ -1167,6 +1168,16 @@ public ResponseMap(String accessToken, String tokenId, Map map, } private List parseRequestedResources() throws RequestedAudienceValidationException { + // An RFC 8693 token-exchange request carries the requested resource/audience in the form body, + // which the JWTProvider's TokenExchangeHandler has already parsed and validated and stashed as a + // request attribute. When present, those body-supplied values take precedence over the resource + // query parameter. + @SuppressWarnings("unchecked") + final List fromExchange = + (List) request.getAttribute(CommonTokenConstants.REQUESTED_AUDIENCES_REQUEST_ATTR); + if (fromExchange != null) { + return fromExchange; + } final Map parameterMap = request.getParameterMap(); final String[] rawValues = parameterMap == null ? null : parameterMap.get(RESOURCE_QUERY_PARAM); final List requested = new ArrayList<>(); 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 4a03a24e6..5007f3c10 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 @@ -89,6 +89,7 @@ import org.apache.commons.lang3.StringUtils; import org.apache.knox.gateway.config.GatewayConfig; import org.apache.knox.gateway.context.ContextAttributes; +import org.apache.knox.gateway.security.CommonTokenConstants; import org.apache.knox.gateway.security.GroupPrincipal; import org.apache.knox.gateway.security.ImpersonatedPrincipal; import org.apache.knox.gateway.security.PrimaryPrincipal; @@ -134,6 +135,7 @@ public class TokenServiceResourceTest { private ServletContext context; private HttpServletRequest request; private String[] resourceParamValues; + private List exchangeRequestedAudiences; private JWTokenAuthority authority; private TestTokenStateService tss = new TestTokenStateService(); private char[] hmacSecret; @@ -204,6 +206,10 @@ private void configureCommonExpectations(Map contextExpectations parameterMap.put(TokenResource.RESOURCE_QUERY_PARAM, resourceParamValues); } EasyMock.expect(request.getParameterMap()).andReturn(parameterMap).anyTimes(); + if (exchangeRequestedAudiences != null) { + EasyMock.expect(request.getAttribute(CommonTokenConstants.REQUESTED_AUDIENCES_REQUEST_ATTR)) + .andReturn(exchangeRequestedAudiences).anyTimes(); + } GatewayServices services = EasyMock.createNiceMock(GatewayServices.class); EasyMock.expect(context.getAttribute(GatewayServices.GATEWAY_SERVICES_ATTRIBUTE)).andReturn(services).anyTimes(); @@ -532,6 +538,56 @@ public void testDynamicResourceAllowedWhenWhitelisted() throws Exception { assertFalse(audiences.contains("https://recipient2")); } + @Test + public void testExchangeRequestedAudiencesFromRequestAttributeAreHonored() throws Exception { + // An RFC 8693 token-exchange request conveys its already-validated resource/audience via a + // request attribute (set by the JWTProvider's TokenExchangeHandler). The audience value is a + // logical name and reaches the aud claim through the passthrough validator. + exchangeRequestedAudiences = Arrays.asList("https://recipient1", "service-a"); + 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(200, retResponse.getStatus()); + + JWT parsedToken = new JWTToken(getTagValue(retResponse.getEntity().toString(), "access_token")); + List audiences = Arrays.asList(parsedToken.getAudienceClaims()); + assertEquals(2, audiences.size()); + assertTrue(audiences.contains("https://recipient1")); + assertTrue(audiences.contains("service-a")); + } + + @Test + public void testExchangeRequestedAudiencesTakePrecedenceOverQueryParam() throws Exception { + // When the token-exchange body value is present it takes precedence over the resource query + // parameter, which must be ignored entirely. + exchangeRequestedAudiences = Arrays.asList("https://from-body"); + resourceParamValues = new String[] { "https://from-query" }; + 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(200, retResponse.getStatus()); + + JWT parsedToken = new JWTToken(getTagValue(retResponse.getEntity().toString(), "access_token")); + List audiences = Arrays.asList(parsedToken.getAudienceClaims()); + assertEquals(1, audiences.size()); + assertTrue(audiences.contains("https://from-body")); + assertFalse(audiences.contains("https://from-query")); + } + @Test public void testDynamicResourceRejectedWhenNotWhitelisted() throws Exception { resourceParamValues = new String[] { "https://recipient1", "https://intruder" }; diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/security/CommonTokenConstants.java b/gateway-spi/src/main/java/org/apache/knox/gateway/security/CommonTokenConstants.java index 2f28b9629..3ca65b222 100644 --- a/gateway-spi/src/main/java/org/apache/knox/gateway/security/CommonTokenConstants.java +++ b/gateway-spi/src/main/java/org/apache/knox/gateway/security/CommonTokenConstants.java @@ -29,4 +29,24 @@ public interface CommonTokenConstants { String AUTH_CODE = "authorization_code"; + /** + * RFC 8707 (Resource Indicators) / RFC 8693 (Token Exchange) {@code resource} request parameter: + * the target service for which the token is requested, expressed as an absolute URI. + */ + String RESOURCE = "resource"; + + /** + * RFC 8693 (Token Exchange) {@code audience} request parameter: the logical name of the target + * service for which the token is requested. + */ + String AUDIENCE = "audience"; + + /** + * Request attribute used to convey the requested audiences parsed from an RFC 8693 token-exchange + * body ({@code resource}/{@code audience}) from the JWTProvider's token-exchange handler to the + * downstream KNOXTOKEN service, which mints the token. When present it takes precedence over the + * {@code resource} query parameter. The value is a {@code List}. + */ + String REQUESTED_AUDIENCES_REQUEST_ATTR = "knox.token.exchange.requested.audiences"; + } From 7cd3b66f4e3f1ccd1c1befc22ef0fad28c93da99 Mon Sep 17 00:00:00 2001 From: Sandor Molnar Date: Wed, 2 Sep 2026 11:29:52 +0200 Subject: [PATCH 3/5] KNOX-3424: JSON-escape the caller-supplied resource value in the token error body The token-issuance error for a rejected resource/audience echoed the caller-supplied resource value back inside a hand-concatenated JSON body, so a value containing a double quote (or other JSON metacharacter) could break out of the JSON string and corrupt the response. Render the {"error": ..., "code": ...} body through JsonUtils instead of string concatenation, which escapes the embedded value the same way the KNOX-3423 (#1354) filter-layer error path does. The {error, code} shape and ErrorCode contract used by the token lifecycle responses are kept. Co-Authored-By: Claude Opus 4.8 --- .../service/knoxtoken/TokenResource.java | 16 +++++++++- .../knoxtoken/TokenServiceResourceTest.java | 29 +++++++++++++++++-- 2 files changed, 42 insertions(+), 3 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 190b3d02d..4249cb383 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 @@ -35,6 +35,7 @@ import java.util.Map; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.LinkedList; import java.util.List; import java.util.Locale; @@ -928,7 +929,7 @@ protected TokenResponseContext getTokenResponse(UserContext context) { } catch (RequestedAudienceValidationException e) { log.rejectedAudienceRequest(e.getMessage()); return new TokenResponseContext(null, - "{\n \"error\": \"" + e.getMessage() + "\",\n \"code\": " + e.getErrorCode().toInt() + "\n}\n", + errorResponseBody(e.getMessage(), e.getErrorCode()), Response.status(Response.Status.BAD_REQUEST)); } @@ -1213,6 +1214,19 @@ private String validateResourceUri(String value) throws RequestedAudienceValidat return value; } + /** + * Renders a token-issuance error body as {@code {"error": ..., "code": ...}}. The {@code error} + * message may embed a caller-supplied value (e.g. an invalid {@code resource} indicator), so it is + * serialized through {@link JsonUtils} to escape it rather than being concatenated verbatim, which + * would allow the value to break out of the JSON string. + */ + private static String errorResponseBody(String error, ErrorCode code) { + final Map body = new LinkedHashMap<>(); + body.put("error", error); + body.put("code", code.toInt()); + return JsonUtils.renderAsJsonString(body); + } + 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/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 5007f3c10..7f708dd19 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 @@ -621,7 +621,8 @@ public void testDynamicResourceRejectedWhenNotAValidUri() throws Exception { Response retResponse = tr.doGet(); assertEquals(400, retResponse.getStatus()); - assertTrue(retResponse.getEntity().toString().contains("\"code\": " + TokenResource.ErrorCode.INVALID_RESOURCE.toInt())); + final Map json = parseJSONResponse((String) retResponse.getEntity()); + assertEquals(TokenResource.ErrorCode.INVALID_RESOURCE.toInt(), json.get("code")); } @Test @@ -639,7 +640,31 @@ public void testDynamicResourceRejectedWhenUriHasFragment() throws Exception { Response retResponse = tr.doGet(); assertEquals(400, retResponse.getStatus()); - assertTrue(retResponse.getEntity().toString().contains("\"code\": " + TokenResource.ErrorCode.INVALID_RESOURCE.toInt())); + final Map json = parseJSONResponse((String) retResponse.getEntity()); + assertEquals(TokenResource.ErrorCode.INVALID_RESOURCE.toInt(), json.get("code")); + } + + @Test + public void testDynamicResourceRejectionEscapesCallerSuppliedValueInErrorBody() throws Exception { + // The rejected resource value is echoed back in the error message; a value containing a double + // quote must be JSON-escaped so it cannot break out of the error body's JSON string. + resourceParamValues = new String[] { "not\"a\"uri" }; + 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()); + // The body must still be parseable JSON (i.e. the quotes did not break out of the string) ... + final Map json = parseJSONResponse((String) retResponse.getEntity()); + assertEquals(TokenResource.ErrorCode.INVALID_RESOURCE.toInt(), json.get("code")); + // ... and the caller-supplied value must round-trip intact inside the error message. + assertTrue(((String) json.get("error")).contains("not\"a\"uri")); } @Test From b3c5a91eb193dd52ad7cbf3152ecb8959d5b154c Mon Sep 17 00:00:00 2001 From: Sandor Molnar Date: Wed, 2 Sep 2026 12:01:49 +0200 Subject: [PATCH 4/5] KNOX-3424: Drop redundant 'unchanged default behavior' note from resource docs Co-Authored-By: Claude Opus 4.8 --- knox-site/docs/config_knox_token.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/knox-site/docs/config_knox_token.md b/knox-site/docs/config_knox_token.md index f37b0dbc5..4ceed94bb 100644 --- a/knox-site/docs/config_knox_token.md +++ b/knox-site/docs/config_knox_token.md @@ -122,7 +122,7 @@ Selecting the `whitelist` validator enables per-request resources: With the `whitelist` validator its behavior is: -* the request does not contain a `resource` parameter -> the statically configured `knox.token.audiences` are used, exactly as before (unchanged default behavior) +* the request does not contain a `resource` parameter -> the statically configured `knox.token.audiences` are used * 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` From 13f57b298ed224a8b24c27a54d0a00e3040b4b1f Mon Sep 17 00:00:00 2001 From: Sandor Molnar Date: Thu, 3 Sep 2026 07:12:45 +0200 Subject: [PATCH 5/5] KNOX-3424: Make the requested-audiences request attribute generic, not exchange-specific Address review feedback: the KNOXTOKEN service should be about issuing tokens and not know about RFC 8693 token exchange. Rename the cross-layer request attribute value from 'knox.token.exchange.requested.audiences' to 'knox.token.requested.audiences' and reword the CommonTokenConstants javadoc and the TokenResource comment so the token service only knows that an upstream component may pre-resolve the requested audiences (taking precedence over the resource query param). The RFC 8693 specifics stay entirely within TokenExchangeHandler, the producer of the attribute. Co-Authored-By: Claude Opus 4.8 --- .../gateway/service/knoxtoken/TokenResource.java | 13 ++++++------- .../knox/gateway/security/CommonTokenConstants.java | 9 +++++---- 2 files changed, 11 insertions(+), 11 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 4249cb383..1b602a8b3 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 @@ -1169,15 +1169,14 @@ public ResponseMap(String accessToken, String tokenId, Map map, } private List parseRequestedResources() throws RequestedAudienceValidationException { - // An RFC 8693 token-exchange request carries the requested resource/audience in the form body, - // which the JWTProvider's TokenExchangeHandler has already parsed and validated and stashed as a - // request attribute. When present, those body-supplied values take precedence over the resource - // query parameter. + // An upstream authentication/federation component may have already resolved and validated the + // requested audiences for this request and stashed them as a request attribute. When present, + // those pre-resolved values take precedence over the resource query parameter. @SuppressWarnings("unchecked") - final List fromExchange = + final List requestedAudiences = (List) request.getAttribute(CommonTokenConstants.REQUESTED_AUDIENCES_REQUEST_ATTR); - if (fromExchange != null) { - return fromExchange; + if (requestedAudiences != null) { + return requestedAudiences; } final Map parameterMap = request.getParameterMap(); final String[] rawValues = parameterMap == null ? null : parameterMap.get(RESOURCE_QUERY_PARAM); diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/security/CommonTokenConstants.java b/gateway-spi/src/main/java/org/apache/knox/gateway/security/CommonTokenConstants.java index 3ca65b222..b90204cc3 100644 --- a/gateway-spi/src/main/java/org/apache/knox/gateway/security/CommonTokenConstants.java +++ b/gateway-spi/src/main/java/org/apache/knox/gateway/security/CommonTokenConstants.java @@ -42,11 +42,12 @@ public interface CommonTokenConstants { String AUDIENCE = "audience"; /** - * Request attribute used to convey the requested audiences parsed from an RFC 8693 token-exchange - * body ({@code resource}/{@code audience}) from the JWTProvider's token-exchange handler to the + * Request attribute an upstream authentication/federation component may set to convey the + * requested audiences it has already resolved and validated for the current request to the * downstream KNOXTOKEN service, which mints the token. When present it takes precedence over the - * {@code resource} query parameter. The value is a {@code List}. + * {@code resource} query parameter the token service would otherwise honor. The value is a + * {@code List}. */ - String REQUESTED_AUDIENCES_REQUEST_ATTR = "knox.token.exchange.requested.audiences"; + String REQUESTED_AUDIENCES_REQUEST_ATTR = "knox.token.requested.audiences"; }