OIDC Provider Configuration - ClientRegistrations
OIDC Provider Configuration is now being used to create more than just ClientRegistration instances. Also, the endpoint is being addressed in more contexts than just the client. To that end, this refactors OidcConfigurationProvider in the config project to ClientRegistrations in the oauth2-client project. Fixes: gh-5647
This commit is contained in:
+140
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* Copyright 2002-2018 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.oauth2.client.registration;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import com.nimbusds.oauth2.sdk.GrantType;
|
||||
import com.nimbusds.oauth2.sdk.ParseException;
|
||||
import com.nimbusds.oauth2.sdk.Scope;
|
||||
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.web.client.RestTemplate;
|
||||
|
||||
/**
|
||||
* 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>.
|
||||
*
|
||||
* @author Rob Winch
|
||||
* @author Josh Cummings
|
||||
* @since 5.1
|
||||
*/
|
||||
public class ClientRegistrations {
|
||||
|
||||
/**
|
||||
* Creates a {@link ClientRegistration.Builder} using the provided
|
||||
* <a href="http://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier">Issuer</a> by making an
|
||||
* <a href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest">OpenID Provider
|
||||
* Configuration Request</a> and using the values in the
|
||||
* <a href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse">OpenID
|
||||
* Provider Configuration Response</a> to initialize the {@link ClientRegistration.Builder}.
|
||||
*
|
||||
* <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
|
||||
* Provider Configuration Response".
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* Example usage:
|
||||
* </p>
|
||||
* <pre>
|
||||
* ClientRegistration registration = ClientRegistrations.fromOidcIssuerLocation("https://example.com")
|
||||
* .clientId("client-id")
|
||||
* .clientSecret("client-secret")
|
||||
* .build();
|
||||
* </pre>
|
||||
* @param issuer the <a href="http://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier">Issuer</a>
|
||||
* @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);
|
||||
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 + "\"");
|
||||
}
|
||||
|
||||
String name = URI.create(issuer).getHost();
|
||||
ClientAuthenticationMethod method = getClientAuthenticationMethod(issuer, metadata.getTokenEndpointAuthMethods());
|
||||
List<GrantType> 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);
|
||||
}
|
||||
List<String> scopes = getScopes(metadata);
|
||||
return ClientRegistration.withRegistrationId(name)
|
||||
.userNameAttributeName(IdTokenClaimNames.SUB)
|
||||
.scope(scopes)
|
||||
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
.clientAuthenticationMethod(method)
|
||||
.redirectUriTemplate("{baseUrl}/{action}/oauth2/code/{registrationId}")
|
||||
.authorizationUri(metadata.getAuthorizationEndpointURI().toASCIIString())
|
||||
.jwkSetUri(metadata.getJWKSetURI().toASCIIString())
|
||||
.userInfoUri(metadata.getUserInfoEndpointURI().toASCIIString())
|
||||
.tokenUri(metadata.getTokenEndpointURI().toASCIIString())
|
||||
.clientName(issuer);
|
||||
}
|
||||
|
||||
private static String getOpenidConfiguration(String issuer) {
|
||||
RestTemplate rest = new RestTemplate();
|
||||
try {
|
||||
return rest.getForObject(issuer + "/.well-known/openid-configuration", String.class);
|
||||
} catch(RuntimeException e) {
|
||||
throw new IllegalArgumentException("Unable to resolve the OpenID Configuration with the provided Issuer of \"" + issuer + "\"", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static 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)) {
|
||||
// If null, the default includes client_secret_basic
|
||||
return ClientAuthenticationMethod.BASIC;
|
||||
}
|
||||
if (metadataAuthMethods.contains(com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod.CLIENT_SECRET_POST)) {
|
||||
return ClientAuthenticationMethod.POST;
|
||||
}
|
||||
throw new IllegalArgumentException("Only ClientAuthenticationMethod.BASIC and ClientAuthenticationMethod.POST are supported. The issuer \"" + issuer + "\" returned a configuration of " + metadataAuthMethods);
|
||||
}
|
||||
|
||||
private static List<String> getScopes(OIDCProviderMetadata metadata) {
|
||||
Scope scope = metadata.getScopes();
|
||||
if (scope == null) {
|
||||
// If null, default to "openid" which must be supported
|
||||
return Collections.singletonList(OidcScopes.OPENID);
|
||||
} else {
|
||||
return scope.toStringList();
|
||||
}
|
||||
}
|
||||
|
||||
private static OIDCProviderMetadata parse(String body) {
|
||||
try {
|
||||
return OIDCProviderMetadata.parse(body);
|
||||
}
|
||||
catch (ParseException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private ClientRegistrations() {}
|
||||
|
||||
}
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
/*
|
||||
* Copyright 2002-2018 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.oauth2.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.MockResponse;
|
||||
import okhttp3.mockwebserver.MockWebServer;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||||
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* @author Rob Winch
|
||||
* @since 5.1
|
||||
*/
|
||||
public class ClientRegistrationsTest {
|
||||
|
||||
/**
|
||||
* Contains all optional parameters that are found in ClientRegistration
|
||||
*/
|
||||
private static final String DEFAULT_RESPONSE =
|
||||
"{\n"
|
||||
+ " \"authorization_endpoint\": \"https://example.com/o/oauth2/v2/auth\", \n"
|
||||
+ " \"claims_supported\": [\n"
|
||||
+ " \"aud\", \n"
|
||||
+ " \"email\", \n"
|
||||
+ " \"email_verified\", \n"
|
||||
+ " \"exp\", \n"
|
||||
+ " \"family_name\", \n"
|
||||
+ " \"given_name\", \n"
|
||||
+ " \"iat\", \n"
|
||||
+ " \"iss\", \n"
|
||||
+ " \"locale\", \n"
|
||||
+ " \"name\", \n"
|
||||
+ " \"picture\", \n"
|
||||
+ " \"sub\"\n"
|
||||
+ " ], \n"
|
||||
+ " \"code_challenge_methods_supported\": [\n"
|
||||
+ " \"plain\", \n"
|
||||
+ " \"S256\"\n"
|
||||
+ " ], \n"
|
||||
+ " \"id_token_signing_alg_values_supported\": [\n"
|
||||
+ " \"RS256\"\n"
|
||||
+ " ], \n"
|
||||
+ " \"issuer\": \"https://example.com\", \n"
|
||||
+ " \"jwks_uri\": \"https://example.com/oauth2/v3/certs\", \n"
|
||||
+ " \"response_types_supported\": [\n"
|
||||
+ " \"code\", \n"
|
||||
+ " \"token\", \n"
|
||||
+ " \"id_token\", \n"
|
||||
+ " \"code token\", \n"
|
||||
+ " \"code id_token\", \n"
|
||||
+ " \"token id_token\", \n"
|
||||
+ " \"code token id_token\", \n"
|
||||
+ " \"none\"\n"
|
||||
+ " ], \n"
|
||||
+ " \"revocation_endpoint\": \"https://example.com/o/oauth2/revoke\", \n"
|
||||
+ " \"scopes_supported\": [\n"
|
||||
+ " \"openid\", \n"
|
||||
+ " \"email\", \n"
|
||||
+ " \"profile\"\n"
|
||||
+ " ], \n"
|
||||
+ " \"subject_types_supported\": [\n"
|
||||
+ " \"public\"\n"
|
||||
+ " ], \n"
|
||||
+ " \"grant_types_supported\" : [\"authorization_code\"], \n"
|
||||
+ " \"token_endpoint\": \"https://example.com/oauth2/v4/token\", \n"
|
||||
+ " \"token_endpoint_auth_methods_supported\": [\n"
|
||||
+ " \"client_secret_post\", \n"
|
||||
+ " \"client_secret_basic\"\n"
|
||||
+ " ], \n"
|
||||
+ " \"userinfo_endpoint\": \"https://example.com/oauth2/v3/userinfo\"\n"
|
||||
+ "}";
|
||||
|
||||
private MockWebServer server;
|
||||
|
||||
private ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
private Map<String, Object> response;
|
||||
|
||||
private String issuer;
|
||||
|
||||
@Before
|
||||
public void setup() throws Exception {
|
||||
this.server = new MockWebServer();
|
||||
this.server.start();
|
||||
this.response = this.mapper.readValue(DEFAULT_RESPONSE, new TypeReference<Map<String, Object>>(){});
|
||||
}
|
||||
|
||||
@After
|
||||
public void cleanup() throws Exception {
|
||||
this.server.shutdown();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void issuerWhenAllInformationThenSuccess() throws Exception {
|
||||
ClientRegistration registration = registration("");
|
||||
ClientRegistration.ProviderDetails provider = registration.getProviderDetails();
|
||||
|
||||
assertThat(registration.getClientAuthenticationMethod()).isEqualTo(ClientAuthenticationMethod.BASIC);
|
||||
assertThat(registration.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE);
|
||||
assertThat(registration.getRegistrationId()).isEqualTo(this.server.getHostName());
|
||||
assertThat(registration.getClientName()).isEqualTo(this.issuer);
|
||||
assertThat(registration.getScopes()).containsOnly("openid", "email", "profile");
|
||||
assertThat(provider.getAuthorizationUri()).isEqualTo("https://example.com/o/oauth2/v2/auth");
|
||||
assertThat(provider.getTokenUri()).isEqualTo("https://example.com/oauth2/v4/token");
|
||||
assertThat(provider.getJwkSetUri()).isEqualTo("https://example.com/oauth2/v3/certs");
|
||||
assertThat(provider.getUserInfoEndpoint().getUri()).isEqualTo("https://example.com/oauth2/v3/userinfo");
|
||||
}
|
||||
|
||||
/**
|
||||
* https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata
|
||||
*
|
||||
* RECOMMENDED. JSON array containing a list of the OAuth 2.0 [RFC6749] scope values that this server supports. The
|
||||
* server MUST support the openid scope value.
|
||||
* @throws Exception
|
||||
*/
|
||||
@Test
|
||||
public void issuerWhenScopesNullThenScopesDefaulted() throws Exception {
|
||||
this.response.remove("scopes_supported");
|
||||
|
||||
ClientRegistration registration = registration("");
|
||||
|
||||
assertThat(registration.getScopes()).containsOnly("openid");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void issuerWhenGrantTypesSupportedNullThenDefaulted() throws Exception {
|
||||
this.response.remove("grant_types_supported");
|
||||
|
||||
ClientRegistration registration = registration("");
|
||||
|
||||
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
|
||||
*/
|
||||
public void issuerWhenGrantTypesSupportedInvalidThenException() throws Exception {
|
||||
this.response.put("grant_types_supported", Arrays.asList("implicit"));
|
||||
|
||||
assertThatThrownBy(() -> registration(""))
|
||||
.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");
|
||||
|
||||
ClientRegistration registration = registration("");
|
||||
|
||||
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"));
|
||||
|
||||
ClientRegistration registration = registration("");
|
||||
|
||||
assertThat(registration.getClientAuthenticationMethod()).isEqualTo(ClientAuthenticationMethod.POST);
|
||||
}
|
||||
|
||||
/**
|
||||
* We currently only support client_secret_basic, so verify we have a meaningful error until we add support.
|
||||
* @throws Exception
|
||||
*/
|
||||
@Test
|
||||
public void issuerWhenTokenEndpointAuthMethodsInvalidThenException() throws Exception {
|
||||
this.response.put("token_endpoint_auth_methods_supported", Arrays.asList("tls_client_auth"));
|
||||
|
||||
assertThatThrownBy(() -> registration(""))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Only ClientAuthenticationMethod.BASIC and ClientAuthenticationMethod.POST are supported. The issuer \"" + this.issuer + "\" returned a configuration of [tls_client_auth]");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void issuerWhenEmptyStringThenMeaningfulErrorMessage() {
|
||||
assertThatThrownBy(() -> ClientRegistrations.fromOidcIssuerLocation(""))
|
||||
.hasMessageContaining("Unable to resolve the OpenID Configuration with the provided Issuer of \"\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void issuerWhenOpenIdConfigurationDoesNotMatchThenMeaningfulErrorMessage() 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.fromOidcIssuerLocation(this.issuer))
|
||||
.hasMessageContaining("The Issuer \"https://example.com\" provided in the OpenID Configuration did not match the requested issuer \"" + this.issuer + "\"");
|
||||
}
|
||||
|
||||
private ClientRegistration registration(String path) throws Exception {
|
||||
this.issuer = createIssuerFromServer(path);
|
||||
this.response.put("issuer", this.issuer);
|
||||
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);
|
||||
|
||||
return ClientRegistrations.fromOidcIssuerLocation(this.issuer)
|
||||
.clientId("client-id")
|
||||
.clientSecret("client-secret")
|
||||
.build();
|
||||
}
|
||||
|
||||
private String createIssuerFromServer(String path) {
|
||||
return this.server.url(path).toString();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user