Skip to content

ReactiveJwtDecoder via OIDC Provider Configuration #5719

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Aug 21, 2018
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 @@ -40,6 +40,8 @@
import com.nimbusds.jwt.proc.JWTProcessor;
import reactor.core.publisher.Mono;

import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jose.jws.JwsAlgorithms;
import org.springframework.util.Assert;

Expand Down Expand Up @@ -67,6 +69,8 @@ public final class NimbusReactiveJwtDecoder implements ReactiveJwtDecoder {

private final JWKSelectorFactory jwkSelectorFactory;

private OAuth2TokenValidator<Jwt> jwtValidator = JwtValidators.createDefault();

public NimbusReactiveJwtDecoder(RSAPublicKey publicKey) {
JWSAlgorithm algorithm = JWSAlgorithm.parse(JwsAlgorithms.RS256);

Expand All @@ -77,6 +81,7 @@ public NimbusReactiveJwtDecoder(RSAPublicKey publicKey) {
new JWSVerificationKeySelector<>(algorithm, jwkSource);
DefaultJWTProcessor jwtProcessor = new DefaultJWTProcessor<>();
jwtProcessor.setJWSKeySelector(jwsKeySelector);
jwtProcessor.setJWTClaimsSetVerifier((claims, context) -> {});

this.jwtProcessor = jwtProcessor;
this.reactiveJwkSource = new ReactiveJWKSourceAdapter(jwkSource);
Expand All @@ -98,6 +103,7 @@ public NimbusReactiveJwtDecoder(String jwkSetUrl) {

DefaultJWTProcessor<JWKContext> jwtProcessor = new DefaultJWTProcessor<>();
jwtProcessor.setJWSKeySelector(jwsKeySelector);
jwtProcessor.setJWTClaimsSetVerifier((claims, context) -> {});
this.jwtProcessor = jwtProcessor;

this.reactiveJwkSource = new ReactiveRemoteJWKSource(jwkSetUrl);
Expand All @@ -106,6 +112,16 @@ public NimbusReactiveJwtDecoder(String jwkSetUrl) {

}

/**
* Use the provided {@link OAuth2TokenValidator} to validate incoming {@link Jwt}s.
*
* @param jwtValidator the {@link OAuth2TokenValidator} to use
*/
public void setJwtValidator(OAuth2TokenValidator<Jwt> jwtValidator) {
Assert.notNull(jwtValidator, "jwtValidator cannot be null");
this.jwtValidator = jwtValidator;
}

@Override
public Mono<Jwt> decode(String token) throws JwtException {
JWT jwt = parse(token);
Expand All @@ -131,7 +147,8 @@ private Mono<Jwt> decode(SignedJWT parsedToken) {
.onErrorMap(e -> new IllegalStateException("Could not obtain the keys", e))
.map(jwkList -> createClaimsSet(parsedToken, jwkList))
.map(set -> createJwt(parsedToken, set))
.onErrorMap(e -> !(e instanceof IllegalStateException), e -> new JwtException("An error occurred while attempting to decode the Jwt: ", e));
.map(this::validateJwt)
.onErrorMap(e -> !(e instanceof IllegalStateException) && !(e instanceof JwtException), e -> new JwtException("An error occurred while attempting to decode the Jwt: ", e));
} catch (RuntimeException ex) {
throw new JwtException("An error occurred while attempting to decode the Jwt: " + ex.getMessage(), ex);
}
Expand Down Expand Up @@ -164,6 +181,17 @@ private Jwt createJwt(JWT parsedJwt, JWTClaimsSet jwtClaimsSet) {
return new Jwt(parsedJwt.getParsedString(), issuedAt, expiresAt, headers, jwtClaimsSet.getClaims());
}

private Jwt validateJwt(Jwt jwt) {
OAuth2TokenValidatorResult result = this.jwtValidator.validate(jwt);

if ( result.hasErrors() ) {
String message = result.getErrors().iterator().next().getDescription();
throw new JwtValidationException(message, result.getErrors());
}

return jwt;
}

private static RSAKey rsaKey(RSAPublicKey publicKey) {
return new RSAKey.Builder(publicKey)
.build();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
* Copyright 2002-2018 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.oauth2.jwt;

import com.nimbusds.oauth2.sdk.ParseException;
import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata;

import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.web.client.RestTemplate;

/**
* Allows creating a {@link ReactiveJwtDecoder} from an
* <a href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig">OpenID Provider Configuration</a>.
*
* @author Josh Cummings
* @since 5.1
*/
public final class ReactiveJwtDecoders {

/**
* Creates a {@link ReactiveJwtDecoder} using the provided
* <a href="http://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier">Issuer</a> by making an
* <a href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest">OpenID Provider
* Configuration Request</a> and using the values in the
* <a href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse">OpenID
* Provider Configuration Response</a> to initialize the {@link ReactiveJwtDecoder}.
*
* @param oidcIssuerLocation the <a href="http://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier">Issuer</a>
* @return a {@link ReactiveJwtDecoder} that was initialized by the OpenID Provider Configuration.
*/
public static ReactiveJwtDecoder fromOidcIssuerLocation(String oidcIssuerLocation) {
String openidConfiguration = getOpenidConfiguration(oidcIssuerLocation);
OIDCProviderMetadata metadata = parse(openidConfiguration);
String metadataIssuer = metadata.getIssuer().getValue();
if (!oidcIssuerLocation.equals(metadataIssuer)) {
throw new IllegalStateException("The Issuer \"" + metadataIssuer + "\" provided in the OpenID Configuration " +
"did not match the requested issuer \"" + oidcIssuerLocation + "\"");
}

OAuth2TokenValidator<Jwt> jwtValidator =
JwtValidators.createDefaultWithIssuer(oidcIssuerLocation);

NimbusReactiveJwtDecoder jwtDecoder =
new NimbusReactiveJwtDecoder(metadata.getJWKSetURI().toASCIIString());
jwtDecoder.setJwtValidator(jwtValidator);

return jwtDecoder;
}

private static String getOpenidConfiguration(String issuer) {
RestTemplate rest = new RestTemplate();
try {
return rest.getForObject(issuer + "/.well-known/openid-configuration", String.class);
} catch(RuntimeException e) {
throw new IllegalArgumentException("Unable to resolve the OpenID Configuration with the provided Issuer of " +
"\"" + issuer + "\"", e);
}
}

private static OIDCProviderMetadata parse(String body) {
try {
return OIDCProviderMetadata.parse(body);
}
catch (ParseException e) {
throw new RuntimeException(e);
}
}

private ReactiveJwtDecoders() {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,28 @@

package org.springframework.security.oauth2.jwt;

import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;

import java.net.UnknownHostException;
import java.security.KeyFactory;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.Date;

import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;

import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

/**
* @author Rob Winch
Expand Down Expand Up @@ -114,7 +121,7 @@ public void decodeWhenIssuedAtThenSuccess() {
@Test
public void decodeWhenExpiredThenFail() {
assertThatCode(() -> this.decoder.decode(this.expired).block())
.isInstanceOf(JwtException.class);
.isInstanceOf(JwtValidationException.class);
}

@Test
Expand Down Expand Up @@ -155,4 +162,24 @@ public void decodeWhenUnsignedTokenThenMessageDoesNotMentionClass() {
.isInstanceOf(JwtException.class)
.hasMessage("Unsupported algorithm of none");
}

@Test
public void decodeWhenUsingCustomValidatorThenValidatorIsInvoked() {
OAuth2TokenValidator jwtValidator = mock(OAuth2TokenValidator.class);
this.decoder.setJwtValidator(jwtValidator);

OAuth2Error error = new OAuth2Error("mock-error", "mock-description", "mock-uri");
OAuth2TokenValidatorResult result = OAuth2TokenValidatorResult.failure(error);
when(jwtValidator.validate(any(Jwt.class))).thenReturn(result);

assertThatCode(() -> this.decoder.decode(messageReadToken).block())
.isInstanceOf(JwtException.class)
.hasMessageContaining("mock-description");
}

@Test
public void setJwtValidatorWhenGivenNullThrowsIllegalArgumentException() {
assertThatCode(() -> this.decoder.setJwtValidator(null))
.isInstanceOf(IllegalArgumentException.class);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/*
* Copyright 2002-2018 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.oauth2.jwt;

import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;

import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;

import static org.assertj.core.api.Assertions.assertThatCode;

/**
* Tests for {@link ReactiveJwtDecoders}
*
* @author Josh Cummings
*/
public class ReactiveJwtDecodersTests {
/**
* Contains those parameters required to construct a ReactiveJwtDecoder as well as any required parameters
*/
private static final String DEFAULT_RESPONSE_TEMPLATE =
"{\n"
+ " \"authorization_endpoint\": \"https://example.com/o/oauth2/v2/auth\", \n"
+ " \"id_token_signing_alg_values_supported\": [\n"
+ " \"RS256\"\n"
+ " ], \n"
+ " \"issuer\": \"%s\", \n"
+ " \"jwks_uri\": \"%s/.well-known/jwks.json\", \n"
+ " \"response_types_supported\": [\n"
+ " \"code\", \n"
+ " \"token\", \n"
+ " \"id_token\", \n"
+ " \"code token\", \n"
+ " \"code id_token\", \n"
+ " \"token id_token\", \n"
+ " \"code token id_token\", \n"
+ " \"none\"\n"
+ " ], \n"
+ " \"subject_types_supported\": [\n"
+ " \"public\"\n"
+ " ], \n"
+ " \"token_endpoint\": \"https://example.com/oauth2/v4/token\"\n"
+ "}";

private static final String JWK_SET = "{\"keys\":[{\"p\":\"49neceJFs8R6n7WamRGy45F5Tv0YM-R2ODK3eSBUSLOSH2tAqjEVKOkLE5fiNA3ygqq15NcKRadB2pTVf-Yb5ZIBuKzko8bzYIkIqYhSh_FAdEEr0vHF5fq_yWSvc6swsOJGqvBEtuqtJY027u-G2gAQasCQdhyejer68zsTn8M\",\"kty\":\"RSA\",\"q\":\"tWR-ysspjZ73B6p2vVRVyHwP3KQWL5KEQcdgcmMOE_P_cPs98vZJfLhxobXVmvzuEWBpRSiqiuyKlQnpstKt94Cy77iO8m8ISfF3C9VyLWXi9HUGAJb99irWABFl3sNDff5K2ODQ8CmuXLYM25OwN3ikbrhEJozlXg_NJFSGD4E\",\"d\":\"FkZHYZlw5KSoqQ1i2RA2kCUygSUOf1OqMt3uomtXuUmqKBm_bY7PCOhmwbvbn4xZYEeHuTR8Xix-0KpHe3NKyWrtRjkq1T_un49_1LLVUhJ0dL-9_x0xRquVjhl_XrsRXaGMEHs8G9pLTvXQ1uST585gxIfmCe0sxPZLvwoic-bXf64UZ9BGRV3lFexWJQqCZp2S21HfoU7wiz6kfLRNi-K4xiVNB1gswm_8o5lRuY7zB9bRARQ3TS2G4eW7p5sxT3CgsGiQD3_wPugU8iDplqAjgJ5ofNJXZezoj0t6JMB_qOpbrmAM1EnomIPebSLW7Ky9SugEd6KMdL5lW6AuAQ\",\"e\":\"AQAB\",\"use\":\"sig\",\"kid\":\"one\",\"qi\":\"wdkFu_tV2V1l_PWUUimG516Zvhqk2SWDw1F7uNDD-Lvrv_WNRIJVzuffZ8WYiPy8VvYQPJUrT2EXL8P0ocqwlaSTuXctrORcbjwgxDQDLsiZE0C23HYzgi0cofbScsJdhcBg7d07LAf7cdJWG0YVl1FkMCsxUlZ2wTwHfKWf-v4\",\"dp\":\"uwnPxqC-IxG4r33-SIT02kZC1IqC4aY7PWq0nePiDEQMQWpjjNH50rlq9EyLzbtdRdIouo-jyQXB01K15-XXJJ60dwrGLYNVqfsTd0eGqD1scYJGHUWG9IDgCsxyEnuG3s0AwbW2UolWVSsU2xMZGb9PurIUZECeD1XDZwMp2s0\",\"dq\":\"hra786AunB8TF35h8PpROzPoE9VJJMuLrc6Esm8eZXMwopf0yhxfN2FEAvUoTpLJu93-UH6DKenCgi16gnQ0_zt1qNNIVoRfg4rw_rjmsxCYHTVL3-RDeC8X_7TsEySxW0EgFTHh-nr6I6CQrAJjPM88T35KHtdFATZ7BCBB8AE\",\"n\":\"oXJ8OyOv_eRnce4akdanR4KYRfnC2zLV4uYNQpcFn6oHL0dj7D6kxQmsXoYgJV8ZVDn71KGmuLvolxsDncc2UrhyMBY6DVQVgMSVYaPCTgW76iYEKGgzTEw5IBRQL9w3SRJWd3VJTZZQjkXef48Ocz06PGF3lhbz4t5UEZtdF4rIe7u-977QwHuh7yRPBQ3sII-cVoOUMgaXB9SHcGF2iZCtPzL_IffDUcfhLQteGebhW8A6eUHgpD5A1PQ-JCw_G7UOzZAjjDjtNM2eqm8j-Ms_gqnm4MiCZ4E-9pDN77CAAPVN7kuX6ejs9KBXpk01z48i9fORYk9u7rAkh1HuQw\"}]}";
private static final String ISSUER_MISMATCH = "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczpcL1wvd3Jvbmdpc3N1ZXIiLCJleHAiOjQ2ODcyNTYwNDl9.Ax8LMI6rhB9Pv_CE3kFi1JPuLj9gZycifWrLeDpkObWEEVAsIls9zAhNFyJlG-Oo7up6_mDhZgeRfyKnpSF5GhKJtXJDCzwg0ZDVUE6rS0QadSxsMMGbl7c4y0lG_7TfLX2iWeNJukJj_oSW9KzW4FsBp1BoocWjrreesqQU3fZHbikH-c_Fs2TsAIpHnxflyEzfOFWpJ8D4DtzHXqfvieMwpy42xsPZK3LR84zlasf0Ne1tC_hLHvyHRdAXwn0CMoKxc7-8j0r9Mq8kAzUsPn9If7bMLqGkxUcTPdk5x7opAUajDZx95SXHLmtztNtBa2S6EfPJXuPKG6tM5Wq5Ug";

private MockWebServer server;
private String issuer;
private String jwkSetUri;

@Before
public void setup() throws Exception {
this.server = new MockWebServer();
this.server.start();
this.issuer = createIssuerFromServer();
this.jwkSetUri = this.issuer + "/.well-known/jwks.json";
}

@After
public void cleanup() throws Exception {
this.server.shutdown();
}

@Test
public void issuerWhenResponseIsTypicalThenReturnedDecoderValidatesIssuer() {
prepareOpenIdConfigurationResponse();
this.server.enqueue(new MockResponse().setBody(JWK_SET));

ReactiveJwtDecoder decoder = ReactiveJwtDecoders.fromOidcIssuerLocation(this.issuer);

assertThatCode(() -> decoder.decode(ISSUER_MISMATCH).block())
.isInstanceOf(JwtValidationException.class)
.hasMessageContaining("This iss claim is not equal to the configured issuer");
}

@Test
public void issuerWhenResponseIsNonCompliantThenThrowsRuntimeException() {
prepareOpenIdConfigurationResponse("{ \"missing_required_keys\" : \"and_values\" }");

assertThatCode(() -> ReactiveJwtDecoders.fromOidcIssuerLocation(this.issuer))
.isInstanceOf(RuntimeException.class);
}

@Test
public void issuerWhenResponseIsMalformedThenThrowsRuntimeException() {
prepareOpenIdConfigurationResponse("malformed");

assertThatCode(() -> ReactiveJwtDecoders.fromOidcIssuerLocation(this.issuer))
.isInstanceOf(RuntimeException.class);
}

@Test
public void issuerWhenRespondingIssuerMismatchesRequestedIssuerThenThrowsIllegalStateException() {
prepareOpenIdConfigurationResponse();

assertThatCode(() -> ReactiveJwtDecoders.fromOidcIssuerLocation(this.issuer + "/wrong"))
.isInstanceOf(IllegalStateException.class);
}

@Test
public void issuerWhenRequestedIssuerIsUnresponsiveThenThrowsIllegalArgumentException()
throws Exception {

this.server.shutdown();

assertThatCode(() -> ReactiveJwtDecoders.fromOidcIssuerLocation("https://issuer"))
.isInstanceOf(IllegalArgumentException.class);
}

private void prepareOpenIdConfigurationResponse() {
String body = String.format(DEFAULT_RESPONSE_TEMPLATE, this.issuer, this.issuer);
prepareOpenIdConfigurationResponse(body);
}

private void prepareOpenIdConfigurationResponse(String body) {
MockResponse mockResponse = new MockResponse()
.setBody(body)
.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
this.server.enqueue(mockResponse);
}

private String createIssuerFromServer() {
return this.server.url("").toString();
}
}