diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/registration/ClientRegistrations.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/registration/ClientRegistrations.java index 5a7663a5262..9accaa924b5 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/registration/ClientRegistrations.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/registration/ClientRegistrations.java @@ -19,28 +19,41 @@ 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 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.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 - * OpenID Provider Configuration. + * OpenID Provider Configuration + * or Authorization Server Metadata based on + * provided issuer. * * @author Rob Winch * @author Josh Cummings + * @author Rafiullah Hamedy * @since 5.1 */ 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; + } /** * Creates a {@link ClientRegistration.Builder} using the provided @@ -50,6 +63,12 @@ public final class ClientRegistrations { * OpenID * Provider Configuration Response 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 Compatibility Notes + * first make an OpenID Provider + * Configuration Request 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. + * *

* 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 @@ -69,11 +88,77 @@ public final class ClientRegistrations { * @return a {@link ClientRegistration.Builder} that was initialized by the OpenID Provider Configuration. */ public static ClientRegistration.Builder fromOidcIssuerLocation(String issuer) { - String openidConfiguration = getOpenidConfiguration(issuer); - OIDCProviderMetadata metadata = parse(openidConfiguration); + Map configuration = getIssuerConfiguration(issuer, OIDC_METADATA_PATH); + OIDCProviderMetadata metadata = parse(configuration.get(ProviderType.OIDCV1), OIDCProviderMetadata::parse); + return withProviderConfiguration(metadata, issuer) + .userInfoUri(metadata.getUserInfoEndpointURI().toASCIIString()); + } + + /** + * Unlike fromOidcIssuerLocation the fromIssuerLocation queries three different endpoints and uses the + * returned response from whichever that returns successfully. When fromIssuerLocation is invoked with an issuer + * the following sequence of actions take place + * + *

    + *
  1. + * The first request is made against {host}/.well-known/openid-configuration/issuer1 where issuer is equal to + * issuer1. See Compatibility Notes of RFC 8414 + * specification for more details. + *
  2. + *
  3. + * If the first attempt request returned non-Success (i.e. 200 status code) response then based on Compatibility Notes of + * RFC 8414 a fallback + * OpenID Provider Configuration Request is made to {host}/issuer1/.well-known/openid-configuration + *
  4. + *
  5. + * If the second attempted request returns a non-Success (i.e. 200 status code) response then based a final + * Authorization Server Metadata Request is being made to + * {host}/.well-known/oauth-authorization-server/issuer1. + *
  6. + *
+ * + * + * As explained above, fromIssuerLocation would behave the exact same way as fromOidcIssuerLocation and that is + * because fromIssuerLocation does the exact same processing as fromOidcIssuerLocation behind the scene. Use of + * fromIssuerLocation 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 fromIssuerLocation is based on RFC 8414 specification. + * + *

+ * Example usage: + *

+ *
+	 * ClientRegistration registration = ClientRegistrations.fromIssuerLocation("https://example.com")
+	 *     .clientId("client-id")
+	 *     .clientSecret("client-secret")
+	 *     .build();
+	 * 
+ * + * @param issuer + * @return a {@link ClientRegistration.Builder} that was initialized by the Authorization Sever Metadata Provider + */ + public static ClientRegistration.Builder fromIssuerLocation(String issuer) { + Map configuration = getIssuerConfiguration(issuer, OIDC_METADATA_PATH, OAUTH2_METADATA_PATH); + + 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 ClientRegistration.Builder withProviderConfiguration(AuthorizationServerMetadata metadata, String issuer) { String metadataIssuer = metadata.getIssuer().getValue(); if (!issuer.equals(metadataIssuer)) { - throw new IllegalStateException("The Issuer \"" + metadataIssuer + "\" provided in the OpenID Configuration did not match the requested issuer \"" + issuer + "\""); + throw new IllegalStateException("The Issuer \"" + metadataIssuer + "\" provided in the configuration metadata did " + + "not match the requested issuer \"" + issuer + "\""); } String name = URI.create(issuer).getHost(); @@ -81,7 +166,8 @@ public static ClientRegistration.Builder fromOidcIssuerLocation(String issuer) { List grantTypes = metadata.getGrantTypes(); // If null, the default includes authorization_code if (grantTypes != null && !grantTypes.contains(GrantType.AUTHORIZATION_CODE)) { - throw new IllegalArgumentException("Only AuthorizationGrantType.AUTHORIZATION_CODE is supported. The issuer \"" + issuer + "\" returned a configuration of " + grantTypes); + throw new IllegalArgumentException("Only AuthorizationGrantType.AUTHORIZATION_CODE is supported. The issuer \"" + issuer + + "\" returned a configuration of " + grantTypes); } List scopes = getScopes(metadata); Map configurationMetadata = new LinkedHashMap<>(metadata.toJSONObject()); @@ -95,21 +181,118 @@ public static ClientRegistration.Builder fromOidcIssuerLocation(String issuer) { .authorizationUri(metadata.getAuthorizationEndpointURI().toASCIIString()) .jwkSetUri(metadata.getJWKSetURI().toASCIIString()) .providerConfigurationMetadata(configurationMetadata) - .userInfoUri(metadata.getUserInfoEndpointURI().toASCIIString()) .tokenUri(metadata.getTokenEndpointURI().toASCIIString()) .clientName(issuer); } - private static String getOpenidConfiguration(String 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 {host}/issuer1/.well-known/openid-configuration. + * 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 - Configuration Metadata from the given issuer + */ + private static Map getIssuerConfiguration(String issuer, String... paths) { + Assert.notEmpty(paths, "paths cannot be empty or null."); + + Map providersUrl = buildIssuerConfigurationUrls(issuer, paths); + Map 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 mapResponse(Map providersUrl, ProviderType providerType) { + Map 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(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); + return rest.getForObject(uri, String.class); + } catch(RuntimeException ex) { + return null; + } + } + + /** + * When invoked with a path then make a + * + * OpenID Provider Configuration Request by querying the OpenId Connection Discovery 1.0 endpoint + * and the url would look as follow {host}/issuer1/.well-known/openid-configuration + * + *

+ * When more than one path is provided then query all the three (3) endpoints for metadata configuration + * as per Section 5 of RF 8414 specification + * and the URLs would look as follow + *

+ * + *
    + *
  1. + * {host}/.well-known/openid-configuration/issuer1 - OpenID as per RFC 8414 + *
  2. + *
  3. + * {host}/issuer1/.well-known/openid-configuration - OpenID Connect 1.0 Discovery Compatibility as per RFC 8414 + *
  4. + *
  5. + * /.well-known/oauth-authorization-server/issuer1 - OAuth2 Authorization Server Metadata as per RFC 8414 + *
  6. + *
+ * + * @param issuer + * @param paths + * @throws IllegalArgumentException throws exception if paths length is not 1 or 3, 1 for fromOidcLocationIssuer + * and 3 for the newly introduced fromIssuerLocation to support querying 3 different metadata provider endpoints + * @return Map key-value map of provider with its request url + */ + private static Map buildIssuerConfigurationUrls(String issuer, String... paths) { + Assert.isTrue(paths.length != 1 || paths.length != 3, "paths length can either be 1 or 3"); + + Map 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 metadataAuthMethods) { + private static ClientAuthenticationMethod getClientAuthenticationMethod(String issuer, + List metadataAuthMethods) { if (metadataAuthMethods == null || metadataAuthMethods.contains(com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod.CLIENT_SECRET_BASIC)) { // If null, the default includes client_secret_basic return ClientAuthenticationMethod.BASIC; @@ -120,10 +303,11 @@ private static ClientAuthenticationMethod getClientAuthenticationMethod(String i if (metadataAuthMethods.contains(com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod.NONE)) { return ClientAuthenticationMethod.NONE; } - throw new IllegalArgumentException("Only ClientAuthenticationMethod.BASIC, ClientAuthenticationMethod.POST and ClientAuthenticationMethod.NONE are supported. The issuer \"" + issuer + "\" returned a configuration of " + metadataAuthMethods); + throw new IllegalArgumentException("Only ClientAuthenticationMethod.BASIC, ClientAuthenticationMethod.POST and " + + "ClientAuthenticationMethod.NONE are supported. The issuer \"" + issuer + "\" returned a configuration of " + metadataAuthMethods); } - private static List getScopes(OIDCProviderMetadata metadata) { + private static List getScopes(AuthorizationServerMetadata metadata) { Scope scope = metadata.getScopes(); if (scope == null) { // If null, default to "openid" which must be supported @@ -133,15 +317,18 @@ private static List getScopes(OIDCProviderMetadata metadata) { } } - private static OIDCProviderMetadata parse(String body) { + private static T parse(String body, ThrowingFunction parser) { try { - return OIDCProviderMetadata.parse(body); - } - catch (ParseException e) { + return parser.apply(body); + } catch (ParseException e) { throw new RuntimeException(e); } } + private interface ThrowingFunction { + T apply(S src) throws E; + } + private ClientRegistrations() {} } diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/registration/ClientRegistrationsTest.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/registration/ClientRegistrationsTest.java index 5911b299af7..26394d04ee9 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/registration/ClientRegistrationsTest.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/registration/ClientRegistrationsTest.java @@ -18,8 +18,12 @@ 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; @@ -36,6 +40,7 @@ /** * @author Rob Winch + * @author Rafiullah Hamedy * @since 5.1 */ public class ClientRegistrationsTest { @@ -122,7 +127,34 @@ public void cleanup() throws Exception { public void issuerWhenAllInformationThenSuccess() throws Exception { ClientRegistration registration = registration("").build(); ClientRegistration.ProviderDetails provider = registration.getProviderDetails(); + assertIssuerMetadata(registration, provider); + assertThat(provider.getUserInfoEndpoint().getUri()).isEqualTo("https://example.com/oauth2/v3/userinfo"); + } + + /** + * + * Test compatibility with OpenID v1 discovery endpoint by making a + * OpenID Provider + * Configuration Request as highlighted + * Compatibility Notes of RFC 8414 specification. + */ + @Test + public void issuerWhenOidcFallbackAllInformationThenSuccess() throws Exception { + ClientRegistration registration = registrationOidcFallback("issuer1", null).build(); + ClientRegistration.ProviderDetails provider = registration.getProviderDetails(); + assertIssuerMetadata(registration, provider); + assertThat(provider.getUserInfoEndpoint().getUri()).isEqualTo("https://example.com/oauth2/v3/userinfo"); + } + + @Test + public void issuerWhenOauth2AllInformationThenSuccess() throws Exception { + ClientRegistration registration = registrationOauth2("", null).build(); + ClientRegistration.ProviderDetails provider = registration.getProviderDetails(); + assertIssuerMetadata(registration, provider); + } + private void assertIssuerMetadata(ClientRegistration registration, + ClientRegistration.ProviderDetails provider) { assertThat(registration.getClientAuthenticationMethod()).isEqualTo(ClientAuthenticationMethod.BASIC); assertThat(registration.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE); assertThat(registration.getRegistrationId()).isEqualTo(this.server.getHostName()); @@ -135,7 +167,6 @@ public void issuerWhenAllInformationThenSuccess() throws Exception { "code_challenge_methods_supported", "id_token_signing_alg_values_supported", "issuer", "jwks_uri", "response_types_supported", "revocation_endpoint", "scopes_supported", "subject_types_supported", "grant_types_supported", "token_endpoint", "token_endpoint_auth_methods_supported", "userinfo_endpoint"); - assertThat(provider.getUserInfoEndpoint().getUri()).isEqualTo("https://example.com/oauth2/v3/userinfo"); } @Test @@ -144,6 +175,18 @@ public void issuerWhenContainsTrailingSlashThenSuccess() throws Exception { assertThat(this.issuer).endsWith("/"); } + @Test + public void issuerWhenOidcFallbackContainsTrailingSlashThenSuccess() throws Exception { + assertThat(registrationOidcFallback("", null)).isNotNull(); + assertThat(this.issuer).endsWith("/"); + } + + @Test + public void issuerWhenOauth2ContainsTrailingSlashThenSuccess() throws Exception { + assertThat(registrationOauth2("", null)).isNotNull(); + assertThat(this.issuer).endsWith("/"); + } + /** * https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata * @@ -160,6 +203,25 @@ public void issuerWhenScopesNullThenScopesDefaulted() throws Exception { assertThat(registration.getScopes()).containsOnly("openid"); } + @Test + public void issuerWhenOidcFallbackScopesNullThenScopesDefaulted() throws Exception { + this.response.remove("scopes_supported"); + + ClientRegistration registration = registrationOidcFallback("", null).build(); + + assertThat(registration.getScopes()).containsOnly("openid"); + } + + @Test + public void issuerWhenOauth2ScopesNullThenScopesDefaulted() throws Exception { + this.response.remove("scopes_supported"); + + ClientRegistration registration = registrationOauth2("", null).build(); + + assertThat(registration.getScopes()).containsOnly("openid"); + } + + @Test public void issuerWhenGrantTypesSupportedNullThenDefaulted() throws Exception { this.response.remove("grant_types_supported"); @@ -169,10 +231,20 @@ public void issuerWhenGrantTypesSupportedNullThenDefaulted() throws Exception { assertThat(registration.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE); } + @Test + public void issuerWhenOauth2GrantTypesSupportedNullThenDefaulted() throws Exception { + this.response.remove("grant_types_supported"); + + ClientRegistration registration = registrationOauth2("", null).build(); + + assertThat(registration.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE); + } + /** * We currently only support authorization_code, so verify we have a meaningful error until we add support. * @throws Exception */ + @Test public void issuerWhenGrantTypesSupportedInvalidThenException() throws Exception { this.response.put("grant_types_supported", Arrays.asList("implicit")); @@ -181,6 +253,15 @@ public void issuerWhenGrantTypesSupportedInvalidThenException() throws Exception .hasMessageContaining("Only AuthorizationGrantType.AUTHORIZATION_CODE is supported. The issuer \"" + this.issuer + "\" returned a configuration of [implicit]"); } + @Test + public void issuerWhenOauth2GrantTypesSupportedInvalidThenException() throws Exception { + this.response.put("grant_types_supported", Arrays.asList("implicit")); + + assertThatThrownBy(() -> registrationOauth2("", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Only AuthorizationGrantType.AUTHORIZATION_CODE is supported. The issuer \"" + this.issuer + "\" returned a configuration of [implicit]"); + } + @Test public void issuerWhenTokenEndpointAuthMethodsNullThenDefaulted() throws Exception { this.response.remove("token_endpoint_auth_methods_supported"); @@ -190,6 +271,15 @@ public void issuerWhenTokenEndpointAuthMethodsNullThenDefaulted() throws Excepti assertThat(registration.getClientAuthenticationMethod()).isEqualTo(ClientAuthenticationMethod.BASIC); } + @Test + public void issuerWhenOauth2TokenEndpointAuthMethodsNullThenDefaulted() throws Exception { + this.response.remove("token_endpoint_auth_methods_supported"); + + ClientRegistration registration = registrationOauth2("", null).build(); + + assertThat(registration.getClientAuthenticationMethod()).isEqualTo(ClientAuthenticationMethod.BASIC); + } + @Test public void issuerWhenTokenEndpointAuthMethodsPostThenMethodIsPost() throws Exception { this.response.put("token_endpoint_auth_methods_supported", Arrays.asList("client_secret_post")); @@ -199,6 +289,15 @@ public void issuerWhenTokenEndpointAuthMethodsPostThenMethodIsPost() throws Exce assertThat(registration.getClientAuthenticationMethod()).isEqualTo(ClientAuthenticationMethod.POST); } + @Test + public void issuerWhenOauth2TokenEndpointAuthMethodsPostThenMethodIsPost() throws Exception { + this.response.put("token_endpoint_auth_methods_supported", Arrays.asList("client_secret_post")); + + ClientRegistration registration = registrationOauth2("", null).build(); + + assertThat(registration.getClientAuthenticationMethod()).isEqualTo(ClientAuthenticationMethod.POST); + } + @Test public void issuerWhenTokenEndpointAuthMethodsNoneThenMethodIsNone() throws Exception { this.response.put("token_endpoint_auth_methods_supported", Arrays.asList("none")); @@ -208,6 +307,15 @@ public void issuerWhenTokenEndpointAuthMethodsNoneThenMethodIsNone() throws Exce assertThat(registration.getClientAuthenticationMethod()).isEqualTo(ClientAuthenticationMethod.NONE); } + @Test + public void issuerWhenOauth2TokenEndpointAuthMethodsNoneThenMethodIsNone() throws Exception { + this.response.put("token_endpoint_auth_methods_supported", Arrays.asList("none")); + + ClientRegistration registration = registrationOauth2("", null).build(); + + assertThat(registration.getClientAuthenticationMethod()).isEqualTo(ClientAuthenticationMethod.NONE); + } + /** * We currently only support client_secret_basic, so verify we have a meaningful error until we add support. * @throws Exception @@ -221,10 +329,25 @@ public void issuerWhenTokenEndpointAuthMethodsInvalidThenException() throws Exce .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 issuerWhenOauth2TokenEndpointAuthMethodsInvalidThenException() throws Exception { + this.response.put("token_endpoint_auth_methods_supported", Arrays.asList("tls_client_auth")); + + 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() { + assertThatThrownBy(() -> ClientRegistrations.fromIssuerLocation("")) + .hasMessageContaining("Unable to resolve Configuration with the provided Issuer of \"\""); + } + @Test public void issuerWhenEmptyStringThenMeaningfulErrorMessage() { assertThatThrownBy(() -> ClientRegistrations.fromOidcIssuerLocation("")) - .hasMessageContaining("Unable to resolve the OpenID Configuration with the provided Issuer of \"\""); + .hasMessageContaining("Unable to resolve Configuration with the provided Issuer of \"\""); } @Test @@ -236,7 +359,19 @@ public void issuerWhenOpenIdConfigurationDoesNotMatchThenMeaningfulErrorMessage( .setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); this.server.enqueue(mockResponse); assertThatThrownBy(() -> ClientRegistrations.fromOidcIssuerLocation(this.issuer)) - .hasMessageContaining("The Issuer \"https://example.com\" provided in the OpenID Configuration did not match the requested issuer \"" + this.issuer + "\""); + .hasMessageContaining("The Issuer \"https://example.com\" provided in the configuration metadata did not match the requested issuer \"" + this.issuer + "\""); + } + + @Test + public void issuerWhenOauth2ConfigurationDoesNotMatchThenMeaningfulErrorMessage() throws Exception { + this.issuer = createIssuerFromServer(""); + String body = this.mapper.writeValueAsString(this.response); + MockResponse mockResponse = new MockResponse() + .setBody(body) + .setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); + this.server.enqueue(mockResponse); + assertThatThrownBy(() -> ClientRegistrations.fromIssuerLocation(this.issuer)) + .hasMessageContaining("The Issuer \"https://example.com\" provided in the configuration metadata did not match the requested issuer \"" + this.issuer + "\""); } private ClientRegistration.Builder registration(String path) throws Exception { @@ -253,7 +388,72 @@ private ClientRegistration.Builder registration(String path) throws Exception { .clientSecret("client-secret"); } + 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(); + final String responseBody = body != null ? body : this.mapper.writeValueAsString(this.response); + + final 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/": + return buildSuccessMockResponse(responseBody); + } + return new MockResponse().setResponseCode(404); + } + }; + + this.server.setDispatcher(dispatcher); + + return ClientRegistrations.fromIssuerLocation(this.issuer) + .clientId("client-id") + .clientSecret("client-secret"); + } + + private String createIssuerFromServer(String path) { return this.server.url(path).toString(); } + + /** + * Simulates a situation when the ClientRegistration is used with a legacy application where the OIDC + * Discovery Endpoint is "/issuer1/.well-known/openid-configuration" instead of + * "/.well-known/openid-configuration/issuer1" in which case the first attempt results in HTTP 404 and + * the subsequent call results in 200 OK. + * + * @see Section 5 for more details. + */ + private ClientRegistration.Builder registrationOidcFallback(String path, String body) throws Exception { + this.issuer = createIssuerFromServer(path); + this.response.put("issuer", this.issuer); + + String responseBody = body != null ? body : this.mapper.writeValueAsString(this.response); + + 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/": + return buildSuccessMockResponse(responseBody); + } + return new MockResponse().setResponseCode(404); + } + }; + this.server.setDispatcher(dispatcher); + + return ClientRegistrations.fromIssuerLocation(this.issuer) + .clientId("client-id") + .clientSecret("client-secret"); + } + + private MockResponse buildSuccessMockResponse(String body) { + return new MockResponse().setResponseCode(200) + .setBody(body) + .setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); + } } diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtDecoders.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtDecoders.java index e407d25b7f7..2f72da63c68 100644 --- a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtDecoders.java +++ b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtDecoders.java @@ -15,25 +15,32 @@ */ package org.springframework.security.oauth2.jwt; +import static org.springframework.security.oauth2.jwt.NimbusJwtDecoder.withJwkSetUri; + import java.net.URI; import java.util.Map; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.RequestEntity; import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.util.Assert; +import org.springframework.web.client.RestClientException; 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 - * OpenID Provider Configuration. + * OpenID Provider Configuration or + * Authorization Server Metadata Request based on provided + * issuer and method invoked. * * @author Josh Cummings + * @author Rafiullah Hamedy * @since 5.1 */ 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"; /** * Creates a {@link JwtDecoder} using the provided @@ -47,37 +54,162 @@ public final class JwtDecoders { * @return a {@link JwtDecoder} that was initialized by the OpenID Provider Configuration. */ public static JwtDecoder fromOidcIssuerLocation(String oidcIssuerLocation) { - Map openidConfiguration = getOpenidConfiguration(oidcIssuerLocation); + Map configuration = getIssuerConfiguration(oidcIssuerLocation, OIDC_METADATA_PATH); + 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. + * + *
    + *
  1. + * {host}/.well-known/openid-configuration/issuer1 - OpenID Provider Configuration Request based on + * Section 5 of + * RFC 8414 Specification + *
  2. + *
  3. + * {host}/issuer1/.well-known/openid-configuration - OpenID v1 Discovery endpoint based on + * OpenID Provider + * Configuration Request with backward compatibility highlighted on + * Section 5 of RF 8414 + *
  4. + *
  5. + * {host}/.well-known/oauth-authorization-server/issuer1 - OAuth2 Authorization Server Metadata based on + * Section 3.1 of RFC 8414 + *
  6. + *
+ * + * @param issuer + * @return a {@link JwtDecoder} that is initialized using + * + * OpenID Provider Configuration Response or + * Authorization Server Metadata Response depending on provided issuer + */ + public static JwtDecoder fromIssuerLocation(String issuer) { + Map configuration = getIssuerConfiguration(issuer, OIDC_METADATA_PATH, OAUTH2_METADATA_PATH); + return withProviderConfiguration(configuration, issuer); + } + + /** + * Validate provided issuer and build {@link JwtDecoder} from + * OpenID Provider + * Configuration Response and Authorization Server Metadata + * Response. + * + * @param configuration + * @param issuer + * @return {@link JwtDecoder} + */ + private static JwtDecoder withProviderConfiguration(Map 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 jwtValidator = - JwtValidators.createDefaultWithIssuer(oidcIssuerLocation); - - NimbusJwtDecoder jwtDecoder = withJwkSetUri(openidConfiguration.get("jwks_uri").toString()).build(); + OAuth2TokenValidator jwtValidator = JwtValidators.createDefaultWithIssuer(issuer); + NimbusJwtDecoder jwtDecoder = withJwkSetUri(configuration.get("jwks_uri").toString()).build(); jwtDecoder.setJwtValidator(jwtValidator); return jwtDecoder; } - private static Map getOpenidConfiguration(String issuer) { - ParameterizedTypeReference> typeReference = new ParameterizedTypeReference>() {}; + /** + * When the length of paths is equal to one (1) then it's a request for OpenId v1 discovery endpoint + * hence a request to {host}/issuer1/.well-known/openid-configuration 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 - Configuration Metadata from the given issuer + */ + private static Map getIssuerConfiguration(String issuer, String... paths) { + Assert.notEmpty(paths, "paths cannot be empty or null."); + + URI[] uris = buildIssuerConfigurationUrls(issuer, paths); + for (URI uri: uris) { + Map 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 Configuration Metadata of the given provider if not null + */ + private static Map makeIssuerRequest(URI uri) { RestTemplate rest = new RestTemplate(); + ParameterizedTypeReference> typeReference = new ParameterizedTypeReference>() {}; try { - URI uri = UriComponentsBuilder.fromUriString(issuer + "/.well-known/openid-configuration") - .build() - .toUri(); RequestEntity 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); + } catch(RestClientException ex) { + return null; + } catch(RuntimeException ex) { + return null; + } + } + + /** + * When invoked with a path then make a + * + * OpenID Provider Configuration Request by querying the OpenId Connection Discovery 1.0 endpoint + * and the url would look as follow {host}/issuer1/.well-known/openid-configuration + * + *

+ * When more than one path is provided then query all the three (3) endpoints for metadata configuration + * as per Section 5 of RF 8414 specification + * and the urls would look as follow + *

+ * + *
    + *
  1. + * {host}/.well-known/openid-configuration/issuer1 - OpenID as per RFC 8414 + *
  2. + *
  3. + * {host}/issuer1/.well-known/openid-configuration - OpenID Connect 1.0 Discovery Compatibility as per RFC 8414 + *
  4. + *
  5. + * {host}/.well-known/oauth-authorization-server/issuer1 - OAuth2 Authorization Server Metadata as per RFC 8414 + *
  6. + *
+ * + * @param issuer + * @param paths + * @throws IllegalArgumentException throws exception if paths length is not 1 or 3, 1 for fromOidcLocationIssuer + * and 3 for the newly introduced fromIssuerLocation to support querying 3 different metadata provider endpoints + * @return URI[] URIs for to + * OpenID Provider Configuration Response and + * Authorization Server Metadata Response + */ + 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() + }; } } diff --git a/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtDecodersTests.java b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtDecodersTests.java index 021d4fa81b0..09531914aca 100644 --- a/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtDecodersTests.java +++ b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtDecodersTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2019 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. @@ -15,8 +15,11 @@ */ package org.springframework.security.oauth2.jwt; +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; @@ -31,6 +34,7 @@ * Tests for {@link JwtDecoders} * * @author Josh Cummings + * @author Rafiullah Hamedy */ public class JwtDecodersTests { /** @@ -65,14 +69,12 @@ public class JwtDecodersTests { 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 @@ -82,48 +84,125 @@ public void cleanup() throws Exception { @Test public void issuerWhenResponseIsTypicalThenReturnedDecoderValidatesIssuer() { - prepareOpenIdConfigurationResponse(); + prepareConfigurationResponse(); this.server.enqueue(new MockResponse().setBody(JWK_SET)); JwtDecoder decoder = JwtDecoders.fromOidcIssuerLocation(this.issuer); + assertResponseIsTypicalThenReturnedDecoderValidatesIssuer(decoder); + } + + @Test + public void issuerWhenOidcFallbackResponseIsTypicalThenReturnedDecoderValidatesIssuer() { + prepareConfigurationResponseForOidcFallback("issuer1", null); + + JwtDecoder decoder = JwtDecoders.fromIssuerLocation(this.issuer); + + assertResponseIsTypicalThenReturnedDecoderValidatesIssuer(decoder); + } + + @Test + public void issuerWhenOauth2ResponseIsTypicalThenReturnedDecoderValidatesIssuer() { + prepareConfigurationResponseForOauth2("issuer1", null); + + 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() { - prepareOpenIdConfigurationResponse(); + prepareConfigurationResponse(); this.server.enqueue(new MockResponse().setBody(JWK_SET)); assertThat(JwtDecoders.fromOidcIssuerLocation(this.issuer)).isNotNull(); assertThat(this.issuer).endsWith("/"); } @Test - public void issuerWhenResponseIsNonCompliantThenThrowsRuntimeException() { - prepareOpenIdConfigurationResponse("{ \"missing_required_keys\" : \"and_values\" }"); + public void issuerWhenOidcFallbackContainsTrailingSlashThenSuccess() { + prepareConfigurationResponse(); + this.server.enqueue(new MockResponse().setBody(JWK_SET)); + assertThat(JwtDecoders.fromIssuerLocation(this.issuer)).isNotNull(); + assertThat(this.issuer).endsWith("/"); + } + + @Test + public void issuerWhenOauth2ContainsTrailingSlashThenSuccess() { + prepareConfigurationResponseForOauth2("", null); + assertThat(JwtDecoders.fromIssuerLocation(this.issuer)).isNotNull(); + assertThat(this.issuer).endsWith("/"); + } + @Test + public void issuerWhenResponseIsNonCompliantThenThrowsRuntimeException() { + prepareConfigurationResponse("{ \"missing_required_keys\" : \"and_values\" }"); assertThatCode(() -> JwtDecoders.fromOidcIssuerLocation(this.issuer)) .isInstanceOf(RuntimeException.class); } + @Test + public void issuerWhenOidcFallbackResponseIsNonCompliantThenThrowsRuntimeException() { + prepareConfigurationResponseForOidcFallback("", "{ \"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); + assertThatCode(() -> JwtDecoders.fromIssuerLocation(this.issuer)) + .isInstanceOf(RuntimeException.class); + } + @Test public void issuerWhenResponseIsMalformedThenThrowsRuntimeException() { - prepareOpenIdConfigurationResponse("malformed"); + prepareConfigurationResponse("malformed"); + assertThatCode(() -> JwtDecoders.fromOidcIssuerLocation(this.issuer)) + .isInstanceOf(RuntimeException.class); + } + @Test + public void issuerWhenOidcFallbackResponseIsMalformedThenThrowsRuntimeException() { + prepareConfigurationResponseForOidcFallback("", "malformed"); assertThatCode(() -> JwtDecoders.fromOidcIssuerLocation(this.issuer)) .isInstanceOf(RuntimeException.class); } @Test - public void issuerWhenRespondingIssuerMismatchesRequestedIssuerThenThrowsIllegalStateException() { - prepareOpenIdConfigurationResponse(); + public void issuerWhenOauth2ResponseIsMalformedThenThrowsRuntimeException() { + prepareConfigurationResponseForOauth2("", "malformed"); + assertThatCode(() -> JwtDecoders.fromIssuerLocation(this.issuer)) + .isInstanceOf(RuntimeException.class); + } + @Test + public void issuerWhenRespondingIssuerMismatchesRequestedIssuerThenThrowsIllegalStateException() { + prepareConfigurationResponse(); assertThatCode(() -> JwtDecoders.fromOidcIssuerLocation(this.issuer + "/wrong")) .isInstanceOf(IllegalStateException.class); } + @Test + public void issuerWhenOidcFallbackRespondingIssuerMismatchesRequestedIssuerThenThrowsIllegalStateException() { + prepareConfigurationResponseForOidcFallback("", null); + assertThatCode(() -> JwtDecoders.fromIssuerLocation(this.issuer + "/wrong")) + .isInstanceOf(IllegalStateException.class); + } + + @Test + public void issuerWhenOauth2RespondingIssuerMismatchesRequestedIssuerThenThrowsIllegalStateException() { + prepareConfigurationResponseForOauth2("", null); + assertThatCode(() -> JwtDecoders.fromIssuerLocation(this.issuer + "/wrong")) + .isInstanceOf(IllegalStateException.class); + } + @Test public void issuerWhenRequestedIssuerIsUnresponsiveThenThrowsIllegalArgumentException() throws Exception { @@ -134,18 +213,90 @@ public void issuerWhenRequestedIssuerIsUnresponsiveThenThrowsIllegalArgumentExce .isInstanceOf(IllegalArgumentException.class); } - private void prepareOpenIdConfigurationResponse() { + @Test + public void issuerWhenOidcFallbackRequestedIssuerIsUnresponsiveThenThrowsIllegalArgumentException() + throws Exception { + + this.server.shutdown(); + + assertThatCode(() -> JwtDecoders.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) { + private void prepareConfigurationResponse(String body) { MockResponse mockResponse = new MockResponse() .setBody(body) .setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); this.server.enqueue(mockResponse); } + /** + * 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); + + 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); + } + + /** + * 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); + + final 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); + } + }; + 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(); }