Browse Source

Polish ClientRegistrations, (Reactive)JwtDecoders

Simplifed some of the branching logic in the implementations. Updated
the JavaDocs. Simplified some of the test support.

Issue: gh-6500
Josh Cummings 6 years ago
parent
commit
1739ef8d3c

+ 89 - 169
oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/registration/ClientRegistrations.java

@@ -16,26 +16,31 @@
 
 package org.springframework.security.oauth2.client.registration;
 
+import java.net.URI;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
 import com.nimbusds.oauth2.sdk.GrantType;
 import com.nimbusds.oauth2.sdk.ParseException;
 import com.nimbusds.oauth2.sdk.Scope;
 import com.nimbusds.oauth2.sdk.as.AuthorizationServerMetadata;
 import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata;
+import net.minidev.json.JSONObject;
+
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.http.RequestEntity;
 import org.springframework.security.oauth2.core.AuthorizationGrantType;
 import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
 import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames;
 import org.springframework.security.oauth2.core.oidc.OidcScopes;
 import org.springframework.util.Assert;
+import org.springframework.web.client.HttpClientErrorException;
 import org.springframework.web.client.RestTemplate;
 import org.springframework.web.util.UriComponentsBuilder;
 
-import java.net.URI;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-
 /**
  * Allows creating a {@link ClientRegistration.Builder} from an
  * <a href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig">OpenID Provider Configuration</a>
@@ -49,11 +54,10 @@ import java.util.Map;
  */
 public final class ClientRegistrations {
 	private static final String OIDC_METADATA_PATH = "/.well-known/openid-configuration";
-	private static final String OAUTH2_METADATA_PATH = "/.well-known/oauth-authorization-server";
-
-	enum ProviderType {
-		OIDCV1, OIDC, OAUTH2;
-	}
+	private static final String OAUTH_METADATA_PATH = "/.well-known/oauth-authorization-server";
+	private static final RestTemplate rest = new RestTemplate();
+	private static final ParameterizedTypeReference<Map<String, Object>> typeReference =
+			new ParameterizedTypeReference<Map<String, Object>>() {};
 
 	/**
 	 * Creates a {@link ClientRegistration.Builder}  using the provided
@@ -63,12 +67,6 @@ public final class ClientRegistrations {
 	 * <a href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse">OpenID
 	 * Provider Configuration Response</a> to initialize the {@link ClientRegistration.Builder}.
 	 *
-	 * When deployed in legacy environments using OpenID Connect Discovery 1.0 and if the provided issuer has
-	 * a path i.e. /issuer1 then as per <a href="https://tools.ietf.org/html/rfc8414#section-5">Compatibility Notes</a>
-	 * first make an <a href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest">OpenID Provider
-	 * Configuration Request</a> using path /.well-known/openid-configuration/issuer1 and only if the retrieval
-	 * fail then a subsequent request to path /issuer1/.well-known/openid-configuration should be made.
-	 *
 	 * <p>
 	 * For example, if the issuer provided is "https://example.com", then an "OpenID Provider Configuration Request" will
 	 * be made to "https://example.com/.well-known/openid-configuration". The result is expected to be an "OpenID
@@ -88,42 +86,41 @@ public final class ClientRegistrations {
 	 * @return a {@link ClientRegistration.Builder} that was initialized by the OpenID Provider Configuration.
 	 */
 	public static ClientRegistration.Builder fromOidcIssuerLocation(String issuer) {
-		Map<ProviderType, String> configuration = getIssuerConfiguration(issuer, OIDC_METADATA_PATH);
-		OIDCProviderMetadata metadata = parse(configuration.get(ProviderType.OIDCV1), OIDCProviderMetadata::parse);
+		Assert.hasText(issuer, "issuer cannot be empty");
+		Map<String, Object> configuration = getConfiguration(issuer, oidc(URI.create(issuer)));
+		OIDCProviderMetadata metadata = parse(configuration, OIDCProviderMetadata::parse);
 		return withProviderConfiguration(metadata, issuer)
 				.userInfoUri(metadata.getUserInfoEndpointURI().toASCIIString());
 	}
 
 	/**
-	 * Unlike <strong>fromOidcIssuerLocation</strong> the <strong>fromIssuerLocation</strong> queries three different endpoints and uses the
-	 * returned response from whichever that returns successfully. When <strong>fromIssuerLocation</strong> is invoked with an issuer
-	 * the following sequence of actions take place
+	 * Creates a {@link ClientRegistration.Builder}  using the provided
+	 * <a href="https://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier">Issuer</a> by querying
+	 * three different discovery endpoints serially, using the values in the first successful response to
+	 * initialize. If an endpoint returns anything other than a 200 or a 4xx, the method will exit without
+	 * attempting subsequent endpoints.
+	 *
+	 * The three endpoints are computed as follows, given that the {@code issuer} is composed of a {@code host}
+	 * and a {@code path}:
 	 *
 	 * <ol>
 	 * 	<li>
-	 *     The first request is made against <i>{host}/.well-known/openid-configuration/issuer1</i> where issuer is equal to
-	 *     <strong>issuer1</strong>. See <a href="https://tools.ietf.org/html/rfc8414#section-5">Compatibility Notes</a> of RFC 8414
-	 *     specification for more details.
+	 * 	   {@code host/.well-known/openid-configuration/path}, as defined in
+	 * 	   <a href="https://tools.ietf.org/html/rfc8414#section-5">RFC 8414's Compatibility Notes</a>.
 	 *  </li>
 	 *  <li>
-	 *  	If the first attempt request returned non-Success (i.e. 200 status code) response then based on <strong>Compatibility Notes</strong> of
-	 *  <strong>RFC 8414</strong> a fallback <a href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest">
-	 *  OpenID Provider Configuration Request</a> is made to <i>{host}/issuer1/.well-known/openid-configuration</i>
+	 *      {@code issuer/.well-known/openid-configuration}, as defined in
+	 *  	<a href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest">
+	 * 	    OpenID Provider Configuration</a>.
 	 *  </li>
 	 *  <li>
-	 *  	If the second attempted request returns a non-Success (i.e. 200 status code) response then based a final
-	 *  <a href="https://tools.ietf.org/html/rfc8414#section-3.1">Authorization Server Metadata Request</a> is being made to
-	 *  <i>{host}/.well-known/oauth-authorization-server/issuer1</i>.
+	 *      {@code host/.well-known/oauth-authorization-server/path}, as defined in
+	 *  	<a href="https://tools.ietf.org/html/rfc8414#section-3.1">Authorization Server Metadata Request</a>.
 	 *  </li>
 	 * </ol>
 	 *
-	 *
-	 * As explained above, <strong>fromIssuerLocation</strong> would behave the exact same way as <strong>fromOidcIssuerLocation</strong> and that is
-	 * because <strong>fromIssuerLocation</strong> does the exact same processing as <strong>fromOidcIssuerLocation</strong> behind the scene. Use of
-	 * <strong>fromIssuerLocation</strong> is encouraged due to the fact that it is well-aligned with RFC 8414 specification and more specifically
-	 * it queries latest OIDC metadata endpoint with a fallback to legacy OIDC v1 discovery endpoint.
-	 *
-	 * The <strong>fromIssuerLocation</strong> is based on <a href="https://tools.ietf.org/html/rfc8414">RFC 8414</a> specification.
+	 * Note that the second endpoint is the equivalent of calling
+	 * {@link ClientRegistrations#fromOidcIssuerLocation(String)}.
 	 *
 	 * <p>
 	 * Example usage:
@@ -136,24 +133,65 @@ public final class ClientRegistrations {
 	 * </pre>
 	 *
 	 * @param issuer
-	 * @return a {@link ClientRegistration.Builder} that was initialized by the Authorization Sever Metadata Provider
+	 * @return a {@link ClientRegistration.Builder} that was initialized by one of the described endpoints
 	 */
 	public static ClientRegistration.Builder fromIssuerLocation(String issuer) {
-		Map<ProviderType, String> configuration = getIssuerConfiguration(issuer, OIDC_METADATA_PATH, OAUTH2_METADATA_PATH);
+		Assert.hasText(issuer, "issuer cannot be empty");
+		URI uri = URI.create(issuer);
+		Map<String, Object> configuration = getConfiguration(issuer, oidc(uri), oidcRfc8414(uri), oauth(uri));
+		AuthorizationServerMetadata metadata = parse(configuration, AuthorizationServerMetadata::parse);
+		ClientRegistration.Builder builder = withProviderConfiguration(metadata, issuer);
+		return Optional.ofNullable((String) configuration.get("userinfo_endpoint"))
+				.map(builder::userInfoUri).orElse(builder);
+	}
 
-		if (configuration.containsKey(ProviderType.OAUTH2)) {
-			AuthorizationServerMetadata metadata = parse(configuration.get(ProviderType.OAUTH2), AuthorizationServerMetadata::parse);
-			ClientRegistration.Builder builder = withProviderConfiguration(metadata, issuer);
-			return builder;
-		} else {
-			String response = configuration.getOrDefault(ProviderType.OIDC, configuration.get(ProviderType.OIDCV1));
-			OIDCProviderMetadata metadata = parse(response, OIDCProviderMetadata::parse);
-			ClientRegistration.Builder builder = withProviderConfiguration(metadata, issuer)
-					.userInfoUri(metadata.getUserInfoEndpointURI().toASCIIString());
-			return builder;
+	private static URI oidc(URI issuer) {
+		return UriComponentsBuilder.fromUri(issuer)
+				.replacePath(issuer.getPath() + OIDC_METADATA_PATH).build(Collections.emptyMap());
+	}
+
+	private static URI oidcRfc8414(URI issuer) {
+		return UriComponentsBuilder.fromUri(issuer)
+				.replacePath(OIDC_METADATA_PATH + issuer.getPath()).build(Collections.emptyMap());
+	}
+
+	private static URI oauth(URI issuer) {
+		return UriComponentsBuilder.fromUri(issuer)
+				.replacePath(OAUTH_METADATA_PATH + issuer.getPath()).build(Collections.emptyMap());
+	}
+
+	private static Map<String, Object> getConfiguration(String issuer, URI... uris) {
+		String errorMessage = "Unable to resolve Configuration with the provided Issuer of \"" + issuer + "\"";
+		for (URI uri : uris) {
+			try {
+				RequestEntity<Void> request = RequestEntity.get(uri).build();
+				return rest.exchange(request, typeReference).getBody();
+			} catch (HttpClientErrorException e) {
+				if (!e.getStatusCode().is4xxClientError()) {
+					throw e;
+				}
+				// else try another endpoint
+			} catch (RuntimeException e) {
+				throw new IllegalArgumentException(errorMessage, e);
+			}
+		}
+		throw new IllegalArgumentException(errorMessage);
+	}
+
+	private static <T> T parse(Map<String, Object> body,
+			ThrowingFunction<JSONObject, T, ParseException> parser) {
+
+		try {
+			return parser.apply(new JSONObject(body));
+		} catch (ParseException e) {
+			throw new RuntimeException(e);
 		}
 	}
 
+	private interface ThrowingFunction<S, T, E extends Throwable> {
+		T apply(S src) throws E;
+	}
+
 	private static ClientRegistration.Builder withProviderConfiguration(AuthorizationServerMetadata metadata, String issuer) {
 		String metadataIssuer = metadata.getIssuer().getValue();
 		if (!issuer.equals(metadataIssuer)) {
@@ -185,112 +223,6 @@ public final class ClientRegistrations {
 				.clientName(issuer);
 	}
 
-	/**
-	 * When the length of paths is equal to one (1) then it's a request for OpenId v1 discovery endpoint
-	 * hence the request is made to <strong>{host}/issuer1/.well-known/openid-configuration</strong>.
-	 * Otherwise, all three (3) metadata endpoints are queried one after another.
-	 *
-	 * @param issuer
-	 * @param paths
-	 * @throws IllegalArgumentException if the paths is null or empty or if none of the providers
-	 * responded to given issuer and paths requests
-	 * @return Map<String, Object> - Configuration Metadata from the given issuer
-	 */
-	private static Map<ProviderType, String> getIssuerConfiguration(String issuer, String... paths) {
-		Assert.notEmpty(paths, "paths cannot be empty or null.");
-
-		Map<ProviderType, String> providersUrl = buildIssuerConfigurationUrls(issuer, paths);
-		Map<ProviderType, String> providerResponse = new HashMap<>();
-
-		if (providersUrl.containsKey(ProviderType.OIDC)) {
-			providerResponse = mapResponse(providersUrl, ProviderType.OIDC);
-		}
-
-		// Fallback to OpenId v1 Discovery Endpoint based on RFC 8414 Compatibility Notes
-		if (providerResponse.isEmpty() && providersUrl.containsKey(ProviderType.OIDCV1)) {
-			providerResponse = mapResponse(providersUrl, ProviderType.OIDCV1);
-		}
-
-		if (providerResponse.isEmpty() && providersUrl.containsKey(ProviderType.OAUTH2)) {
-			providerResponse = mapResponse(providersUrl, ProviderType.OAUTH2);
-		}
-
-		if (providerResponse.isEmpty()) {
-			throw new IllegalArgumentException("Unable to resolve Configuration with the provided Issuer of \"" + issuer + "\"");
-		}
-		return providerResponse;
-	}
-
-	private static Map<ProviderType, String> mapResponse(Map<ProviderType, String> providersUrl, ProviderType providerType) {
-		Map<ProviderType, String> providerResponse = new HashMap<>();
-		String response = makeIssuerRequest(providersUrl.get(providerType));
-		if (response != null) {
-			providerResponse.put(providerType, response);
-		}
-		return providerResponse;
-	}
-
-	private static String makeIssuerRequest(String uri) {
-		RestTemplate rest = new RestTemplate();
-		try {
-			return rest.getForObject(uri, String.class);
-		} catch(RuntimeException ex) {
-			return null;
-		}
-	}
-
-	/**
-	 * When invoked with a path then make a
-	 * <a href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest">
-	 * OpenID Provider Configuration Request</a> by querying the OpenId Connection Discovery 1.0 endpoint
-	 * and the url would look as follow <strong>{host}/issuer1/.well-known/openid-configuration</strong>
-	 *
-	 * <p>
-	 * When more than one path is provided then query all the three (3) endpoints for metadata configuration
-	 * as per <a href="https://tools.ietf.org/html/rfc8414#section-5">Section 5</a> of RF 8414 specification
-	 * and the URLs would look as follow
-	 * </p>
-	 *
-	 * <ol>
-	 * <li>
-	 * <strong>{host}/.well-known/openid-configuration/issuer1</strong>  - OpenID as per RFC 8414
-	 * </li>
-	 * <li>
-	 * <strong>{host}/issuer1/.well-known/openid-configuration</strong> -  OpenID Connect 1.0 Discovery Compatibility as per RFC 8414
-	 * </li>
-	 * <li>
-	 * <strong>/.well-known/oauth-authorization-server/issuer1</strong> - OAuth2 Authorization Server Metadata as per RFC 8414
-	 * </li>
-	 * </ol>
-	 *
-	 * @param issuer
-	 * @param paths
-	 * @throws IllegalArgumentException throws exception if paths length is not 1 or 3, 1 for <strong>fromOidcLocationIssuer</strong>
-	 * and 3 for the newly introduced <strong>fromIssuerLocation</strong> to support querying 3 different metadata provider endpoints
-	 * @return Map<ProviderType, String> key-value map of provider with its request url
-	 */
-	private static Map<ProviderType, String> buildIssuerConfigurationUrls(String issuer, String... paths) {
-		Assert.isTrue(paths.length != 1 || paths.length != 3, "paths length can either be 1 or 3");
-
-		Map<ProviderType, String> providersUrl = new HashMap<>();
-
-		URI issuerURI = URI.create(issuer);
-
-		if (paths.length == 1) {
-			providersUrl.put(ProviderType.OIDCV1,
-					UriComponentsBuilder.fromUri(issuerURI).replacePath(issuerURI.getPath() + paths[0]).toUriString());
-		} else {
-			providersUrl.put(ProviderType.OIDC,
-					UriComponentsBuilder.fromUri(issuerURI).replacePath(paths[0] + issuerURI.getPath()).toUriString());
-			providersUrl.put(ProviderType.OIDCV1,
-					UriComponentsBuilder.fromUri(issuerURI).replacePath(issuerURI.getPath() + paths[0]).toUriString());
-			providersUrl.put(ProviderType.OAUTH2,
-					UriComponentsBuilder.fromUri(issuerURI).replacePath(paths[1] + issuerURI.getPath()).toUriString());
-		}
-
-		return providersUrl;
-	}
-
 	private static ClientAuthenticationMethod getClientAuthenticationMethod(String issuer,
 			List<com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod> metadataAuthMethods) {
 		if (metadataAuthMethods == null || metadataAuthMethods.contains(com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod.CLIENT_SECRET_BASIC)) {
@@ -317,18 +249,6 @@ public final class ClientRegistrations {
 		}
 	}
 
-	private static <T> T parse(String body, ThrowingFunction<String, T, ParseException> parser) {
-		try {
-			return parser.apply(body);
-		} catch (ParseException e) {
-			throw new RuntimeException(e);
-		}
-	}
-
-	private interface ThrowingFunction<S, T, E extends Throwable> {
-		T apply(S src) throws E;
-	}
-
 	private ClientRegistrations() {}
 
 }

+ 27 - 29
oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/registration/ClientRegistrationsTest.java

@@ -16,25 +16,24 @@
 
 package org.springframework.security.oauth2.client.registration;
 
+import java.util.Arrays;
+import java.util.Map;
+
 import com.fasterxml.jackson.core.type.TypeReference;
 import com.fasterxml.jackson.databind.ObjectMapper;
-
 import okhttp3.mockwebserver.Dispatcher;
 import okhttp3.mockwebserver.MockResponse;
 import okhttp3.mockwebserver.MockWebServer;
 import okhttp3.mockwebserver.RecordedRequest;
-
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
+
 import org.springframework.http.HttpHeaders;
 import org.springframework.http.MediaType;
 import org.springframework.security.oauth2.core.AuthorizationGrantType;
 import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
 
-import java.util.Arrays;
-import java.util.Map;
-
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
@@ -147,8 +146,8 @@ public class ClientRegistrationsTest {
 	}
 
 	@Test
-	public void issuerWhenOauth2AllInformationThenSuccess() throws Exception {
-		ClientRegistration registration = registrationOauth2("", null).build();
+	public void issuerWhenOAuth2AllInformationThenSuccess() throws Exception {
+		ClientRegistration registration = registrationOAuth2("", null).build();
 		ClientRegistration.ProviderDetails provider = registration.getProviderDetails();
 		assertIssuerMetadata(registration, provider);
 	}
@@ -182,8 +181,8 @@ public class ClientRegistrationsTest {
 	}
 
 	@Test
-	public void issuerWhenOauth2ContainsTrailingSlashThenSuccess() throws Exception {
-		assertThat(registrationOauth2("", null)).isNotNull();
+	public void issuerWhenOAuth2ContainsTrailingSlashThenSuccess() throws Exception {
+		assertThat(registrationOAuth2("", null)).isNotNull();
 		assertThat(this.issuer).endsWith("/");
 	}
 
@@ -213,10 +212,10 @@ public class ClientRegistrationsTest {
 	}
 
 	@Test
-	public void issuerWhenOauth2ScopesNullThenScopesDefaulted() throws Exception {
+	public void issuerWhenOAuth2ScopesNullThenScopesDefaulted() throws Exception {
 		this.response.remove("scopes_supported");
 
-		ClientRegistration registration = registrationOauth2("", null).build();
+		ClientRegistration registration = registrationOAuth2("", null).build();
 
 		assertThat(registration.getScopes()).containsOnly("openid");
 	}
@@ -232,10 +231,10 @@ public class ClientRegistrationsTest {
 	}
 
 	@Test
-	public void issuerWhenOauth2GrantTypesSupportedNullThenDefaulted() throws Exception {
+	public void issuerWhenOAuth2GrantTypesSupportedNullThenDefaulted() throws Exception {
 		this.response.remove("grant_types_supported");
 
-		ClientRegistration registration = registrationOauth2("", null).build();
+		ClientRegistration registration = registrationOAuth2("", null).build();
 
 		assertThat(registration.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE);
 	}
@@ -254,10 +253,10 @@ public class ClientRegistrationsTest {
 	}
 
 	@Test
-	public void issuerWhenOauth2GrantTypesSupportedInvalidThenException() throws Exception {
+	public void issuerWhenOAuth2GrantTypesSupportedInvalidThenException() throws Exception {
 		this.response.put("grant_types_supported", Arrays.asList("implicit"));
 
-		assertThatThrownBy(() -> registrationOauth2("", null))
+		assertThatThrownBy(() -> registrationOAuth2("", null))
 				.isInstanceOf(IllegalArgumentException.class)
 				.hasMessageContaining("Only AuthorizationGrantType.AUTHORIZATION_CODE is supported. The issuer \"" + this.issuer + "\" returned a configuration of [implicit]");
 	}
@@ -272,10 +271,10 @@ public class ClientRegistrationsTest {
 	}
 
 	@Test
-	public void issuerWhenOauth2TokenEndpointAuthMethodsNullThenDefaulted() throws Exception {
+	public void issuerWhenOAuth2TokenEndpointAuthMethodsNullThenDefaulted() throws Exception {
 		this.response.remove("token_endpoint_auth_methods_supported");
 
-		ClientRegistration registration = registrationOauth2("", null).build();
+		ClientRegistration registration = registrationOAuth2("", null).build();
 
 		assertThat(registration.getClientAuthenticationMethod()).isEqualTo(ClientAuthenticationMethod.BASIC);
 	}
@@ -290,10 +289,10 @@ public class ClientRegistrationsTest {
 	}
 
 	@Test
-	public void issuerWhenOauth2TokenEndpointAuthMethodsPostThenMethodIsPost() throws Exception {
+	public void issuerWhenOAuth2TokenEndpointAuthMethodsPostThenMethodIsPost() throws Exception {
 		this.response.put("token_endpoint_auth_methods_supported", Arrays.asList("client_secret_post"));
 
-		ClientRegistration registration = registrationOauth2("", null).build();
+		ClientRegistration registration = registrationOAuth2("", null).build();
 
 		assertThat(registration.getClientAuthenticationMethod()).isEqualTo(ClientAuthenticationMethod.POST);
 	}
@@ -308,10 +307,10 @@ public class ClientRegistrationsTest {
 	}
 
 	@Test
-	public void issuerWhenOauth2TokenEndpointAuthMethodsNoneThenMethodIsNone() throws Exception {
+	public void issuerWhenOAuth2TokenEndpointAuthMethodsNoneThenMethodIsNone() throws Exception {
 		this.response.put("token_endpoint_auth_methods_supported", Arrays.asList("none"));
 
-		ClientRegistration registration = registrationOauth2("", null).build();
+		ClientRegistration registration = registrationOAuth2("", null).build();
 
 		assertThat(registration.getClientAuthenticationMethod()).isEqualTo(ClientAuthenticationMethod.NONE);
 	}
@@ -330,24 +329,24 @@ public class ClientRegistrationsTest {
 	}
 
 	@Test
-	public void issuerWhenOauth2TokenEndpointAuthMethodsInvalidThenException() throws Exception {
+	public void issuerWhenOAuth2TokenEndpointAuthMethodsInvalidThenException() throws Exception {
 		this.response.put("token_endpoint_auth_methods_supported", Arrays.asList("tls_client_auth"));
 
-		assertThatThrownBy(() -> registrationOauth2("", null))
+		assertThatThrownBy(() -> registrationOAuth2("", null))
 				.isInstanceOf(IllegalArgumentException.class)
 				.hasMessageContaining("Only ClientAuthenticationMethod.BASIC, ClientAuthenticationMethod.POST and ClientAuthenticationMethod.NONE are supported. The issuer \"" + this.issuer + "\" returned a configuration of [tls_client_auth]");
 	}
 
 	@Test
-	public void issuerWhenOauth2EmptyStringThenMeaningfulErrorMessage() {
+	public void issuerWhenOAuth2EmptyStringThenMeaningfulErrorMessage() {
 		assertThatThrownBy(() -> ClientRegistrations.fromIssuerLocation(""))
-				.hasMessageContaining("Unable to resolve Configuration with the provided Issuer of \"\"");
+				.hasMessageContaining("issuer cannot be empty");
 	}
 
 	@Test
 	public void issuerWhenEmptyStringThenMeaningfulErrorMessage() {
 		assertThatThrownBy(() -> ClientRegistrations.fromOidcIssuerLocation(""))
-				.hasMessageContaining("Unable to resolve Configuration with the provided Issuer of \"\"");
+				.hasMessageContaining("issuer cannot be empty");
 	}
 
 	@Test
@@ -363,7 +362,7 @@ public class ClientRegistrationsTest {
 	}
 
 	@Test
-	public void issuerWhenOauth2ConfigurationDoesNotMatchThenMeaningfulErrorMessage()  throws Exception {
+	public void issuerWhenOAuth2ConfigurationDoesNotMatchThenMeaningfulErrorMessage()  throws Exception {
 		this.issuer = createIssuerFromServer("");
 		String body = this.mapper.writeValueAsString(this.response);
 		MockResponse mockResponse = new MockResponse()
@@ -388,7 +387,7 @@ public class ClientRegistrationsTest {
 			.clientSecret("client-secret");
 	}
 
-	private ClientRegistration.Builder registrationOauth2(String path, String body) throws Exception {
+	private ClientRegistration.Builder registrationOAuth2(String path, String body) throws Exception {
 		this.issuer = createIssuerFromServer(path);
 		this.response.put("issuer", this.issuer);
 		this.issuer = this.server.url(path).toString();
@@ -435,7 +434,6 @@ public class ClientRegistrationsTest {
 		final Dispatcher dispatcher = new Dispatcher() {
 			@Override
 			public MockResponse dispatch(RecordedRequest request) throws InterruptedException {
-				System.out.println("request.getPath:" + request.getPath());
 				switch(request.getPath()) {
 					case "/issuer1/.well-known/openid-configuration":
 					case "/.well-known/openid-configuration/":

+ 73 - 122
oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtDecoders.java

@@ -15,19 +15,21 @@
  */
 package org.springframework.security.oauth2.jwt;
 
-import static org.springframework.security.oauth2.jwt.NimbusJwtDecoder.withJwkSetUri;
-
 import java.net.URI;
+import java.util.Collections;
 import java.util.Map;
 
 import org.springframework.core.ParameterizedTypeReference;
 import org.springframework.http.RequestEntity;
+import org.springframework.http.ResponseEntity;
 import org.springframework.security.oauth2.core.OAuth2TokenValidator;
 import org.springframework.util.Assert;
-import org.springframework.web.client.RestClientException;
+import org.springframework.web.client.HttpClientErrorException;
 import org.springframework.web.client.RestTemplate;
 import org.springframework.web.util.UriComponentsBuilder;
 
+import static org.springframework.security.oauth2.jwt.NimbusJwtDecoder.withJwkSetUri;
+
 /**
  * Allows creating a {@link JwtDecoder} from an
  * <a href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig">OpenID Provider Configuration</a> or
@@ -40,7 +42,10 @@ import org.springframework.web.util.UriComponentsBuilder;
  */
 public final class JwtDecoders {
 	private static final String OIDC_METADATA_PATH = "/.well-known/openid-configuration";
-	private static final String OAUTH2_METADATA_PATH = "/.well-known/oauth-authorization-server";
+	private static final String OAUTH_METADATA_PATH = "/.well-known/oauth-authorization-server";
+	private static final RestTemplate rest = new RestTemplate();
+	private static final ParameterizedTypeReference<Map<String, Object>> typeReference =
+			new ParameterizedTypeReference<Map<String, Object>>() {};
 
 	/**
 	 * Creates a {@link JwtDecoder} using the provided
@@ -54,43 +59,84 @@ public final class JwtDecoders {
 	 * @return a {@link JwtDecoder} that was initialized by the OpenID Provider Configuration.
 	 */
 	public static JwtDecoder fromOidcIssuerLocation(String oidcIssuerLocation) {
-		Map<String, Object> configuration = getIssuerConfiguration(oidcIssuerLocation, OIDC_METADATA_PATH);
+		Assert.hasText(oidcIssuerLocation, "oidcIssuerLocation cannot be empty");
+		Map<String, Object> configuration = getConfiguration(oidcIssuerLocation, oidc(URI.create(oidcIssuerLocation)));
 		return withProviderConfiguration(configuration, oidcIssuerLocation);
 	}
 
 	/**
-	 * Creates a {@link JwtDecoder} using the provided issuer by querying configuration metadata endpoints for
-	 * OpenID (including fallback to legacy) and OAuth2 in order.
+	 * Creates a {@link JwtDecoder} using the provided
+	 * <a href="https://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier">Issuer</a> by querying
+	 * three different discovery endpoints serially, using the values in the first successful response to
+	 * initialize. If an endpoint returns anything other than a 200 or a 4xx, the method will exit without
+	 * attempting subsequent endpoints.
+	 *
+	 * The three endpoints are computed as follows, given that the {@code issuer} is composed of a {@code host}
+	 * and a {@code path}:
 	 *
 	 * <ol>
-	 * <li>
-	 * <strong>{host}/.well-known/openid-configuration/issuer1</strong> - OpenID Provider Configuration Request based on
-	 * <a href="https://tools.ietf.org/html/rfc8414#section-5">Section 5</a> of <a href="https://tools.ietf.org/html/rfc8414">
-	 * RFC 8414 Specification</a>
-	 * </li>
-	 * <li>
-	 * <strong>{host}/issuer1/.well-known/openid-configuration</strong> - OpenID v1 Discovery endpoint based on
-	 * <a href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest">OpenID Provider
-	 * Configuration Request</a> with backward compatibility highlighted on <a href="https://tools.ietf.org/html/rfc8414#section-5">
-	 * Section 5</a> of RF 8414
-	 * </li>
-	 * <li>
-	 * <strong>{host}/.well-known/oauth-authorization-server/issuer1</strong> - OAuth2 Authorization Server Metadata based on
-	 * <a href="https://tools.ietf.org/html/rfc8414#section-3.1">Section 3.1</a> of RFC 8414
-	 * </li>
+	 * 	<li>
+	 * 	   {@code host/.well-known/openid-configuration/path}, as defined in
+	 * 	   <a href="https://tools.ietf.org/html/rfc8414#section-5">RFC 8414's Compatibility Notes</a>.
+	 *  </li>
+	 *  <li>
+	 *      {@code issuer/.well-known/openid-configuration}, as defined in
+	 *  	<a href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest">
+	 * 	    OpenID Provider Configuration</a>.
+	 *  </li>
+	 *  <li>
+	 *      {@code host/.well-known/oauth-authorization-server/path}, as defined in
+	 *  	<a href="https://tools.ietf.org/html/rfc8414#section-3.1">Authorization Server Metadata Request</a>.
+	 *  </li>
 	 * </ol>
 	 *
+	 * Note that the second endpoint is the equivalent of calling
+	 * {@link JwtDecoders#fromOidcIssuerLocation(String)}
+	 *
 	 * @param issuer
-	 * @return a {@link JwtDecoder} that is initialized using
-	 * <a href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse">
-	 * OpenID Provider Configuration Response</a> or <a href="https://tools.ietf.org/html/rfc8414#section-3.2">
-	 * Authorization Server Metadata Response</a> depending on provided issuer
+	 * @return a {@link JwtDecoder} that was initialized by one of the described endpoints
 	 */
 	public static JwtDecoder fromIssuerLocation(String issuer) {
-		Map<String, Object> configuration = getIssuerConfiguration(issuer, OIDC_METADATA_PATH, OAUTH2_METADATA_PATH);
+		Assert.hasText(issuer, "issuer cannot be empty");
+		URI uri = URI.create(issuer);
+		Map<String, Object> configuration = getConfiguration(issuer, oidc(uri), oidcRfc8414(uri), oauth(uri));
 		return withProviderConfiguration(configuration, issuer);
 	}
 
+	private static URI oidc(URI issuer) {
+		return UriComponentsBuilder.fromUri(issuer)
+				.replacePath(issuer.getPath() + OIDC_METADATA_PATH).build(Collections.emptyMap());
+	}
+
+	private static URI oidcRfc8414(URI issuer) {
+		return UriComponentsBuilder.fromUri(issuer)
+				.replacePath(OIDC_METADATA_PATH + issuer.getPath()).build(Collections.emptyMap());
+	}
+
+	private static URI oauth(URI issuer) {
+		return UriComponentsBuilder.fromUri(issuer)
+				.replacePath(OAUTH_METADATA_PATH + issuer.getPath()).build(Collections.emptyMap());
+	}
+
+	private static Map<String, Object> getConfiguration(String issuer, URI... uris) {
+		String errorMessage = "Unable to resolve the Configuration with the provided Issuer of " +
+				"\"" + issuer + "\"";
+		for (URI uri : uris) {
+			try {
+				RequestEntity<Void> request = RequestEntity.get(uri).build();
+				ResponseEntity<Map<String, Object>> response = rest.exchange(request, typeReference);
+				return response.getBody();
+			} catch (RuntimeException e) {
+				if (!(e instanceof HttpClientErrorException &&
+						((HttpClientErrorException) e).getStatusCode().is4xxClientError())) {
+					throw new IllegalArgumentException(errorMessage, e);
+				}
+				// else try another endpoint
+			}
+		}
+		throw new IllegalArgumentException(errorMessage);
+	}
+
 	/**
 	 * Validate provided issuer and build {@link JwtDecoder} from
 	 * <a href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse">OpenID Provider
@@ -118,100 +164,5 @@ public final class JwtDecoders {
 		return jwtDecoder;
 	}
 
-	/**
-	 * When the length of paths is equal to one (1) then it's a request for OpenId v1 discovery endpoint
-	 * hence a request to <strong>{host}/issuer1/.well-known/openid-configuration</strong> is being made.
-	 * Otherwise, all three (3) discovery endpoint are queried one after another depending one after another
-	 * until one endpoint returns successful response.
-	 *
-	 * @param issuer
-	 * @param paths
-	 * @throws IllegalArgumentException if the paths is null or empty or if none of the providers
-	 * responded to given issuer and paths requests
-	 * @return Map<String, Object> - Configuration Metadata from the given issuer
-	 */
-	private static Map<String, Object> getIssuerConfiguration(String issuer, String... paths) {
-		Assert.notEmpty(paths, "paths cannot be empty or null.");
-
-		URI[] uris = buildIssuerConfigurationUrls(issuer, paths);
-		for (URI uri: uris) {
-			Map<String, Object> response = makeIssuerRequest(uri);
-			if (response != null) {
-				return response;
-			}
-		}
-		throw new IllegalArgumentException("Unable to resolve Configuration with the provided Issuer of \"" + issuer + "\"");
-	}
-
-	/**
-	 * Make a rest API request to the given URI that is either of OpenId, OpenId Connection Discovery 1.0 or OAuth2 and if
-	 * successful then return the Response as key-value map. If the request is not successful then the thrown exception is
-	 * caught and null is returned indicating no provider available.
-	 *
-	 * @param uri
-	 * @return Map<String, Object> Configuration Metadata of the given provider if not null
-	 */
-	private static Map<String, Object> makeIssuerRequest(URI uri) {
-		RestTemplate rest = new RestTemplate();
-		ParameterizedTypeReference<Map<String, Object>> typeReference = new ParameterizedTypeReference<Map<String, Object>>() {};
-		try {
-			RequestEntity<Void> request = RequestEntity.get(uri).build();
-			return rest.exchange(request, typeReference).getBody();
-		} catch(RestClientException ex) {
-			return null;
-		} catch(RuntimeException ex) {
-			return null;
-		}
-	}
-
-	/**
-	 * When invoked with a path then make a
-	 * <a href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest">
-	 * OpenID Provider Configuration Request</a> by querying the OpenId Connection Discovery 1.0 endpoint
-	 * and the url would look as follow <strong>{host}/issuer1/.well-known/openid-configuration</strong>
-	 *
-	 * <p>
-	 * When more than one path is provided then query all the three (3) endpoints for metadata configuration
-	 * as per <a href="https://tools.ietf.org/html/rfc8414#section-5">Section 5</a> of RF 8414 specification
-	 * and the urls would look as follow
-	 * </p>
-	 *
-	 * <ol>
-	 * <li>
-	 * <strong>{host}/.well-known/openid-configuration/issuer1</strong>  - OpenID as per RFC 8414
-	 * </li>
-	 * <li>
-	 * <strong>{host}/issuer1/.well-known/openid-configuration</strong>  - OpenID Connect 1.0 Discovery Compatibility as per RFC 8414
-	 * </li>
-	 * <li>
-	 * <strong>{host}/.well-known/oauth-authorization-server/issuer1</strong>  - OAuth2 Authorization Server Metadata as per RFC 8414
-	 * </li>
-	 * </ol>
-	 *
-	 * @param issuer
-	 * @param paths
-	 * @throws IllegalArgumentException throws exception if paths length is not 1 or 3, 1 for <strong>fromOidcLocationIssuer</strong>
-	 * and 3 for the newly introduced <strong>fromIssuerLocation</strong> to support querying 3 different metadata provider endpoints
-	 * @return URI[] URIs for to <a href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse">
-	 * OpenID Provider Configuration Response</a> and <a href="https://tools.ietf.org/html/rfc8414#section-3.2">
-	 * Authorization Server Metadata Response</a>
-	 */
-	private static URI[] buildIssuerConfigurationUrls(String issuer, String... paths) {
-		Assert.isTrue(paths.length != 1 || paths.length != 3, "paths length can either be 1 or 3");
-		URI issuerURI = URI.create(issuer);
-
-		if (paths.length == 1) {
-			return new URI[] {
-					UriComponentsBuilder.fromUri(issuerURI).replacePath(issuerURI.getPath() + paths[0]).build().toUri()
-			};
-		} else {
-			return new URI[] {
-					UriComponentsBuilder.fromUri(issuerURI).replacePath(paths[0] + issuerURI.getPath()).build().toUri(),
-					UriComponentsBuilder.fromUri(issuerURI).replacePath(issuerURI.getPath() + paths[0]).build().toUri(),
-					UriComponentsBuilder.fromUri(issuerURI).replacePath(paths[1] + issuerURI.getPath()).build().toUri()
-			};
-		}
-	}
-
 	private JwtDecoders() {}
 }

+ 99 - 28
oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/ReactiveJwtDecoders.java

@@ -15,14 +15,20 @@
  */
 package org.springframework.security.oauth2.jwt;
 
+import java.net.URI;
+import java.util.Collections;
+import java.util.Map;
+
 import org.springframework.core.ParameterizedTypeReference;
 import org.springframework.http.RequestEntity;
+import org.springframework.http.ResponseEntity;
 import org.springframework.security.oauth2.core.OAuth2TokenValidator;
+import org.springframework.util.Assert;
+import org.springframework.web.client.HttpClientErrorException;
 import org.springframework.web.client.RestTemplate;
 import org.springframework.web.util.UriComponentsBuilder;
 
-import java.net.URI;
-import java.util.Map;
+import static org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder.withJwkSetUri;
 
 /**
  * Allows creating a {@link ReactiveJwtDecoder} from an
@@ -32,6 +38,11 @@ import java.util.Map;
  * @since 5.1
  */
 public final class ReactiveJwtDecoders {
+	private static final String OIDC_METADATA_PATH = "/.well-known/openid-configuration";
+	private static final String OAUTH_METADATA_PATH = "/.well-known/oauth-authorization-server";
+	private static final RestTemplate rest = new RestTemplate();
+	private static final ParameterizedTypeReference<Map<String, Object>> typeReference =
+			new ParameterizedTypeReference<Map<String, Object>>() {};
 
 	/**
 	 * Creates a {@link ReactiveJwtDecoder} using the provided
@@ -45,40 +56,100 @@ public final class ReactiveJwtDecoders {
 	 * @return a {@link ReactiveJwtDecoder} that was initialized by the OpenID Provider Configuration.
 	 */
 	public static ReactiveJwtDecoder fromOidcIssuerLocation(String oidcIssuerLocation) {
-		Map<String, Object> openidConfiguration = getOpenidConfiguration(oidcIssuerLocation);
+		Assert.hasText(oidcIssuerLocation, "oidcIssuerLocation cannot be empty");
+		Map<String, Object> configuration = getConfiguration(oidcIssuerLocation, oidc(URI.create(oidcIssuerLocation)));
+		return withProviderConfiguration(configuration, oidcIssuerLocation);
+	}
+
+	/**
+	 * Creates a {@link ReactiveJwtDecoder} using the provided
+	 * <a href="https://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier">Issuer</a> by querying
+	 * three different discovery endpoints serially, using the values in the first successful response to
+	 * initialize. If an endpoint returns anything other than a 200 or a 4xx, the method will exit without
+	 * attempting subsequent endpoints.
+	 *
+	 * The three endpoints are computed as follows, given that the {@code issuer} is composed of a {@code host}
+	 * and a {@code path}:
+	 *
+	 * <ol>
+	 * 	<li>
+	 * 	   {@code host/.well-known/openid-configuration/path}, as defined in
+	 * 	   <a href="https://tools.ietf.org/html/rfc8414#section-5">RFC 8414's Compatibility Notes</a>.
+	 *  </li>
+	 *  <li>
+	 *      {@code issuer/.well-known/openid-configuration}, as defined in
+	 *  	<a href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest">
+	 * 	    OpenID Provider Configuration</a>.
+	 *  </li>
+	 *  <li>
+	 *      {@code host/.well-known/oauth-authorization-server/path}, as defined in
+	 *  	<a href="https://tools.ietf.org/html/rfc8414#section-3.1">Authorization Server Metadata Request</a>.
+	 *  </li>
+	 * </ol>
+	 *
+	 * Note that the second endpoint is the equivalent of calling
+	 * {@link ReactiveJwtDecoders#fromOidcIssuerLocation(String)}
+	 *
+	 * @param issuer
+	 * @return a {@link ReactiveJwtDecoder} that was initialized by one of the described endpoints
+	 */
+	public static ReactiveJwtDecoder fromIssuerLocation(String issuer) {
+		Assert.hasText(issuer, "issuer cannot be empty");
+		URI uri = URI.create(issuer);
+		Map<String, Object> configuration = getConfiguration(issuer, oidc(uri), oidcRfc8414(uri), oauth(uri));
+		return withProviderConfiguration(configuration, issuer);
+	}
+
+	private static URI oidc(URI issuer) {
+		return UriComponentsBuilder.fromUri(issuer)
+				.replacePath(issuer.getPath() + OIDC_METADATA_PATH).build(Collections.emptyMap());
+	}
+
+	private static URI oidcRfc8414(URI issuer) {
+		return UriComponentsBuilder.fromUri(issuer)
+				.replacePath(OIDC_METADATA_PATH + issuer.getPath()).build(Collections.emptyMap());
+	}
+
+	private static URI oauth(URI issuer) {
+		return UriComponentsBuilder.fromUri(issuer)
+				.replacePath(OAUTH_METADATA_PATH + issuer.getPath()).build(Collections.emptyMap());
+	}
+
+	private static Map<String, Object> getConfiguration(String issuer, URI... uris) {
+		String errorMessage = "Unable to resolve the Configuration with the provided Issuer of " +
+				"\"" + issuer + "\"";
+		for (URI uri : uris) {
+			try {
+				RequestEntity<Void> request = RequestEntity.get(uri).build();
+				ResponseEntity<Map<String, Object>> response = rest.exchange(request, typeReference);
+				return response.getBody();
+			} catch (RuntimeException e) {
+				if (!(e instanceof HttpClientErrorException &&
+						((HttpClientErrorException) e).getStatusCode().is4xxClientError())) {
+					throw new IllegalArgumentException(errorMessage, e);
+				}
+				// else try another endpoint
+			}
+		}
+		throw new IllegalArgumentException(errorMessage);
+	}
+
+	private static ReactiveJwtDecoder withProviderConfiguration(Map<String, Object> configuration, String issuer) {
 		String metadataIssuer = "(unavailable)";
-		if (openidConfiguration.containsKey("issuer")) {
-			metadataIssuer = openidConfiguration.get("issuer").toString();
+		if (configuration.containsKey("issuer")) {
+			metadataIssuer = configuration.get("issuer").toString();
 		}
-		if (!oidcIssuerLocation.equals(metadataIssuer)) {
-			throw new IllegalStateException("The Issuer \"" + metadataIssuer + "\" provided in the OpenID Configuration " +
-					"did not match the requested issuer \"" + oidcIssuerLocation + "\"");
+		if (!issuer.equals(metadataIssuer)) {
+			throw new IllegalStateException("The Issuer \"" + metadataIssuer + "\" provided in the configuration did not "
+					+ "match the requested issuer \"" + issuer + "\"");
 		}
 
-		OAuth2TokenValidator<Jwt> jwtValidator =
-				JwtValidators.createDefaultWithIssuer(oidcIssuerLocation);
-
-		NimbusReactiveJwtDecoder jwtDecoder =
-				new NimbusReactiveJwtDecoder(openidConfiguration.get("jwks_uri").toString());
+		OAuth2TokenValidator<Jwt> jwtValidator = JwtValidators.createDefaultWithIssuer(issuer);
+		NimbusReactiveJwtDecoder jwtDecoder = withJwkSetUri(configuration.get("jwks_uri").toString()).build();
 		jwtDecoder.setJwtValidator(jwtValidator);
 
 		return jwtDecoder;
 	}
 
-	private static Map<String, Object> getOpenidConfiguration(String issuer) {
-		ParameterizedTypeReference<Map<String, Object>> typeReference = new ParameterizedTypeReference<Map<String, Object>>() {};
-		RestTemplate rest = new RestTemplate();
-		try {
-			URI uri = UriComponentsBuilder.fromUriString(issuer + "/.well-known/openid-configuration")
-					.build()
-					.toUri();
-			RequestEntity<Void> request = RequestEntity.get(uri).build();
-			return rest.exchange(request, typeReference).getBody();
-		} catch(RuntimeException e) {
-			throw new IllegalArgumentException("Unable to resolve the OpenID Configuration with the provided Issuer of " +
-					"\"" + issuer + "\"", e);
-		}
-	}
-
 	private ReactiveJwtDecoders() {}
 }

+ 92 - 98
oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtDecodersTests.java

@@ -15,17 +15,23 @@
  */
 package org.springframework.security.oauth2.jwt;
 
+import java.net.URI;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+
+import okhttp3.HttpUrl;
 import okhttp3.mockwebserver.Dispatcher;
 import okhttp3.mockwebserver.MockResponse;
 import okhttp3.mockwebserver.MockWebServer;
 import okhttp3.mockwebserver.RecordedRequest;
-
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 
 import org.springframework.http.HttpHeaders;
 import org.springframework.http.MediaType;
+import org.springframework.web.util.UriComponentsBuilder;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatCode;
@@ -67,6 +73,9 @@ public class JwtDecodersTests {
 	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 static final String OIDC_METADATA_PATH = "/.well-known/openid-configuration";
+	private static final String OAUTH_METADATA_PATH = "/.well-known/oauth-authorization-server";
+
 	private MockWebServer server;
 	private String issuer;
 
@@ -74,7 +83,7 @@ public class JwtDecodersTests {
 	public void setup() throws Exception {
 		this.server = new MockWebServer();
 		this.server.start();
-		this.issuer = createIssuerFromServer();
+		this.issuer = createIssuerFromServer() + "path";
 	}
 
 	@After
@@ -85,56 +94,50 @@ public class JwtDecodersTests {
 	@Test
 	public void issuerWhenResponseIsTypicalThenReturnedDecoderValidatesIssuer() {
 		prepareConfigurationResponse();
-		this.server.enqueue(new MockResponse().setBody(JWK_SET));
-
 		JwtDecoder decoder = JwtDecoders.fromOidcIssuerLocation(this.issuer);
-
-		assertResponseIsTypicalThenReturnedDecoderValidatesIssuer(decoder);
+		assertThatCode(() -> decoder.decode(ISSUER_MISMATCH))
+				.isInstanceOf(JwtValidationException.class)
+				.hasMessageContaining("This iss claim is not equal to the configured issuer");
 	}
 
 	@Test
 	public void issuerWhenOidcFallbackResponseIsTypicalThenReturnedDecoderValidatesIssuer() {
-		prepareConfigurationResponseForOidcFallback("issuer1", null);
-
+		prepareConfigurationResponseOidc();
 		JwtDecoder decoder = JwtDecoders.fromIssuerLocation(this.issuer);
-
-		assertResponseIsTypicalThenReturnedDecoderValidatesIssuer(decoder);
+		assertThatCode(() -> decoder.decode(ISSUER_MISMATCH))
+				.isInstanceOf(JwtValidationException.class)
+				.hasMessageContaining("This iss claim is not equal to the configured issuer");
 	}
 
 	@Test
-	public void issuerWhenOauth2ResponseIsTypicalThenReturnedDecoderValidatesIssuer() {
-		prepareConfigurationResponseForOauth2("issuer1", null);
-
+	public void issuerWhenOAuth2ResponseIsTypicalThenReturnedDecoderValidatesIssuer() {
+		prepareConfigurationResponseOAuth2();
 		JwtDecoder decoder = JwtDecoders.fromIssuerLocation(this.issuer);
-
-		assertResponseIsTypicalThenReturnedDecoderValidatesIssuer(decoder);
-	}
-
-	private void assertResponseIsTypicalThenReturnedDecoderValidatesIssuer(JwtDecoder decoder) {
 		assertThatCode(() -> decoder.decode(ISSUER_MISMATCH))
-		.isInstanceOf(JwtValidationException.class)
-		.hasMessageContaining("This iss claim is not equal to the configured issuer");
+				.isInstanceOf(JwtValidationException.class)
+				.hasMessageContaining("This iss claim is not equal to the configured issuer");
 	}
 
 	@Test
 	public void issuerWhenContainsTrailingSlashThenSuccess() {
+		this.issuer += "/";
 		prepareConfigurationResponse();
-		this.server.enqueue(new MockResponse().setBody(JWK_SET));
 		assertThat(JwtDecoders.fromOidcIssuerLocation(this.issuer)).isNotNull();
 		assertThat(this.issuer).endsWith("/");
 	}
 
 	@Test
 	public void issuerWhenOidcFallbackContainsTrailingSlashThenSuccess() {
-		prepareConfigurationResponse();
-		this.server.enqueue(new MockResponse().setBody(JWK_SET));
+		this.issuer += "/";
+		prepareConfigurationResponseOidc();
 		assertThat(JwtDecoders.fromIssuerLocation(this.issuer)).isNotNull();
 		assertThat(this.issuer).endsWith("/");
 	}
 
 	@Test
-	public void issuerWhenOauth2ContainsTrailingSlashThenSuccess() {
-		prepareConfigurationResponseForOauth2("", null);
+	public void issuerWhenOAuth2ContainsTrailingSlashThenSuccess() {
+		this.issuer += "/";
+		prepareConfigurationResponseOAuth2();
 		assertThat(JwtDecoders.fromIssuerLocation(this.issuer)).isNotNull();
 		assertThat(this.issuer).endsWith("/");
 	}
@@ -148,15 +151,14 @@ public class JwtDecodersTests {
 
 	@Test
 	public void issuerWhenOidcFallbackResponseIsNonCompliantThenThrowsRuntimeException() {
-		prepareConfigurationResponseForOidcFallback("", "{ \"missing_required_keys\" : \"and_values\" }");
+		prepareConfigurationResponseOidc("{ \"missing_required_keys\" : \"and_values\" }");
 		assertThatCode(() -> JwtDecoders.fromIssuerLocation(this.issuer))
 				.isInstanceOf(RuntimeException.class);
 	}
 
 	@Test
-	public void issuerWhenOauth2ResponseIsNonCompliantThenThrowsRuntimeException() {
-		prepareConfigurationResponseForOauth2("", "{ \"missing_required_keys\" : \"and_values\" }");
-		System.out.println("this.issuer = " + this.issuer);
+	public void issuerWhenOAuth2ResponseIsNonCompliantThenThrowsRuntimeException() {
+		prepareConfigurationResponseOAuth2("{ \"missing_required_keys\" : \"and_values\" }");
 		assertThatCode(() -> JwtDecoders.fromIssuerLocation(this.issuer))
 				.isInstanceOf(RuntimeException.class);
 	}
@@ -170,36 +172,36 @@ public class JwtDecodersTests {
 
 	@Test
 	public void issuerWhenOidcFallbackResponseIsMalformedThenThrowsRuntimeException() {
-		prepareConfigurationResponseForOidcFallback("", "malformed");
-		assertThatCode(() -> JwtDecoders.fromOidcIssuerLocation(this.issuer))
+		prepareConfigurationResponseOidc("malformed");
+		assertThatCode(() -> JwtDecoders.fromIssuerLocation(this.issuer))
 				.isInstanceOf(RuntimeException.class);
 	}
 
 	@Test
-	public void issuerWhenOauth2ResponseIsMalformedThenThrowsRuntimeException() {
-		prepareConfigurationResponseForOauth2("", "malformed");
+	public void issuerWhenOAuth2ResponseIsMalformedThenThrowsRuntimeException() {
+		prepareConfigurationResponseOAuth2("malformed");
 		assertThatCode(() -> JwtDecoders.fromIssuerLocation(this.issuer))
 				.isInstanceOf(RuntimeException.class);
 	}
 
 	@Test
 	public void issuerWhenRespondingIssuerMismatchesRequestedIssuerThenThrowsIllegalStateException() {
-		prepareConfigurationResponse();
-		assertThatCode(() -> JwtDecoders.fromOidcIssuerLocation(this.issuer + "/wrong"))
+		prepareConfigurationResponse(String.format(DEFAULT_RESPONSE_TEMPLATE, this.issuer + "/wrong", this.issuer));
+		assertThatCode(() -> JwtDecoders.fromOidcIssuerLocation(this.issuer))
 				.isInstanceOf(IllegalStateException.class);
 	}
 
 	@Test
 	public void issuerWhenOidcFallbackRespondingIssuerMismatchesRequestedIssuerThenThrowsIllegalStateException() {
-		prepareConfigurationResponseForOidcFallback("", null);
-		assertThatCode(() -> JwtDecoders.fromIssuerLocation(this.issuer + "/wrong"))
+		prepareConfigurationResponseOidc(String.format(DEFAULT_RESPONSE_TEMPLATE, this.issuer + "/wrong", this.issuer));
+		assertThatCode(() -> JwtDecoders.fromIssuerLocation(this.issuer))
 				.isInstanceOf(IllegalStateException.class);
 	}
 
 	@Test
-	public void issuerWhenOauth2RespondingIssuerMismatchesRequestedIssuerThenThrowsIllegalStateException() {
-		prepareConfigurationResponseForOauth2("", null);
-		assertThatCode(() -> JwtDecoders.fromIssuerLocation(this.issuer + "/wrong"))
+	public void issuerWhenOAuth2RespondingIssuerMismatchesRequestedIssuerThenThrowsIllegalStateException() {
+		prepareConfigurationResponseOAuth2(String.format(DEFAULT_RESPONSE_TEMPLATE, this.issuer + "/wrong", this.issuer));
+		assertThatCode(() -> JwtDecoders.fromIssuerLocation(this.issuer))
 				.isInstanceOf(IllegalStateException.class);
 	}
 
@@ -208,7 +210,6 @@ public class JwtDecodersTests {
 			throws Exception {
 
 		this.server.shutdown();
-
 		assertThatCode(() -> JwtDecoders.fromOidcIssuerLocation("https://issuer"))
 				.isInstanceOf(IllegalArgumentException.class);
 	}
@@ -218,7 +219,6 @@ public class JwtDecodersTests {
 			throws Exception {
 
 		this.server.shutdown();
-
 		assertThatCode(() -> JwtDecoders.fromIssuerLocation("https://issuer"))
 				.isInstanceOf(IllegalArgumentException.class);
 	}
@@ -229,75 +229,69 @@ public class JwtDecodersTests {
 	}
 
 	private void prepareConfigurationResponse(String body) {
-		MockResponse mockResponse = new MockResponse()
-				.setBody(body)
-				.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
-		this.server.enqueue(mockResponse);
+		this.server.enqueue(response(body));
+		this.server.enqueue(response(JWK_SET));
 	}
 
-	/**
-	 * A mock server that responds to API requests for OIDC (i.e. openid-configuration) metadata endpoint when the
-	 * request path matches the switch case.
-	 *
-	 * @param path
-	 * @param body
-	 */
-	private void prepareConfigurationResponseForOidcFallback(String path, String body) {
-		this.issuer = this.server.url(path).toString();
-		String responseBody = body != null ? body : String.format(DEFAULT_RESPONSE_TEMPLATE, this.issuer, this.issuer);
+	private void prepareConfigurationResponseOidc() {
+		String body = String.format(DEFAULT_RESPONSE_TEMPLATE, this.issuer, this.issuer);
+		prepareConfigurationResponseOidc(body);
+	}
 
-		final Dispatcher dispatcher = new Dispatcher() {
-			@Override
-			public MockResponse dispatch(RecordedRequest request) throws InterruptedException {
-				switch(request.getPath()) {
-					case "/issuer1/.well-known/openid-configuration":
-					case "/wrong/.well-known/openid-configuration":
-						return buildSuccessMockResponse(responseBody);
-					case "/issuer1/.well-known/jwks.json":
-						return buildSuccessMockResponse(JWK_SET);
-
-				}
-				return new MockResponse().setResponseCode(404);
-			}
-		};
-		this.server.setDispatcher(dispatcher);
+	private void prepareConfigurationResponseOidc(String body) {
+		Map<String, MockResponse> responses = new HashMap<>();
+		responses.put(oidc(), response(body));
+		responses.put(jwks(), response(JWK_SET));
+		prepareConfigurationResponses(responses);
 	}
 
-	/**
-	 * A mock server that responds to API requests for OAuth2 (oauth-authorization-server) metadata endpoint when the
-	 * request path matches the switch case.
-	 *
-	 * @param path
-	 * @param body
-	 */
-	private void prepareConfigurationResponseForOauth2(String path, String body) {
-		this.issuer = this.server.url(path).toString();
-		final String responseBody = body != null ? body : String.format(DEFAULT_RESPONSE_TEMPLATE, this.issuer, this.issuer);
+	private void prepareConfigurationResponseOAuth2() {
+		String body = String.format(DEFAULT_RESPONSE_TEMPLATE, this.issuer, this.issuer);
+		prepareConfigurationResponseOAuth2(body);
+	}
 
-		final Dispatcher dispatcher = new Dispatcher() {
+	private void prepareConfigurationResponseOAuth2(String body) {
+		Map<String, MockResponse> responses = new HashMap<>();
+		responses.put(oauth(), response(body));
+		responses.put(jwks(), response(JWK_SET));
+		prepareConfigurationResponses(responses);
+	}
+
+	private void prepareConfigurationResponses(Map<String, MockResponse> responses) {
+		Dispatcher dispatcher = new Dispatcher() {
 			@Override
-			public MockResponse dispatch(RecordedRequest request) throws InterruptedException {
-				switch(request.getPath()) {
-					case "/.well-known/oauth-authorization-server/issuer1":
-					case "/.well-known/oauth-authorization-server/wrong":
-					case "/.well-known/oauth-authorization-server/":
-						return buildSuccessMockResponse(responseBody);
-					case "/issuer1/.well-known/jwks.json":
-						return buildSuccessMockResponse(JWK_SET);
-				}
-				return new MockResponse().setResponseCode(404);
+			public MockResponse dispatch(RecordedRequest request) {
+				return Optional.of(request).map(RecordedRequest::getRequestUrl).map(HttpUrl::toString)
+						.map(responses::get)
+						.orElse(new MockResponse().setResponseCode(404));
 			}
 		};
 		this.server.setDispatcher(dispatcher);
 	}
 
-	private MockResponse buildSuccessMockResponse(String body) {
-		return new MockResponse().setResponseCode(200)
-				.setBody(body)
-				.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
-	}
-
 	private String createIssuerFromServer() {
 		return this.server.url("").toString();
 	}
+
+	private String oidc() {
+		URI uri = URI.create(this.issuer);
+		return UriComponentsBuilder.fromUri(uri)
+				.replacePath(uri.getPath() + OIDC_METADATA_PATH).toUriString();
+	}
+
+	private String oauth() {
+		URI uri = URI.create(this.issuer);
+		return UriComponentsBuilder.fromUri(uri)
+				.replacePath(OAUTH_METADATA_PATH + uri.getPath()).toUriString();
+	}
+
+	private String jwks() {
+		return this.issuer + "/.well-known/jwks.json";
+	}
+
+	private MockResponse response(String body) {
+		return new MockResponse()
+				.setBody(body)
+				.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
+	}
 }

+ 155 - 14
oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/ReactiveJwtDecodersTests.java

@@ -15,14 +15,23 @@
  */
 package org.springframework.security.oauth2.jwt;
 
+import java.net.URI;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+
+import okhttp3.HttpUrl;
+import okhttp3.mockwebserver.Dispatcher;
 import okhttp3.mockwebserver.MockResponse;
 import okhttp3.mockwebserver.MockWebServer;
+import okhttp3.mockwebserver.RecordedRequest;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 
 import org.springframework.http.HttpHeaders;
 import org.springframework.http.MediaType;
+import org.springframework.web.util.UriComponentsBuilder;
 
 import static org.assertj.core.api.Assertions.assertThatCode;
 
@@ -62,6 +71,9 @@ public class ReactiveJwtDecodersTests {
 	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 static final String OIDC_METADATA_PATH = "/.well-known/openid-configuration";
+	private static final String OAUTH_METADATA_PATH = "/.well-known/oauth-authorization-server";
+
 	private MockWebServer server;
 	private String issuer;
 	private String jwkSetUri;
@@ -71,7 +83,8 @@ public class ReactiveJwtDecodersTests {
 		this.server = new MockWebServer();
 		this.server.start();
 		this.issuer = createIssuerFromServer();
-		this.jwkSetUri = this.issuer + "/.well-known/jwks.json";
+		this.jwkSetUri = this.issuer + ".well-known/jwks.json";
+		this.issuer += "path";
 	}
 
 	@After
@@ -81,8 +94,7 @@ public class ReactiveJwtDecodersTests {
 
 	@Test
 	public void issuerWhenResponseIsTypicalThenReturnedDecoderValidatesIssuer() {
-		prepareOpenIdConfigurationResponse();
-		this.server.enqueue(new MockResponse().setBody(JWK_SET));
+		prepareConfigurationResponse();
 
 		ReactiveJwtDecoder decoder = ReactiveJwtDecoders.fromOidcIssuerLocation(this.issuer);
 
@@ -91,27 +103,91 @@ public class ReactiveJwtDecodersTests {
 				.hasMessageContaining("This iss claim is not equal to the configured issuer");
 	}
 
+	@Test
+	public void issuerWhenOidcFallbackResponseIsTypicalThenReturnedDecoderValidatesIssuer() {
+		prepareConfigurationResponseOidc();
+
+		ReactiveJwtDecoder decoder = ReactiveJwtDecoders.fromIssuerLocation(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 issuerWhenOAuth2ResponseIsTypicalThenReturnedDecoderValidatesIssuer() {
+		prepareConfigurationResponseOAuth2();
+
+		ReactiveJwtDecoder decoder = ReactiveJwtDecoders.fromIssuerLocation(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\" }");
+		prepareConfigurationResponse("{ \"missing_required_keys\" : \"and_values\" }");
 
 		assertThatCode(() -> ReactiveJwtDecoders.fromOidcIssuerLocation(this.issuer))
 				.isInstanceOf(RuntimeException.class);
 	}
 
+	@Test
+	public void issuerWhenOidcFallbackResponseIsNonCompliantThenThrowsRuntimeException() {
+		prepareConfigurationResponseOidc("{ \"missing_required_keys\" : \"and_values\" }");
+		assertThatCode(() -> ReactiveJwtDecoders.fromIssuerLocation(this.issuer))
+				.isInstanceOf(RuntimeException.class);
+	}
+
+	@Test
+	public void issuerWhenOAuth2ResponseIsNonCompliantThenThrowsRuntimeException() {
+		prepareConfigurationResponseOAuth2("{ \"missing_required_keys\" : \"and_values\" }");
+		assertThatCode(() -> ReactiveJwtDecoders.fromIssuerLocation(this.issuer))
+				.isInstanceOf(RuntimeException.class);
+	}
+
 	@Test
 	public void issuerWhenResponseIsMalformedThenThrowsRuntimeException() {
-		prepareOpenIdConfigurationResponse("malformed");
+		prepareConfigurationResponse("malformed");
 
 		assertThatCode(() -> ReactiveJwtDecoders.fromOidcIssuerLocation(this.issuer))
 				.isInstanceOf(RuntimeException.class);
 	}
 
+	@Test
+	public void issuerWhenOidcFallbackResponseIsMalformedThenThrowsRuntimeException() {
+		prepareConfigurationResponseOidc("malformed");
+		assertThatCode(() -> ReactiveJwtDecoders.fromIssuerLocation(this.issuer))
+				.isInstanceOf(RuntimeException.class);
+	}
+
+	@Test
+	public void issuerWhenOAuth2ResponseIsMalformedThenThrowsRuntimeException() {
+		prepareConfigurationResponseOAuth2("malformed");
+		assertThatCode(() -> ReactiveJwtDecoders.fromIssuerLocation(this.issuer))
+				.isInstanceOf(RuntimeException.class);
+	}
+
 	@Test
 	public void issuerWhenRespondingIssuerMismatchesRequestedIssuerThenThrowsIllegalStateException() {
-		prepareOpenIdConfigurationResponse();
+		prepareConfigurationResponse(String.format(DEFAULT_RESPONSE_TEMPLATE, this.issuer + "/wrong", this.issuer));
+
+		assertThatCode(() -> ReactiveJwtDecoders.fromOidcIssuerLocation(this.issuer))
+				.isInstanceOf(IllegalStateException.class);
+	}
+
+	@Test
+	public void issuerWhenOidcFallbackRespondingIssuerMismatchesRequestedIssuerThenThrowsIllegalStateException() {
+		prepareConfigurationResponseOidc(String.format(DEFAULT_RESPONSE_TEMPLATE, this.issuer + "/wrong", this.issuer));
+		assertThatCode(() -> ReactiveJwtDecoders.fromIssuerLocation(this.issuer))
+				.isInstanceOf(IllegalStateException.class);
+	}
 
-		assertThatCode(() -> ReactiveJwtDecoders.fromOidcIssuerLocation(this.issuer + "/wrong"))
+	@Test
+	public void issuerWhenOAuth2RespondingIssuerMismatchesRequestedIssuerThenThrowsIllegalStateException() {
+		prepareConfigurationResponseOAuth2(String.format(DEFAULT_RESPONSE_TEMPLATE, this.issuer + "/wrong", this.issuer));
+		assertThatCode(() -> ReactiveJwtDecoders.fromIssuerLocation(this.issuer))
 				.isInstanceOf(IllegalStateException.class);
 	}
 
@@ -125,19 +201,84 @@ public class ReactiveJwtDecodersTests {
 				.isInstanceOf(IllegalArgumentException.class);
 	}
 
-	private void prepareOpenIdConfigurationResponse() {
+	@Test
+	public void issuerWhenOidcFallbackRequestedIssuerIsUnresponsiveThenThrowsIllegalArgumentException()
+			throws Exception {
+
+		this.server.shutdown();
+		assertThatCode(() -> ReactiveJwtDecoders.fromIssuerLocation("https://issuer"))
+				.isInstanceOf(IllegalArgumentException.class);
+	}
+
+	private void prepareConfigurationResponse() {
 		String body = String.format(DEFAULT_RESPONSE_TEMPLATE, this.issuer, this.issuer);
-		prepareOpenIdConfigurationResponse(body);
+		prepareConfigurationResponse(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 void prepareConfigurationResponse(String body) {
+		this.server.enqueue(response(body));
+		this.server.enqueue(response(JWK_SET));
+	}
+
+	private void prepareConfigurationResponseOidc() {
+		String body = String.format(DEFAULT_RESPONSE_TEMPLATE, this.issuer, this.issuer);
+		prepareConfigurationResponseOidc(body);
+	}
+
+	private void prepareConfigurationResponseOidc(String body) {
+		Map<String, MockResponse> responses = new HashMap<>();
+		responses.put(oidc(), response(body));
+		responses.put(jwks(), response(JWK_SET));
+		prepareConfigurationResponses(responses);
+	}
+
+	private void prepareConfigurationResponseOAuth2() {
+		String body = String.format(DEFAULT_RESPONSE_TEMPLATE, this.issuer, this.issuer);
+		prepareConfigurationResponseOAuth2(body);
+	}
+
+	private void prepareConfigurationResponseOAuth2(String body) {
+		Map<String, MockResponse> responses = new HashMap<>();
+		responses.put(oauth(), response(body));
+		responses.put(jwks(), response(JWK_SET));
+		prepareConfigurationResponses(responses);
+	}
+
+	private void prepareConfigurationResponses(Map<String, MockResponse> responses) {
+		Dispatcher dispatcher = new Dispatcher() {
+			@Override
+			public MockResponse dispatch(RecordedRequest request) {
+				return Optional.of(request).map(RecordedRequest::getRequestUrl).map(HttpUrl::toString)
+						.map(responses::get)
+						.orElse(new MockResponse().setResponseCode(404));
+			}
+		};
+		this.server.setDispatcher(dispatcher);
 	}
 
 	private String createIssuerFromServer() {
 		return this.server.url("").toString();
 	}
+
+	private String oidc() {
+		URI uri = URI.create(this.issuer);
+		return UriComponentsBuilder.fromUri(uri)
+				.replacePath(uri.getPath() + OIDC_METADATA_PATH).toUriString();
+	}
+
+	private String oauth() {
+		URI uri = URI.create(this.issuer);
+		return UriComponentsBuilder.fromUri(uri)
+				.replacePath(OAUTH_METADATA_PATH + uri.getPath()).toUriString();
+	}
+
+	private String jwks() {
+		return this.issuer + "/.well-known/jwks.json";
+	}
+
+	private MockResponse response(String body) {
+		return new MockResponse()
+				.setBody(body)
+				.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
+	}
 }