Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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.</p>
*
* <p>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.</p>
*/
class TokenExchangeHandler {

Expand Down Expand Up @@ -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<String> 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) {
Expand All @@ -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,
Expand Down Expand Up @@ -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<String> parseRequestedAudiences(HttpServletRequest bodyRequest) throws InvalidResourceException {
final List<String> 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<String> 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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -58,6 +61,7 @@ public class TokenExchangeHandlerTest {
private TokenExchangeHandler handler;
private HttpServletResponse response;
private FilterChain chain;
private Capture<Object> requestedAudiencesAttr;

@Before
public void setUp() {
Expand Down Expand Up @@ -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();
}
Expand All @@ -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();
Expand Down
Loading
Loading