Răsfoiți Sursa

Adds dynamic client registration how-to guide

Closes gh-647
Dmitriy Dubson 2 ani în urmă
părinte
comite
3386b1e8a2

+ 1 - 0
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[]

+ 119 - 0
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 <base64-encoded-credentials>
+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
+`<clientId>:<clientSecret>`. 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.

+ 1 - 0
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"

+ 111 - 0
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<String> grantTypes,
+			@JsonProperty("redirect_uris") List<String> 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<String> grantTypes,
+			@JsonProperty("redirect_uris") List<String> 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();
+	}
+}

+ 96 - 0
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<SecurityContext> 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<SecurityContext> jwkSource) {
+		return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
+	}
+
+	@Bean
+	public AuthorizationServerSettings authorizationServerSettings() {
+		return AuthorizationServerSettings.builder().build();
+	}
+	// @fold:off
+}

+ 43 - 0
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>
+	}
+}

+ 81 - 0
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 {
+	}
+}