Skip to content

Adds dynamic client registration how-to guide #1320

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/modules/ROOT/nav.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 119 additions & 0 deletions docs/modules/ROOT/pages/guides/how-to-dynamic-client-registration.adoc
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions docs/spring-authorization-server-docs.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
111 changes: 111 additions & 0 deletions docs/src/main/java/sample/dcr/DcrClient.java
Original file line number Diff line number Diff line change
@@ -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 changes: 96 additions & 0 deletions docs/src/main/java/sample/dcr/DcrConfiguration.java
Original file line number Diff line number Diff line change
@@ -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 changes: 43 additions & 0 deletions docs/src/main/java/sample/dcr/RegisteredClientConfiguration.java
Original file line number Diff line number Diff line change
@@ -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>
}
}
Loading