Skip to content

Commit

Permalink
Adds dynamic client registration how-to guide
Browse files Browse the repository at this point in the history
  • Loading branch information
ddubson committed Aug 29, 2023
1 parent 99cd1b8 commit 2f4b39a
Show file tree
Hide file tree
Showing 7 changed files with 471 additions and 0 deletions.
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[]
175 changes: 175 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,175 @@
[[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]
- xref:guides/how-to-dynamic-client-registration.adoc#retrieve-client[Retrieve 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, register a new client with the following request.

[NOTE]
The access token can only be used once. After a single registration request, the access token is invalidated.

[source,console]
----
curl -X POST --location "https://authserver.example.org/connect/register" --http1.1 \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-H "Authorization: Bearer <initial-access-token>" \
-d "{
\"client_name\": \"My Example\",
\"grant_types\": [
\"authorization_code\",
\"client_credentials\",
\"refresh_token\"
],
\"scope\": \"openid profile email\",
\"redirect_uris\": [
\"https://client.example.org/callback\",
\"https://client.example.org/callback2\"
],
\"token_endpoint_auth_method\": \"client_secret_basic\",
\"post_logout_redirect_uris\": [
\"https://client.example.org/logout\"
]
}"
----

An example register client response may be as follows:

[source,console]
----
HTTP/1.1 201
...
{
"client_id": "Q_AQ0wUzbTXo-IE4rNJbU9Dv8BBex1zQrjDeJs0mDbM",
"client_id_issued_at": 1690726915,
"client_name": "My Example",
"client_secret": "XleADJhomxA2Rmyom2hmpnS6_CDnyAFBI9JsGeC-XQ0QLa9p9JExXJABiYz7fOXA",
"redirect_uris": [
"https://client.example.org/callback",
"https://client.example.org/callback2"
],
"post_logout_redirect_uris": [
"https://client.example.org/logout"
],
"grant_types": [
"refresh_token",
"client_credentials",
"authorization_code"
],
"response_types": [
"code"
],
"scope": "openid profile email",
"token_endpoint_auth_method": "client_secret_basic",
"id_token_signed_response_alg": "RS256",
"registration_client_uri": "https://authserver.example.org/connect/register?client_id=Q_AQ0wUzbTXo-IE4rNJbU9Dv8BBex1zQrjDeJs0mDbM",
"registration_access_token": "<access-token>",
"client_secret_expires_at": 0
}
----

With the client registered, a `registration_access_token` and a `registration_client_uri` are provided to be able to
read the created client in a follow up request. The next step is optional.

[[retrieve-client]]
== Retrieve client

Using fields `registration_access_token` and `registration_client_uri` from the previous step's response, read the client
with the following request:

[source,console]
----
curl -X GET --location "<registration_client_uri>" \
-H "Authorization: Bearer <registration_access_token>" \
-H "Accept: application/json"
----

The response should contain the same information about the client as seen when the client was first registered, with
the exception of `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
94 changes: 94 additions & 0 deletions docs/src/main/java/sample/dcr/DcrConfiguration.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
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.User;
import org.springframework.security.core.userdetails.UserDetails;
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.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() {
// @formatter:off
UserDetails userDetails = User.withDefaultPasswordEncoder()
.username("user")
.password("password")
.roles("USER")
.build();
// @formatter:on

return new InMemoryUserDetailsManager(userDetails);
}

@Bean
public JWKSource<SecurityContext> jwkSource() {
KeyPair keyPair = generateRsaKey();
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
// @formatter:off
RSAKey rsaKey = new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString())
.build();
// @formatter:on
JWKSet jwkSet = new JWKSet(rsaKey);
return new ImmutableJWKSet<>(jwkSet);
}

private static KeyPair generateRsaKey() { // <6>
KeyPair keyPair;
try {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(2048);
keyPair = keyPairGenerator.generateKeyPair();
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
return keyPair;
}

@Bean
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
}

@Bean
public AuthorizationServerSettings authorizationServerSettings() {
return AuthorizationServerSettings.builder().build();
}
// @fold:off
}
91 changes: 91 additions & 0 deletions docs/src/main/java/sample/dcr/DynamicClientRegistrationClient.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
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 DynamicClientRegistrationClient {

public static final ClientRegistrationRequest SAMPLE_CLIENT_REGISTRATION_REQUEST = new DynamicClientRegistrationClient.ClientRegistrationRequest(
"client-1",
List.of(AuthorizationGrantType.AUTHORIZATION_CODE.getValue()),
List.of("https://client.example.org/callback", "https://client.example.org/callback2"),
"openid email profile"
);

private final WebClient webClient;

public DynamicClientRegistrationClient(final WebClient webClient) {
this.webClient = webClient;
}

public record ClientRegistrationRequest(
@JsonProperty("client_name") String clientName,
@JsonProperty("grant_types") List<String> grantTypes,
@JsonProperty("redirect_uris") List<String> redirectUris,
String scope) {
}

public record ClientRegistrationResponse(
@JsonProperty("registration_access_token") String registrationAccessToken,
@JsonProperty("registration_client_uri") String registrationClientUri,
@JsonProperty("client_name") String clientName,
@JsonProperty("grant_types") List<String> grantTypes,
@JsonProperty("redirect_uris") List<String> redirectUris,
String scope) {
}

public ClientRegistrationResponse registerClient(String initialAccessToken, ClientRegistrationRequest request) {
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), ClientRegistrationRequest.class)
.retrieve()
.bodyToMono(ClientRegistrationResponse.class)
.block();
}

public ClientRegistrationResponse retrieveClient(String registrationAccessToken, String registrationClientUri) {
return this.webClient
.get()
.uri(registrationClientUri)
.header(HttpHeaders.AUTHORIZATION, "Bearer %s".formatted(registrationAccessToken))
.retrieve()
.bodyToMono(ClientRegistrationResponse.class)
.block();
}

public void exampleRegistration(String initialAccessToken) {
ClientRegistrationResponse clientRegistrationResponse = this.registerClient(initialAccessToken, SAMPLE_CLIENT_REGISTRATION_REQUEST);

assert(clientRegistrationResponse.clientName.contentEquals("client-1"));
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();
String registrationClientUri = clientRegistrationResponse.registrationClientUri();

ClientRegistrationResponse retrievedClient = this.retrieveClient(registrationAccessToken, registrationClientUri);

assert(retrievedClient.clientName.contentEquals("client-1"));
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());
}
}
Loading

0 comments on commit 2f4b39a

Please sign in to comment.