Skip to content

Commit

Permalink
Add how-to guide for dynamic client registration with custom metadata
Browse files Browse the repository at this point in the history
Fixes gh-1044
  • Loading branch information
ddubson committed Sep 29, 2023
1 parent 2dcbc58 commit b98bbce
Show file tree
Hide file tree
Showing 9 changed files with 453 additions and 9 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Spring Authorization Server implements the https://openid.net/specs/openid-conne
* xref:guides/how-to-dynamic-client-registration.adoc#configure-client-registrar[Configure client registrar]
* 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#customize-metadata[Customize client metadata]

[[enable-dynamic-client-registration]]
== Enable Dynamic Client Registration
Expand All @@ -20,7 +21,7 @@ To enable, add the following configuration:
[[sample.SecurityConfig]]
[source,java]
----
include::{examples-dir}/main/java/sample/registration/SecurityConfig.java[]
include::{examples-dir}/main/java/sample/registration/basic/SecurityConfig.java[]
----

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

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

[[sample.ClientRegistrar]]
[[sample.basic.ClientRegistrar]]
[source,java]
----
include::{examples-dir}/main/java/sample/registration/ClientRegistrar.java[]
include::{examples-dir}/main/java/sample/registration/basic/ClientRegistrar.java[]
----

<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].
Expand All @@ -103,3 +104,61 @@ include::{examples-dir}/main/java/sample/registration/ClientRegistrar.java[]

[NOTE]
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.

[[customize-metadata]]
== Customize client metadata

In order to accept custom client metadata when registering a client, a few additional implementation details
are necessary.

[NOTE]
====
The following example depicts example custom metadata `logo_uri` (string type) and `contacts` (string array type)
====

Create a set of custom `Converter` classes in order to retain custom client claims.

[[sample.custom.CustomMetadataConfig]]
[source,java]
----
include::{examples-dir}/main/java/sample/registration/custommetadata/CustomMetadataConfig.java[]
----

