Skip to content

Commit 2f4b39a

Browse files
committed
Adds dynamic client registration how-to guide
Closes gh-647
1 parent 99cd1b8 commit 2f4b39a

File tree

7 files changed

+471
-0
lines changed

7 files changed

+471
-0
lines changed

docs/modules/ROOT/nav.adoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@
1111
** xref:guides/how-to-userinfo.adoc[]
1212
** xref:guides/how-to-jpa.adoc[]
1313
** xref:guides/how-to-custom-claims-authorities.adoc[]
14+
** xref:guides/how-to-dynamic-client-registration.adoc[]
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
[[how-to-dynamic-client-registration]]
2+
= How-to: Register a client dynamically
3+
:index-link: ../how-to.html
4+
:docs-dir: ..
5+
6+
This guide shows how to configure OpenID Connect Dynamic Client Registration 1.0 in Spring Authorization Server and
7+
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]
8+
specification, gaining the ability to dynamically register and retrieve OpenID clients.
9+
10+
- xref:guides/how-to-dynamic-client-registration.adoc#enable[Enable Dynamic Client Registration]
11+
- xref:guides/how-to-dynamic-client-registration.adoc#configure-initial-client[Configure initial client]
12+
- xref:guides/how-to-dynamic-client-registration.adoc#obtain-initial-access-token[Obtain initial access token]
13+
- xref:guides/how-to-dynamic-client-registration.adoc#register-client[Register a client]
14+
- xref:guides/how-to-dynamic-client-registration.adoc#retrieve-client[Retrieve client]
15+
16+
[[enable]]
17+
== Enable Dynamic Client Registration
18+
19+
By default, dynamic client registration functionality is disabled in Spring Authorization Server.
20+
To enable, add the following configuration:
21+
22+
[[sample.dcrAuthServerConfig]]
23+
[source,java]
24+
----
25+
include::{examples-dir}/main/java/sample/dcr/DcrConfiguration.java[]
26+
----
27+
28+
<1> Add a `SecurityFilterChain` `@Bean` that registers an `OAuth2AuthorizationServerConfigurer`
29+
<2> In the configurer, apply OIDC client registration endpoint customizer with default values.
30+
This enables dynamic client registration functionality.
31+
32+
Please refer to xref:protocol-endpoints.adoc#oidc-client-registration-endpoint[Client Registration Endpoint docs] for
33+
in-depth configuration details.
34+
35+
[[configure-initial-client]]
36+
== Configure initial client
37+
38+
An initial client is required in order to register new clients in the authorization server. The client must be configured
39+
with scopes `client.create` and optionally `client.read` for creating clients and reading clients, respectively.
40+
A programmatic example of such a client is below.
41+
42+
[[sample.dcrRegisteredClientConfig]]
43+
[source,java]
44+
----
45+
include::{examples-dir}/main/java/sample/dcr/RegisteredClientConfiguration.java[]
46+
----
47+
48+
<1> A `RegisteredClientRepository` `@Bean` is configured with a set of clients.
49+
<2> An initial client with client id `dcr-client` is configured.
50+
<3> `client_credentials` grant type is set to fetch access tokens directly.
51+
<4> `client.create` scope is configured for the client to ensure they are able to create clients.
52+
<5> `client.read` scope is configured for the client to ensure they are able to fetch and read clients.
53+
<6> The initial client is saved into the data store.
54+
55+
After configuring the above, run the authorization server in your preferred environment.
56+
57+
[[obtain-initial-access-token]]
58+
== Obtain initial access token
59+
60+
An initial access token is required to be able to create client registration requests. The token request must contain a
61+
request for scope `client.create` only.
62+
63+
[source,httprequest]
64+
----
65+
POST /oauth2/token HTTP/1.1
66+
Authorization: Basic <base64-encoded-credentials>
67+
Content-Type: application/x-www-form-urlencoded
68+
69+
grant_type=client_credentials&scope=client.create
70+
----
71+
72+
[WARNING]
73+
====
74+
If you provide more than one scope in the request, you will not be able to register a client. The client creation
75+
request requires an access token with a single scope of `client.create`
76+
====
77+
78+
[TIP]
79+
====
80+
To obtain encoded credentials for the above request, `base64` encode the client credentials in the format of
81+
`<clientId>:<clientSecret>`. Below is an encoding operation for the example in this guide.
82+
83+
[source,console]
84+
----
85+
echo -n "initial-app:secret" | base64
86+
----
87+
====
88+
89+
[[register-client]]
90+
== Register a client
91+
92+
With an access token obtained from the previous step, register a new client with the following request.
93+
94+
[NOTE]
95+
The access token can only be used once. After a single registration request, the access token is invalidated.
96+
97+
[source,console]
98+
----
99+
curl -X POST --location "https://authserver.example.org/connect/register" --http1.1 \
100+
-H "Content-Type: application/json" \
101+
-H "Accept: application/json" \
102+
-H "Authorization: Bearer <initial-access-token>" \
103+
-d "{
104+
\"client_name\": \"My Example\",
105+
\"grant_types\": [
106+
\"authorization_code\",
107+
\"client_credentials\",
108+
\"refresh_token\"
109+
],
110+
\"scope\": \"openid profile email\",
111+
\"redirect_uris\": [
112+
\"https://client.example.org/callback\",
113+
\"https://client.example.org/callback2\"
114+
],
115+
\"token_endpoint_auth_method\": \"client_secret_basic\",
116+
\"post_logout_redirect_uris\": [
117+
\"https://client.example.org/logout\"
118+
]
119+
}"
120+
----
121+
122+
An example register client response may be as follows:
123+
124+
[source,console]
125+
----
126+
HTTP/1.1 201
127+
...
128+
129+
{
130+
"client_id": "Q_AQ0wUzbTXo-IE4rNJbU9Dv8BBex1zQrjDeJs0mDbM",
131+
"client_id_issued_at": 1690726915,
132+
"client_name": "My Example",
133+
"client_secret": "XleADJhomxA2Rmyom2hmpnS6_CDnyAFBI9JsGeC-XQ0QLa9p9JExXJABiYz7fOXA",
134+
"redirect_uris": [
135+
"https://client.example.org/callback",
136+
"https://client.example.org/callback2"
137+
],
138+
"post_logout_redirect_uris": [
139+
"https://client.example.org/logout"
140+
],
141+
"grant_types": [
142+
"refresh_token",
143+
"client_credentials",
144+
"authorization_code"
145+
],
146+
"response_types": [
147+
"code"
148+
],
149+
"scope": "openid profile email",
150+
"token_endpoint_auth_method": "client_secret_basic",
151+
"id_token_signed_response_alg": "RS256",
152+
"registration_client_uri": "https://authserver.example.org/connect/register?client_id=Q_AQ0wUzbTXo-IE4rNJbU9Dv8BBex1zQrjDeJs0mDbM",
153+
"registration_access_token": "<access-token>",
154+
"client_secret_expires_at": 0
155+
}
156+
----
157+
158+
With the client registered, a `registration_access_token` and a `registration_client_uri` are provided to be able to
159+
read the created client in a follow up request. The next step is optional.
160+
161+
[[retrieve-client]]
162+
== Retrieve client
163+
164+
Using fields `registration_access_token` and `registration_client_uri` from the previous step's response, read the client
165+
with the following request:
166+
167+
[source,console]
168+
----
169+
curl -X GET --location "<registration_client_uri>" \
170+
-H "Authorization: Bearer <registration_access_token>" \
171+
-H "Accept: application/json"
172+
----
173+
174+
The response should contain the same information about the client as seen when the client was first registered, with
175+
the exception of `registration_access_token` field.

