Skip to content

Commit b98bbce

Browse files
committed
Add how-to guide for dynamic client registration with custom metadata
Fixes gh-1044
1 parent 2dcbc58 commit b98bbce

File tree

9 files changed

+453
-9
lines changed

9 files changed

+453
-9
lines changed

docs/modules/ROOT/pages/guides/how-to-dynamic-client-registration.adoc

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Spring Authorization Server implements the https://openid.net/specs/openid-conne
1010
* xref:guides/how-to-dynamic-client-registration.adoc#configure-client-registrar[Configure client registrar]
1111
* xref:guides/how-to-dynamic-client-registration.adoc#obtain-initial-access-token[Obtain initial access token]
1212
* xref:guides/how-to-dynamic-client-registration.adoc#register-client[Register a client]
13+
* xref:guides/how-to-dynamic-client-registration.adoc#customize-metadata[Customize client metadata]
1314

1415
[[enable-dynamic-client-registration]]
1516
== Enable Dynamic Client Registration
@@ -20,7 +21,7 @@ To enable, add the following configuration:
2021
[[sample.SecurityConfig]]
2122
[source,java]
2223
----
23-
include::{examples-dir}/main/java/sample/registration/SecurityConfig.java[]
24+
include::{examples-dir}/main/java/sample/registration/basic/SecurityConfig.java[]
2425
----
2526

2627
<1> Enable the xref:protocol-endpoints.adoc#oidc-client-registration-endpoint[OpenID Connect 1.0 Client Registration Endpoint] with the default configuration.
@@ -35,7 +36,7 @@ The following listing shows an example client:
3536
[[sample.ClientConfig]]
3637
[source,java]
3738
----
38-
include::{examples-dir}/main/java/sample/registration/ClientConfig.java[]
39+
include::{examples-dir}/main/java/sample/registration/basic/ClientConfig.java[]
3940
----
4041

4142
<1> `client_credentials` grant type is configured to obtain access tokens directly.
@@ -83,10 +84,10 @@ With an access token obtained from the previous step, a client can now be dynami
8384
The "initial" access token can only be used once.
8485
After the client is registered, the access token is invalidated.
8586

86-
[[sample.ClientRegistrar]]
87+
[[sample.basic.ClientRegistrar]]
8788
[source,java]
8889
----
89-
include::{examples-dir}/main/java/sample/registration/ClientRegistrar.java[]
90+
include::{examples-dir}/main/java/sample/registration/basic/ClientRegistrar.java[]
9091
----
9192

9293
<1> A minimal representation of a client registration request. You may add additional client metadata parameters as per https://openid.net/specs/openid-connect-registration-1_0.html#RegistrationRequest[Client Registration Request].
@@ -103,3 +104,61 @@ include::{examples-dir}/main/java/sample/registration/ClientRegistrar.java[]
103104