<1> Create a `Consumer<List<AuthenticationProvider>>` implementation.
<2> Identify custom fields that should be accepted during client registration.
<3> Filter for `OidcClientRegistrationAuthenticationProvider` instance.
<4> Add a custom registered client `Converter` (implementation in #6)
<5> Add a custom client registration `Converter` (implementation in #7)
<6> Custom registered client `Converter` implementation that adds custom claims to registered client settings.
<7> Custom client registration `Converter` implementation that modifies client registration claims with custom metadata.

[[sample.custom.SecurityConfig]]
[source,java]
----
include::{examples-dir}/main/java/sample/registration/custommetadata/SecurityConfig.java[]
----

<1> Configure the `Consumer<List<AuthenticationProvider>>` implementation from above with client registration endpoint authentication providers.

Once the authorization server is configured as per steps outlined above, a client with custom metadata can now be registered.

[NOTE]
====
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.
====

[[sample.custom.ClientRegistrar]]
[source,java]
----
include::{examples-dir}/main/java/sample/registration/custommetadata/ClientRegistrar.java[]
----

<1> A minimal representation of a client registration request with added custom metadata fields `logo_uri` and `contacts`.
<2> A minimal representation of a client registration response with added custom metadata fields `logo_uri` and `contacts`.
<3> Example demonstrating client registration and client retrieval.
<4> A sample client registration request object with custom metadata.
<5> Register the client using the "initial" access token and client registration request object.
<6> After successful registration, assert on the client custom metadata parameters that should be populated in the response.
<7> Extract `registration_access_token` and `registration_client_uri` response parameters, for use in retrieval of the newly registered client.
<8> Retrieve the client using the `registration_access_token` and `registration_client_uri`.
<9> After client retrieval, assert on the custom client metadata parameters that should be populated in the response.
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package sample.registration;
package sample.registration.basic;

import java.util.UUID;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package sample.registration;
package sample.registration.basic;

import java.util.List;
import java.util.Objects;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package sample.registration;
package sample.registration.basic;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/*
* 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.registration.custommetadata;

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 ClientRegistrar {
// @fold:on
private final WebClient webClient;

public ClientRegistrar(WebClient webClient) {
this.webClient = webClient;
}
// @fold:off

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

public record ClientRegistrationResponse( // <2>
@JsonProperty("registration_access_token") String registrationAccessToken,
@JsonProperty("registration_client_uri") String registrationClientUri,
@JsonProperty("client_name") String clientName,
@JsonProperty("client_id") String clientId,
@JsonProperty("client_secret") String clientSecret,
@JsonProperty("grant_types") List<String> grantTypes,
@JsonProperty("redirect_uris") List<String> redirectUris,
@JsonProperty("logo_uri") String logoUri,
List<String> contacts,
String scope) {
}

// @fold:on
public void exampleRegistration(String initialAccessToken) { // <3>
ClientRegistrationRequest clientRegistrationRequest = new ClientRegistrationRequest( // <4>
"client-1",
List.of(AuthorizationGrantType.AUTHORIZATION_CODE.getValue()),
List.of("https://client.example.org/callback", "https://client.example.org/callback2"),
"https://client.example.org/logo",
List.of("contact-1", "contact-2"),
"openid email profile"
);

ClientRegistrationResponse clientRegistrationResponse =
registerClient(initialAccessToken, clientRegistrationRequest); // <5>

assert (clientRegistrationResponse.logoUri().contentEquals("https://client.example.org/logo")); // <6>
assert (clientRegistrationResponse.contacts().size() == 2);
assert (clientRegistrationResponse.contacts().contains("contact-1"));
assert (clientRegistrationResponse.contacts().contains("contact-2"));
// @fold:on
assert (clientRegistrationResponse.clientName().contentEquals("client-1"));
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());
// @fold:off

String registrationAccessToken = clientRegistrationResponse.registrationAccessToken(); // <7>
String registrationClientUri = clientRegistrationResponse.registrationClientUri();

ClientRegistrationResponse retrievedClient = retrieveClient(registrationAccessToken, registrationClientUri); // <8>

assert (clientRegistrationResponse.logoUri().contentEquals("https://client.example.org/logo")); // <9>
assert (clientRegistrationResponse.contacts().size() == 2);
assert (clientRegistrationResponse.contacts().contains("contact-1"));
assert (clientRegistrationResponse.contacts().contains("contact-2"));
// @fold:on
assert (retrievedClient.clientName().contentEquals("client-1"));
assert (!Objects.isNull(retrievedClient.clientId()));
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());
// @fold:off
}

// @fold:on
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();
}
// @fold:off
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
* 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.registration.custommetadata;

import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.oidc.OidcClientRegistration;
import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcClientRegistrationAuthenticationProvider;
import org.springframework.security.oauth2.server.authorization.oidc.converter.OidcClientRegistrationRegisteredClientConverter;
import org.springframework.security.oauth2.server.authorization.oidc.converter.RegisteredClientOidcClientRegistrationConverter;
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
import org.springframework.util.CollectionUtils;

import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.stream.Collectors;

public class CustomMetadataConfig {
public static Consumer<List<AuthenticationProvider>> registeredClientConverters() {
List<String> customClientMetadata = List.of("logo_uri", "contacts"); // <1>

return (authenticationProviders) -> // <2>
authenticationProviders.forEach(authenticationProvider -> {
if (authenticationProvider instanceof OidcClientRegistrationAuthenticationProvider provider) { // <3>
provider.setRegisteredClientConverter(new CustomRegisteredClientConverter(customClientMetadata)); // <4>
provider.setClientRegistrationConverter(new CustomClientRegistrationConverter(customClientMetadata)); // <5>
}
});
}

static class CustomRegisteredClientConverter implements Converter<OidcClientRegistration, RegisteredClient> { // <6>
private final List<String> customMetadata;

private final OidcClientRegistrationRegisteredClientConverter delegate;

CustomRegisteredClientConverter(List<String> customMetadata) {
this.customMetadata = customMetadata;
this.delegate = new OidcClientRegistrationRegisteredClientConverter();
}

public RegisteredClient convert(OidcClientRegistration clientRegistration) {
RegisteredClient convertedClient = delegate.convert(clientRegistration);
ClientSettings.Builder clientSettingsBuilder = ClientSettings
.withSettings(convertedClient.getClientSettings().getSettings());

if (!CollectionUtils.isEmpty(this.customMetadata)) {
clientRegistration.getClaims().forEach((claim, value) -> {
if (this.customMetadata.contains(claim)) {
clientSettingsBuilder.setting(claim, value);
}
});
}

return RegisteredClient.from(convertedClient).clientSettings(clientSettingsBuilder.build()).build();
}
}

static class CustomClientRegistrationConverter implements Converter<RegisteredClient, OidcClientRegistration> { // <7>
private final List<String> customMetadata;

private final RegisteredClientOidcClientRegistrationConverter delegate;

CustomClientRegistrationConverter(List<String> customMetadata) {
this.customMetadata = customMetadata;
this.delegate = new RegisteredClientOidcClientRegistrationConverter();
}

public OidcClientRegistration convert(RegisteredClient registeredClient) {
var clientRegistration = delegate.convert(registeredClient);
Map<String, Object> claims = new HashMap<>(clientRegistration.getClaims());
if (!CollectionUtils.isEmpty(customMetadata)) {
ClientSettings clientSettings = registeredClient.getClientSettings();

claims.putAll(customMetadata.stream()
.filter(metadatum -> clientSettings.getSetting(metadatum) != null)
.collect(Collectors.toMap(Function.identity(), clientSettings::getSetting)));
}
return OidcClientRegistration.withClaims(claims).build();
}
}

}
Loading

0 comments on commit b98bbce

Please sign in to comment.