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 2810c4af0f..7ff5353fd9 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 f4e326237b..85a7d99279 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 818dddd2cb..1b602a8b3d 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;
@@ -33,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;
@@ -71,6 +74,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;
@@ -125,7 +129,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 = 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";
@@ -221,7 +225,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,11 +925,11 @@ 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,
- "{\n \"error\": \"" + e.getMessage() + "\",\n \"code\": " + e.getErrorCode().toInt() + "\n}\n",
+ errorResponseBody(e.getMessage(), e.getErrorCode()),
Response.status(Response.Status.BAD_REQUEST));
}
@@ -1163,26 +1168,64 @@ public ResponseMap(String accessToken, String tokenId, Map map,
}
}
- private List parseRequestedAudiences() {
+ private List parseRequestedResources() throws RequestedAudienceValidationException {
+ // 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 requestedAudiences =
+ (List) request.getAttribute(CommonTokenConstants.REQUESTED_AUDIENCES_REQUEST_ATTR);
+ if (requestedAudiences != null) {
+ return requestedAudiences;
+ }
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;
+ }
+
+ /**
+ * 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/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 ad532f0899..f05fad2cb6 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 251ea0dd8f..a02420c686 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 6875f13b4c..7f708dd194 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;
@@ -133,7 +134,8 @@ public class TokenServiceResourceTest {
private ServletContext context;
private HttpServletRequest request;
- private String[] audienceParamValues;
+ private String[] resourceParamValues;
+ private List exchangeRequestedAudiences;
private JWTokenAuthority authority;
private TestTokenStateService tss = new TestTokenStateService();
private char[] hmacSecret;
@@ -200,10 +202,14 @@ 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();
+ 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();
@@ -437,7 +443,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 +465,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 +516,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 +534,66 @@ 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 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 testDynamicAudienceRejectedWhenNotWhitelisted() throws Exception {
- audienceParamValues = new String[] { "recipient1", "intruder" };
+ 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" };
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();
@@ -550,11 +606,73 @@ public void testDynamicAudienceRejectedWhenNotWhitelisted() throws Exception {
}
@Test
- public void testDynamicAudienceMultipleValuesAndCommaSeparated() throws Exception {
- audienceParamValues = new String[] { "recipient1", " recipient2 , recipient3" };
+ 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();
+ tr.request = request;
+ tr.context = context;
+ tr.init();
+
+ Response retResponse = tr.doGet();
+ assertEquals(400, retResponse.getStatus());
+ final Map json = parseJSONResponse((String) retResponse.getEntity());
+ assertEquals(TokenResource.ErrorCode.INVALID_RESOURCE.toInt(), json.get("code"));
+ }
+
+ @Test
+ 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());
+ 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
+ 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 +686,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/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 2f28b96293..b90204cc31 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,25 @@ 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 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 token service would otherwise honor. The value is a
+ * {@code List}.
+ */
+ String REQUESTED_AUDIENCES_REQUEST_ATTR = "knox.token.requested.audiences";
+
}
diff --git a/knox-site/docs/config_knox_token.md b/knox-site/docs/config_knox_token.md
index 98fc38f092..4ceed94bb0 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
+* 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