From 11fa68c0489ac0b1a253bb2f697f111040021a2e Mon Sep 17 00:00:00 2001 From: Dmitriy Dubson Date: Sun, 30 Jul 2023 10:46:37 -0400 Subject: [PATCH] Adds dynamic client registration how-to guide Closes gh-647 --- docs/modules/ROOT/nav.adoc | 1 + .../how-to-dynamic-client-registration.adoc | 119 ++++++++++++++++++ docs/spring-authorization-server-docs.gradle | 1 + docs/src/main/java/sample/dcr/DcrClient.java | 111 ++++++++++++++++ .../java/sample/dcr/DcrConfiguration.java | 96 ++++++++++++++ .../dcr/RegisteredClientConfiguration.java | 43 +++++++ .../dcr/DynamicClientRegistrationTests.java | 81 ++++++++++++ 7 files changed, 452 insertions(+) create mode 100644 docs/modules/ROOT/pages/guides/how-to-dynamic-client-registration.adoc create mode 100644 docs/src/main/java/sample/dcr/DcrClient.java create mode 100644 docs/src/main/java/sample/dcr/DcrConfiguration.java create mode 100644 docs/src/main/java/sample/dcr/RegisteredClientConfiguration.java create mode 100644 docs/src/test/java/sample/dcr/DynamicClientRegistrationTests.java diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index 8b426764b..05f3af622 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -11,3 +11,4 @@ ** xref:guides/how-to-userinfo.adoc[] ** xref:guides/how-to-jpa.adoc[] ** xref:guides/how-to-custom-claims-authorities.adoc[] +** xref:guides/how-to-dynamic-client-registration.adoc[] diff --git a/docs/modules/ROOT/pages/guides/how-to-dynamic-client-registration.adoc b/docs/modules/ROOT/pages/guides/how-to-dynamic-client-registration.adoc new file mode 100644 index 000000000..f4a4cd137 --- /dev/null +++ b/docs/modules/ROOT/pages/guides/how-to-dynamic-client-registration.adoc @@ -0,0 +1,119 @@ +[[how-to-dynamic-client-registration]] += How-to: Register a client dynamically +:index-link: ../how-to.html +:docs-dir: .. + +This guide shows how to configure OpenID Connect Dynamic Client Registration 1.0 in Spring Authorization Server and walks through an example of how to register a client. +Spring Authorization Server implements https://openid.net/specs/openid-connect-registration-1_0.html[OpenID Connect Dynamic Client Registration 1.0] +specification, gaining the ability to dynamically register and retrieve OpenID clients. + +- xref:guides/how-to-dynamic-client-registration.adoc#enable[Enable Dynamic Client Registration] +- xref:guides/how-to-dynamic-client-registration.adoc#configure-initial-client[Configure initial client] +- xref:guides/how-to-dynamic-client-registration.adoc#obtain-initial-access-token[Obtain initial access token] +- xref:guides/how-to-dynamic-client-registration.adoc#register-client[Register a client] + +[[enable]] +== Enable Dynamic Client Registration + +By default, dynamic client registration functionality is disabled in Spring Authorization Server. +To enable, add the following configuration: + +[[sample.dcrAuthServerConfig]] +[source,java] +---- +include::{examples-dir}/main/java/sample/dcr/DcrConfiguration.java[] +---- + +<1> Add a `SecurityFilterChain` `@Bean` that registers an `OAuth2AuthorizationServerConfigurer` +<2> In the configurer, apply OIDC client registration endpoint customizer with default values. +This enables dynamic client registration functionality. + +Please refer to xref:protocol-endpoints.adoc#oidc-client-registration-endpoint[Client Registration Endpoint docs] for in-depth configuration details. + +[[configure-initial-client]] +== Configure initial client + +An initial client is required in order to register new clients in the authorization server. +The client must be configured with scopes `client.create` and optionally `client.read` for creating clients and reading clients, respectively. +A programmatic example of such a client is below. + +[[sample.dcrRegisteredClientConfig]] +[source,java] +---- +include::{examples-dir}/main/java/sample/dcr/RegisteredClientConfiguration.java[] +---- + +<1> A `RegisteredClientRepository` `@Bean` is configured with a set of clients. +<2> An initial client with client id `dcr-client` is configured. +<3> `client_credentials` grant type is set to fetch access tokens directly. +<4> `client.create` scope is configured for the client to ensure they are able to create clients. +<5> `client.read` scope is configured for the client to ensure they are able to fetch and read clients. +<6> The initial client is saved into the data store. + +After configuring the above, run the authorization server in your preferred environment. + +[[obtain-initial-access-token]] +== Obtain initial access token + +An initial access token is required to be able to create client registration requests. +The token request must contain a request for scope `client.create` only. + +[source,httprequest] +---- +POST /oauth2/token HTTP/1.1 +Authorization: Basic +Content-Type: application/x-www-form-urlencoded + +grant_type=client_credentials&scope=client.create +---- + +[WARNING] +==== +If you provide more than one scope in the request, you will not be able to register a client. +The client creation request requires an access token with a single scope of `client.create` +==== + +[TIP] +==== +To obtain encoded credentials for the above request, `base64` encode the client credentials in the format of +`:`. Below is an encoding operation for the example in this guide. + +[source,console] +---- +echo -n "initial-app:secret" | base64 +---- +==== + +[[register-client]] +== Register a client + +With an access token obtained from the previous step, a client can now be dynamically registered. + +[NOTE] +The access token can only be used once. After a single registration request, the access token is invalidated. + +[[sample.dcrClientRegistration]] +[source,java] +---- +include::{examples-dir}/main/java/sample/dcr/DcrClient.java[] +---- + +<1> A minimal client registration request object. +You may add additional fields as per https://openid.net/specs/openid-connect-registration-1_0.html#RegistrationRequest[OpenID Connect Dynamic Client Registration 1.0 spec - Client Registration Request]. +<2> A minimal client registration response object. +You may add additional response fields as per https://openid.net/specs/openid-connect-registration-1_0.html#RegistrationResponse[OpenID Connect Dynamic Client Registration 1.0 spec - Client Registration Response]. +<3> A sample client registration request object which will be used to register a sample client. +<4> Example dynamic client registration procedure, demonstrating dynamic registration and client retrieval. +<5> Register a client using sample request from step 2, using initial access token from previous step. +Skip to step 10 for implementation. +<6> After registration, assert on the fields that should be populated in the response upon successful registration. +<7> Extract `registration_access_token` and `registration_client_uri` fields, for use in retrieval of the newly registered client. +<8> Retrieve client. Skip to step 11 for implementation. +<9> After client retrieval, assert on the fields that should be populated in the response. +<10> Sample client registration procedure using Spring WebFlux's `WebClient`. +Note that the `WebClient` must have `baseUrl` of the authorization server configured. +<11> Sample client retrieval procedure using Spring WebFlux's `WebClient`. +Note that the `WebClient` must have `baseUrl` of the authorization server configured. + +The retrieve client response should contain the same information about the client as seen when the client was first +registered, except for `registration_access_token` field. diff --git a/docs/spring-authorization-server-docs.gradle b/docs/spring-authorization-server-docs.gradle index a494b7e42..b015ea2bc 100644 --- a/docs/spring-authorization-server-docs.gradle +++ b/docs/spring-authorization-server-docs.gradle @@ -56,6 +56,7 @@ dependencies { implementation "org.springframework.boot:spring-boot-starter-oauth2-client" implementation "org.springframework.boot:spring-boot-starter-oauth2-resource-server" implementation "org.springframework.boot:spring-boot-starter-data-jpa" + implementation "org.springframework:spring-webflux" implementation project(":spring-security-oauth2-authorization-server") runtimeOnly "com.h2database:h2" testImplementation "org.springframework.boot:spring-boot-starter-test" diff --git a/docs/src/main/java/sample/dcr/DcrClient.java b/docs/src/main/java/sample/dcr/DcrClient.java new file mode 100644 index 000000000..9f999332d --- /dev/null +++ b/docs/src/main/java/sample/dcr/DcrClient.java @@ -0,0 +1,111 @@ +/* + * Copyright 2020-2023 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 + * + * https://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 sample.dcr; + +import com.fasterxml.jackson.annotation.JsonProperty; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Objects; + +public class DcrClient { + // @fold:on + private final WebClient webClient; + + public DcrClient(final WebClient webClient) { + this.webClient = webClient; + } + // @fold:off + + public record DcrRequest( // <1> + @JsonProperty("client_name") String clientName, + @JsonProperty("grant_types") List grantTypes, + @JsonProperty("redirect_uris") List redirectUris, + String scope) { + } + + public record DcrResponse( // <2> + @JsonProperty("registration_access_token") String registrationAccessToken, + @JsonProperty("registration_client_uri") String registrationClientUri, + @JsonProperty("client_name") String clientName, + @JsonProperty("client_secret") String clientSecret, + @JsonProperty("grant_types") List grantTypes, + @JsonProperty("redirect_uris") List redirectUris, + String scope) { + } + + public static final DcrRequest SAMPLE_CLIENT_REGISTRATION_REQUEST = new DcrRequest( // <3> + "client-1", + List.of(AuthorizationGrantType.AUTHORIZATION_CODE.getValue()), + List.of("https://client.example.org/callback", "https://client.example.org/callback2"), + "openid email profile" + ); + + public void exampleRegistration(String initialAccessToken) { // <4> + DcrResponse clientRegistrationResponse = + this.registerClient(initialAccessToken, SAMPLE_CLIENT_REGISTRATION_REQUEST); // <5> + + assert (clientRegistrationResponse.clientName().contentEquals("client-1")); // <6> + assert (!Objects.isNull(clientRegistrationResponse.clientSecret())); + assert (clientRegistrationResponse.scope().contentEquals("openid profile email")); + assert (clientRegistrationResponse.grantTypes().contains(AuthorizationGrantType.AUTHORIZATION_CODE.getValue())); + assert (clientRegistrationResponse.redirectUris().contains("https://client.example.org/callback")); + assert (clientRegistrationResponse.redirectUris().contains("https://client.example.org/callback2")); + assert (!clientRegistrationResponse.registrationAccessToken().isEmpty()); + assert (!clientRegistrationResponse.registrationClientUri().isEmpty()); + + String registrationAccessToken = clientRegistrationResponse.registrationAccessToken(); // <7> + String registrationClientUri = clientRegistrationResponse.registrationClientUri(); + + DcrResponse retrievedClient = this.retrieveClient(registrationAccessToken, registrationClientUri); // <8> + + assert (retrievedClient.clientName().contentEquals("client-1")); // <9> + assert (!Objects.isNull(retrievedClient.clientSecret())); + assert (retrievedClient.scope().contentEquals("openid profile email")); + assert (retrievedClient.grantTypes().contains(AuthorizationGrantType.AUTHORIZATION_CODE.getValue())); + assert (retrievedClient.redirectUris().contains("https://client.example.org/callback")); + assert (retrievedClient.redirectUris().contains("https://client.example.org/callback2")); + assert (Objects.isNull(retrievedClient.registrationAccessToken())); + assert (!retrievedClient.registrationClientUri().isEmpty()); + } + + public DcrResponse registerClient(String initialAccessToken, DcrRequest request) { // <10> + return this.webClient + .post() + .uri("/connect/register") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer %s".formatted(initialAccessToken)) + .body(Mono.just(request), DcrRequest.class) + .retrieve() + .bodyToMono(DcrResponse.class) + .block(); + } + + public DcrResponse retrieveClient(String registrationAccessToken, String registrationClientUri) { // <11> + return this.webClient + .get() + .uri(registrationClientUri) + .header(HttpHeaders.AUTHORIZATION, "Bearer %s".formatted(registrationAccessToken)) + .retrieve() + .bodyToMono(DcrResponse.class) + .block(); + } +} diff --git a/docs/src/main/java/sample/dcr/DcrConfiguration.java b/docs/src/main/java/sample/dcr/DcrConfiguration.java new file mode 100644 index 000000000..2f37878fe --- /dev/null +++ b/docs/src/main/java/sample/dcr/DcrConfiguration.java @@ -0,0 +1,96 @@ +/* + * Copyright 2020-2023 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 + * + * https://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 sample.dcr; + +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.SecurityContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration; +import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer; +import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.util.Collections; +import java.util.UUID; + +@Configuration +@EnableWebSecurity +public class DcrConfiguration { + @Bean // <1> + public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { + OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); + http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) + .oidc(oidc -> oidc.clientRegistrationEndpoint(Customizer.withDefaults())); // <2> + http.oauth2ResourceServer(oauth2ResourceServer -> + oauth2ResourceServer.jwt(Customizer.withDefaults())); + + return http.build(); + } + // @fold:on + + @Bean + public UserDetailsService userDetailsService() { + // This example uses client credentials grant type - no need for any users. + return new InMemoryUserDetailsManager(Collections.emptyList()); + } + + @Bean + public JWKSource jwkSource() { + // @formatter:off + KeyPair keyPair; + try { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + keyPair = keyPairGenerator.generateKeyPair(); + } catch (Exception ex) { + throw new IllegalStateException(ex); + } + RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); + RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); + RSAKey rsaKey = new RSAKey.Builder(publicKey) + .privateKey(privateKey) + .keyID(UUID.randomUUID().toString()) + .build(); + // @formatter:on + JWKSet jwkSet = new JWKSet(rsaKey); + return new ImmutableJWKSet<>(jwkSet); + } + + @Bean + public JwtDecoder jwtDecoder(JWKSource jwkSource) { + return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource); + } + + @Bean + public AuthorizationServerSettings authorizationServerSettings() { + return AuthorizationServerSettings.builder().build(); + } + // @fold:off +} diff --git a/docs/src/main/java/sample/dcr/RegisteredClientConfiguration.java b/docs/src/main/java/sample/dcr/RegisteredClientConfiguration.java new file mode 100644 index 000000000..c973fdf07 --- /dev/null +++ b/docs/src/main/java/sample/dcr/RegisteredClientConfiguration.java @@ -0,0 +1,43 @@ +/* + * Copyright 2020-2023 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 + * + * https://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 sample.dcr; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; + +import java.util.UUID; + +@Configuration +public class RegisteredClientConfiguration { + @Bean // <1> + public RegisteredClientRepository registeredClientRepository() { + RegisteredClient initialClient = RegisteredClient.withId(UUID.randomUUID().toString()) + .clientId("dcr-client") // <2> + .clientSecret("{noop}secret") + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) // <3> + .scope("client.create") // <4> + .scope("client.read") // <5> + .build(); + + return new InMemoryRegisteredClientRepository(initialClient); // <6> + } +} diff --git a/docs/src/test/java/sample/dcr/DynamicClientRegistrationTests.java b/docs/src/test/java/sample/dcr/DynamicClientRegistrationTests.java new file mode 100644 index 000000000..e0c47a9db --- /dev/null +++ b/docs/src/test/java/sample/dcr/DynamicClientRegistrationTests.java @@ -0,0 +1,81 @@ +/* + * Copyright 2020-2023 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 + * + * https://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 sample.dcr; + +import com.jayway.jsonpath.JsonPath; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.reactive.function.client.WebClient; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + + +/** + * Tests for Dynamic Client Registration how-to guide + * + * @author Dmitriy Dubson + */ +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + classes = {DynamicClientRegistrationTests.AuthorizationServerConfig.class} +) +@AutoConfigureMockMvc +public class DynamicClientRegistrationTests { + + @Autowired + private MockMvc mvc; + + @LocalServerPort + private String port; + + @Test + public void dynamicallyRegisterAClient() throws Exception { + String tokenRequestBody = "scope=client.create&grant_type=client_credentials" ; + MockHttpServletResponse tokenResponse = this.mvc.perform(post("/oauth2/token") + .with(httpBasic("dcr-client", "secret")) + .contentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE) + .content(tokenRequestBody)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.access_token").isNotEmpty()) + .andReturn() + .getResponse(); + + String initialAccessToken = JsonPath.parse(tokenResponse.getContentAsString()).read("$.access_token"); + + WebClient webClient = WebClient.builder().baseUrl("http://127.0.0.1:%s".formatted(port)).build(); + DcrClient dcrClient = new DcrClient(webClient); + + dcrClient.exampleRegistration(initialAccessToken); + } + + @EnableAutoConfiguration + @EnableWebSecurity + @Import({DcrConfiguration.class, RegisteredClientConfiguration.class}) + static class AuthorizationServerConfig { + } +}