104105
[NOTE]
105106
The https://openid.net/specs/openid-connect-registration-1_0.html#ReadResponse[Client Read Response] should contain the same client metadata parameters as the https://openid.net/specs/openid-connect-registration-1_0.html#RegistrationResponse[Client Registration Response], except the `registration_access_token` parameter.
107+
108+
[[customize-metadata]]
109+
== Customize client metadata
110+
111+
In order to accept custom client metadata when registering a client, a few additional implementation details
112+
are necessary.
113+
114+
[NOTE]
115+
====
116+
The following example depicts example custom metadata `logo_uri` (string type) and `contacts` (string array type)
117+
====
118+
119+
Create a set of custom `Converter` classes in order to retain custom client claims.
120+
121+
[[sample.custom.CustomMetadataConfig]]
122+
[source,java]
123+
----
124+
include::{examples-dir}/main/java/sample/registration/custommetadata/CustomMetadataConfig.java[]
125+
----
126+
127+
<1> Create a `Consumer<List<AuthenticationProvider>>` implementation.
128+
<2> Identify custom fields that should be accepted during client registration.
129+
<3> Filter for `OidcClientRegistrationAuthenticationProvider` instance.
130+
<4> Add a custom registered client `Converter` (implementation in #6)
131+
<5> Add a custom client registration `Converter` (implementation in #7)
132+
<6> Custom registered client `Converter` implementation that adds custom claims to registered client settings.
133+
<7> Custom client registration `Converter` implementation that modifies client registration claims with custom metadata.
134+
135+
[[sample.custom.SecurityConfig]]
136+
[source,java]
137+
----
138+
include::{examples-dir}/main/java/sample/registration/custommetadata/SecurityConfig.java[]
139+
----
140+
141+
<1> Configure the `Consumer<List<AuthenticationProvider>>` implementation from above with client registration endpoint authentication providers.
142+
143+
Once the authorization server is configured as per steps outlined above, a client with custom metadata can now be registered.
144+
145+
[NOTE]
146+
====
147+
The registration and retrieval implementations of a client can be found in xref:guides/how-to-dynamic-client-registration.adoc#register-client[Register a client] section.
148+
====
149+
150+
[[sample.custom.ClientRegistrar]]
151+
[source,java]
152+
----
153+
include::{examples-dir}/main/java/sample/registration/custommetadata/ClientRegistrar.java[]
154+
----
155+
156+
<1> A minimal representation of a client registration request with added custom metadata fields `logo_uri` and `contacts`.
157+
<2> A minimal representation of a client registration response with added custom metadata fields `logo_uri` and `contacts`.
158+
<3> Example demonstrating client registration and client retrieval.
159+
<4> A sample client registration request object with custom metadata.
160+
<5> Register the client using the "initial" access token and client registration request object.
161+
<6> After successful registration, assert on the client custom metadata parameters that should be populated in the response.
162+
<7> Extract `registration_access_token` and `registration_client_uri` response parameters, for use in retrieval of the newly registered client.
163+
<8> Retrieve the client using the `registration_access_token` and `registration_client_uri`.
164+
<9> After client retrieval, assert on the custom client metadata parameters that should be populated in the response.

docs/src/main/java/sample/registration/ClientConfig.java renamed to docs/src/main/java/sample/registration/basic/ClientConfig.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
* See the License for the specific language governing permissions and
1414
* limitations under the License.
1515
*/
16-
package sample.registration;
16+
package sample.registration.basic;
1717

1818
import java.util.UUID;
1919

docs/src/main/java/sample/registration/ClientRegistrar.java renamed to docs/src/main/java/sample/registration/basic/ClientRegistrar.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
* See the License for the specific language governing permissions and
1414
* limitations under the License.
1515
*/
16-
package sample.registration;
16+
package sample.registration.basic;
1717

1818
import java.util.List;
1919
import java.util.Objects;

docs/src/main/java/sample/registration/SecurityConfig.java renamed to docs/src/main/java/sample/registration/basic/SecurityConfig.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
* See the License for the specific language governing permissions and
1414
* limitations under the License.
1515
*/
16-
package sample.registration;
16+
package sample.registration.basic;
1717

1818
import org.springframework.context.annotation.Bean;
1919
import org.springframework.context.annotation.Configuration;
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/*
2+
* Copyright 2020-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package sample.registration.custommetadata;
17+
18+
import com.fasterxml.jackson.annotation.JsonProperty;
19+
import org.springframework.http.HttpHeaders;
20+
import org.springframework.http.MediaType;
21+
import org.springframework.security.oauth2.core.AuthorizationGrantType;
22+
import org.springframework.web.reactive.function.client.WebClient;
23+
import reactor.core.publisher.Mono;
24+
25+
import java.util.List;
26+
import java.util.Objects;
27+
28+
public class ClientRegistrar {
29+
// @fold:on
30+
private final WebClient webClient;
31+
32+
public ClientRegistrar(WebClient webClient) {
33+
this.webClient = webClient;
34+
}
35+
// @fold:off
36+
37+
public record ClientRegistrationRequest( // <1>
38+
@JsonProperty("client_name") String clientName,
39+
@JsonProperty("grant_types") List<String> grantTypes,
40+
@JsonProperty("redirect_uris") List<String> redirectUris,
41+
@JsonProperty("logo_uri") String logoUri,
42+
List<String> contacts,
43+
String scope) {
44+
}
45+
46+
public record ClientRegistrationResponse( // <2>
47+
@JsonProperty("registration_access_token") String registrationAccessToken,
48+
@JsonProperty("registration_client_uri") String registrationClientUri,
49+
@JsonProperty("client_name") String clientName,
50+
@JsonProperty("client_id") String clientId,
51+
@JsonProperty("client_secret") String clientSecret,
52+
@JsonProperty("grant_types") List<String> grantTypes,
53+
@JsonProperty("redirect_uris") List<String> redirectUris,
54+
@JsonProperty("logo_uri") String logoUri,
55+
List<String> contacts,
56+
String scope) {
57+
}
58+
59+
// @fold:on
60+
public void exampleRegistration(String initialAccessToken) { // <3>
61+
ClientRegistrationRequest clientRegistrationRequest = new ClientRegistrationRequest( // <4>
62+
"client-1",
63+
List.of(AuthorizationGrantType.AUTHORIZATION_CODE.getValue()),
64+
List.of("https://client.example.org/callback", "https://client.example.org/callback2"),
65+
"https://client.example.org/logo",
66+
List.of("contact-1", "contact-2"),
67+
"openid email profile"
68+
);
69+
70+
ClientRegistrationResponse clientRegistrationResponse =
71+
registerClient(initialAccessToken, clientRegistrationRequest); // <5>
72+
73+
assert (clientRegistrationResponse.logoUri().contentEquals("https://client.example.org/logo")); // <6>
74+
assert (clientRegistrationResponse.contacts().size() == 2);
75+
assert (clientRegistrationResponse.contacts().contains("contact-1"));
76+
assert (clientRegistrationResponse.contacts().contains("contact-2"));
77+
// @fold:on
78+
assert (clientRegistrationResponse.clientName().contentEquals("client-1"));
79+
assert (!Objects.isNull(clientRegistrationResponse.clientSecret()));
80+
assert (clientRegistrationResponse.scope().contentEquals("openid profile email"));
81+
assert (clientRegistrationResponse.grantTypes().contains(AuthorizationGrantType.AUTHORIZATION_CODE.getValue()));
82+
assert (clientRegistrationResponse.redirectUris().contains("https://client.example.org/callback"));
83+
assert (clientRegistrationResponse.redirectUris().contains("https://client.example.org/callback2"));
84+
assert (!clientRegistrationResponse.registrationAccessToken().isEmpty());
85+
assert (!clientRegistrationResponse.registrationClientUri().isEmpty());
86+
// @fold:off
87+
88+
String registrationAccessToken = clientRegistrationResponse.registrationAccessToken(); // <7>
89+
String registrationClientUri = clientRegistrationResponse.registrationClientUri();
90+
91+
ClientRegistrationResponse retrievedClient = retrieveClient(registrationAccessToken, registrationClientUri); // <8>
92+
93+
assert (clientRegistrationResponse.logoUri().contentEquals("https://client.example.org/logo")); // <9>
94+
assert (clientRegistrationResponse.contacts().size() == 2);
95+
assert (clientRegistrationResponse.contacts().contains("contact-1"));
96+
assert (clientRegistrationResponse.contacts().contains("contact-2"));
97+
// @fold:on
98+
assert (retrievedClient.clientName().contentEquals("client-1"));
99+
assert (!Objects.isNull(retrievedClient.clientId()));
100+
assert (!Objects.isNull(retrievedClient.clientSecret()));
101+
assert (retrievedClient.scope().contentEquals("openid profile email"));
102+
assert (retrievedClient.grantTypes().contains(AuthorizationGrantType.AUTHORIZATION_CODE.getValue()));
103+
assert (retrievedClient.redirectUris().contains("https://client.example.org/callback"));
104+
assert (retrievedClient.redirectUris().contains("https://client.example.org/callback2"));
105+
assert (Objects.isNull(retrievedClient.registrationAccessToken()));
106+
assert (!retrievedClient.registrationClientUri().isEmpty());
107+
// @fold:off
108+
}
109+
110+
// @fold:on
111+
public ClientRegistrationResponse registerClient(String initialAccessToken, ClientRegistrationRequest request) {
112+
return this.webClient
113+
.post()
114+
.uri("/connect/register")
115+
.contentType(MediaType.APPLICATION_JSON)
116+
.accept(MediaType.APPLICATION_JSON)
117+
.header(HttpHeaders.AUTHORIZATION, "Bearer %s".formatted(initialAccessToken))
118+
.body(Mono.just(request), ClientRegistrationRequest.class)
119+
.retrieve()
120+
.bodyToMono(ClientRegistrationResponse.class)
121+
.block();
122+
}
123+
124+
public ClientRegistrationResponse retrieveClient(String registrationAccessToken, String registrationClientUri) {
125+
return this.webClient
126+
.get()
127+
.uri(registrationClientUri)
128+
.header(HttpHeaders.AUTHORIZATION, "Bearer %s".formatted(registrationAccessToken))
129+
.retrieve()
130+
.bodyToMono(ClientRegistrationResponse.class)
131+
.block();
132+
}
133+
// @fold:off
134+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/*
2+
* Copyright 2020-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package sample.registration.custommetadata;
17+
18+
import org.springframework.core.convert.converter.Converter;
19+
import org.springframework.security.authentication.AuthenticationProvider;
20+
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
21+
import org.springframework.security.oauth2.server.authorization.oidc.OidcClientRegistration;
22+
import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcClientRegistrationAuthenticationProvider;
23+
import org.springframework.security.oauth2.server.authorization.oidc.converter.OidcClientRegistrationRegisteredClientConverter;
24+
import org.springframework.security.oauth2.server.authorization.oidc.converter.RegisteredClientOidcClientRegistrationConverter;
25+
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
26+
import org.springframework.util.CollectionUtils;
27+
28+
import java.util.HashMap;
29+
import java.util.List;
30+
import java.util.Map;
31+
import java.util.function.Consumer;
32+
import java.util.function.Function;
33+
import java.util.stream.Collectors;
34+
35+
public class CustomMetadataConfig {
36+
public static Consumer<List<AuthenticationProvider>> registeredClientConverters() {
37+
List<String> customClientMetadata = List.of("logo_uri", "contacts"); // <1>
38+
39+
return (authenticationProviders) -> // <2>
40+
authenticationProviders.forEach(authenticationProvider -> {
41+
if (authenticationProvider instanceof OidcClientRegistrationAuthenticationProvider provider) { // <3>
42+
provider.setRegisteredClientConverter(new CustomRegisteredClientConverter(customClientMetadata)); // <4>
43+
provider.setClientRegistrationConverter(new CustomClientRegistrationConverter(customClientMetadata)); // <5>
44+
}
45+
});
46+
}
47+
48+
static class CustomRegisteredClientConverter implements Converter<OidcClientRegistration, RegisteredClient> { // <6>
49+
private final List<String> customMetadata;
50+
51+
private final OidcClientRegistrationRegisteredClientConverter delegate;
52+
53+
CustomRegisteredClientConverter(List<String> customMetadata) {
54+
this.customMetadata = customMetadata;
55+
this.delegate = new OidcClientRegistrationRegisteredClientConverter();
56+
}
57+
58+
public RegisteredClient convert(OidcClientRegistration clientRegistration) {
59+
RegisteredClient convertedClient = delegate.convert(clientRegistration);
60+
ClientSettings.Builder clientSettingsBuilder = ClientSettings
61+
.withSettings(convertedClient.getClientSettings().getSettings());
62+
63+
if (!CollectionUtils.isEmpty(this.customMetadata)) {
64+
clientRegistration.getClaims().forEach((claim, value) -> {
65+
if (this.customMetadata.contains(claim)) {
66+
clientSettingsBuilder.setting(claim, value);
67+
}
68+
});
69+
}
70+
71+
return RegisteredClient.from(convertedClient).clientSettings(clientSettingsBuilder.build()).build();
72+
}
73+
}
74+
75+
static class CustomClientRegistrationConverter implements Converter<RegisteredClient, OidcClientRegistration> { // <7>
76+
private final List<String> customMetadata;
77+
78+
private final RegisteredClientOidcClientRegistrationConverter delegate;
79+
80+
CustomClientRegistrationConverter(List<String> customMetadata) {
81+
this.customMetadata = customMetadata;
82+
this.delegate = new RegisteredClientOidcClientRegistrationConverter();
83+
}
84+
85+
public OidcClientRegistration convert(RegisteredClient registeredClient) {
86+
var clientRegistration = delegate.convert(registeredClient);
87+
Map<String, Object> claims = new HashMap<>(clientRegistration.getClaims());
88+
if (!CollectionUtils.isEmpty(customMetadata)) {
89+
ClientSettings clientSettings = registeredClient.getClientSettings();
90+
91+
claims.putAll(customMetadata.stream()
92+
.filter(metadatum -> clientSettings.getSetting(metadatum) != null)
93+
.collect(Collectors.toMap(Function.identity(), clientSettings::getSetting)));
94+
}
95+
return OidcClientRegistration.withClaims(claims).build();
96+
}
97+
}
98+
99+
}

0 commit comments

Comments
 (0)