docs/spring-authorization-server-docs.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ dependencies {
5656
implementation "org.springframework.boot:spring-boot-starter-oauth2-client"
5757
implementation "org.springframework.boot:spring-boot-starter-oauth2-resource-server"
5858
implementation "org.springframework.boot:spring-boot-starter-data-jpa"
59+
implementation "org.springframework:spring-webflux"
5960
implementation project(":spring-security-oauth2-authorization-server")
6061
runtimeOnly "com.h2database:h2"
6162
testImplementation "org.springframework.boot:spring-boot-starter-test"
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package sample.dcr;
2+
3+
import com.nimbusds.jose.jwk.JWKSet;
4+
import com.nimbusds.jose.jwk.RSAKey;
5+
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
6+
import com.nimbusds.jose.jwk.source.JWKSource;
7+
import com.nimbusds.jose.proc.SecurityContext;
8+
import org.springframework.context.annotation.Bean;
9+
import org.springframework.context.annotation.Configuration;
10+
import org.springframework.security.config.Customizer;
11+
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
12+
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
13+
import org.springframework.security.core.userdetails.User;
14+
import org.springframework.security.core.userdetails.UserDetails;
15+
import org.springframework.security.core.userdetails.UserDetailsService;
16+
import org.springframework.security.oauth2.jwt.JwtDecoder;
17+
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
18+
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
19+
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
20+
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
21+
import org.springframework.security.web.SecurityFilterChain;
22+
23+
import java.security.KeyPair;
24+
import java.security.KeyPairGenerator;
25+
import java.security.interfaces.RSAPrivateKey;
26+
import java.security.interfaces.RSAPublicKey;
27+
import java.util.UUID;
28+
29+
@Configuration
30+
@EnableWebSecurity
31+
public class DcrConfiguration {
32+
@Bean // <1>
33+
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
34+
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
35+
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
36+
.oidc(oidc -> oidc.clientRegistrationEndpoint(Customizer.withDefaults())); // <2>
37+
http.oauth2ResourceServer(oauth2ResourceServer ->
38+
oauth2ResourceServer.jwt(Customizer.withDefaults()));
39+
40+
return http.build();
41+
}
42+
// @fold:on
43+
44+
@Bean
45+
public UserDetailsService userDetailsService() {
46+
// @formatter:off
47+
UserDetails userDetails = User.withDefaultPasswordEncoder()
48+
.username("user")
49+
.password("password")
50+
.roles("USER")
51+
.build();
52+
// @formatter:on
53+
54+
return new InMemoryUserDetailsManager(userDetails);
55+
}
56+
57+
@Bean
58+
public JWKSource<SecurityContext> jwkSource() {
59+
KeyPair keyPair = generateRsaKey();
60+
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
61+
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
62+
// @formatter:off
63+
RSAKey rsaKey = new RSAKey.Builder(publicKey)
64+
.privateKey(privateKey)
65+
.keyID(UUID.randomUUID().toString())
66+
.build();
67+
// @formatter:on
68+
JWKSet jwkSet = new JWKSet(rsaKey);
69+
return new ImmutableJWKSet<>(jwkSet);
70+
}
71+
72+
private static KeyPair generateRsaKey() { // <6>
73+
KeyPair keyPair;
74+
try {
75+
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
76+
keyPairGenerator.initialize(2048);
77+
keyPair = keyPairGenerator.generateKeyPair();
78+
} catch (Exception ex) {
79+
throw new IllegalStateException(ex);
80+
}
81+
return keyPair;
82+
}
83+
84+
@Bean
85+
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
86+
return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
87+
}
88+
89+
@Bean
90+
public AuthorizationServerSettings authorizationServerSettings() {
91+
return AuthorizationServerSettings.builder().build();
92+
}
93+
// @fold:off
94+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package sample.dcr;
2+
3+
import com.fasterxml.jackson.annotation.JsonProperty;
4+
import org.springframework.http.HttpHeaders;
5+
import org.springframework.http.MediaType;
6+
import org.springframework.security.oauth2.core.AuthorizationGrantType;
7+
import org.springframework.web.reactive.function.client.WebClient;
8+
import reactor.core.publisher.Mono;
9+
10+
import java.util.List;
11+
import java.util.Objects;
12+
13+
public class DynamicClientRegistrationClient {
14+
15+
public static final ClientRegistrationRequest SAMPLE_CLIENT_REGISTRATION_REQUEST = new DynamicClientRegistrationClient.ClientRegistrationRequest(
16+
"client-1",
17+
List.of(AuthorizationGrantType.AUTHORIZATION_CODE.getValue()),
18+
List.of("https://client.example.org/callback", "https://client.example.org/callback2"),
19+
"openid email profile"
20+
);
21+
22+
private final WebClient webClient;
23+
24+
public DynamicClientRegistrationClient(final WebClient webClient) {
25+
this.webClient = webClient;
26+
}
27+
28+
public record ClientRegistrationRequest(
29+
@JsonProperty("client_name") String clientName,
30+
@JsonProperty("grant_types") List<String> grantTypes,
31+
@JsonProperty("redirect_uris") List<String> redirectUris,
32+
String scope) {
33+
}
34+
35+
public record ClientRegistrationResponse(
36+
@JsonProperty("registration_access_token") String registrationAccessToken,
37+
@JsonProperty("registration_client_uri") String registrationClientUri,
38+
@JsonProperty("client_name") String clientName,
39+
@JsonProperty("grant_types") List<String> grantTypes,
40+
@JsonProperty("redirect_uris") List<String> redirectUris,
41+
String scope) {
42+
}
43+
44+
public ClientRegistrationResponse registerClient(String initialAccessToken, ClientRegistrationRequest request) {
45+
return this.webClient
46+
.post()
47+
.uri("/connect/register")
48+
.contentType(MediaType.APPLICATION_JSON)
49+
.accept(MediaType.APPLICATION_JSON)
50+
.header(HttpHeaders.AUTHORIZATION, "Bearer %s".formatted(initialAccessToken))
51+
.body(Mono.just(request), ClientRegistrationRequest.class)
52+
.retrieve()
53+
.bodyToMono(ClientRegistrationResponse.class)
54+
.block();
55+
}
56+
57+
public ClientRegistrationResponse retrieveClient(String registrationAccessToken, String registrationClientUri) {
58+
return this.webClient
59+
.get()
60+
.uri(registrationClientUri)
61+
.header(HttpHeaders.AUTHORIZATION, "Bearer %s".formatted(registrationAccessToken))
62+
.retrieve()
63+
.bodyToMono(ClientRegistrationResponse.class)
64+
.block();
65+
}
66+
67+
public void exampleRegistration(String initialAccessToken) {
68+
ClientRegistrationResponse clientRegistrationResponse = this.registerClient(initialAccessToken, SAMPLE_CLIENT_REGISTRATION_REQUEST);
69+
70+
assert(clientRegistrationResponse.clientName.contentEquals("client-1"));
71+
assert(clientRegistrationResponse.scope.contentEquals("openid profile email"));
72+
assert(clientRegistrationResponse.grantTypes.contains(AuthorizationGrantType.AUTHORIZATION_CODE.getValue()));
73+
assert(clientRegistrationResponse.redirectUris.contains("https://client.example.org/callback"));
74+
assert(clientRegistrationResponse.redirectUris.contains("https://client.example.org/callback2"));
75+
assert(!clientRegistrationResponse.registrationAccessToken.isEmpty());
76+
assert(!clientRegistrationResponse.registrationClientUri.isEmpty());
77+
78+
String registrationAccessToken = clientRegistrationResponse.registrationAccessToken();
79+
String registrationClientUri = clientRegistrationResponse.registrationClientUri();
80+
81+
ClientRegistrationResponse retrievedClient = this.retrieveClient(registrationAccessToken, registrationClientUri);
82+
83+
assert(retrievedClient.clientName.contentEquals("client-1"));
84+
assert(retrievedClient.scope.contentEquals("openid profile email"));
85+
assert(retrievedClient.grantTypes.contains(AuthorizationGrantType.AUTHORIZATION_CODE.getValue()));
86+
assert(retrievedClient.redirectUris.contains("https://client.example.org/callback"));
87+
assert(retrievedClient.redirectUris.contains("https://client.example.org/callback2"));
88+
assert(Objects.isNull(retrievedClient.registrationAccessToken));
89+
assert(!retrievedClient.registrationClientUri.isEmpty());
90+
}
91+
}

0 commit comments

Comments
 (0)