7 changed files with 452 additions and 0 deletions
@ -0,0 +1,119 @@
@@ -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. |
||||
@ -0,0 +1,111 @@
@@ -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(); |
||||
} |
||||
} |
||||
@ -0,0 +1,96 @@
@@ -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
|
||||
} |
||||
@ -0,0 +1,43 @@
@@ -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>
|
||||
} |
||||
} |
||||
@ -0,0 +1,81 @@
@@ -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 { |
||||
} |
||||
} |
||||
Loading…
Reference in new issue