diff --git a/docs/modules/ROOT/pages/guides/how-to-dynamic-client-registration.adoc b/docs/modules/ROOT/pages/guides/how-to-dynamic-client-registration.adoc index 85fc06a30e..0d953517e2 100644 --- a/docs/modules/ROOT/pages/guides/how-to-dynamic-client-registration.adoc +++ b/docs/modules/ROOT/pages/guides/how-to-dynamic-client-registration.adoc @@ -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 @@ -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. @@ -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. @@ -83,23 +84,93 @@ 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]. <2> A minimal representation of a client registration response. You may add additional client metadata parameters as per https://openid.net/specs/openid-connect-registration-1_0.html#RegistrationResponse[Client Registration Response]. -<3> Example demonstrating client registration and client retrieval. -<4> A sample client registration request object. -<5> Register the client using the "initial" access token and client registration request object. -<6> After successful registration, assert on the client 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 client metadata parameters that should be populated in the response. -<10> Sample https://openid.net/specs/openid-connect-registration-1_0.html#RegistrationRequest[Client Registration Request] using `WebClient`. -<11> Sample https://openid.net/specs/openid-connect-registration-1_0.html#ReadRequest[Client Read Request] using `WebClient`. +<3> Sample https://openid.net/specs/openid-connect-registration-1_0.html#RegistrationRequest[Client Registration Request] using `WebClient`. +<4> Sample https://openid.net/specs/openid-connect-registration-1_0.html#ReadRequest[Client Read Request] using `WebClient`. + +[[sample.basic.ClientRegistrationExample]] +[source,java] +---- +include::{examples-dir}/main/java/sample/registration/basic/BasicClientRegistrationExample.java[] +---- + +<1> Example demonstrating client registration and client retrieval. +<2> A sample client registration request object. +<3> Register the client using the "initial" access token and client registration request object. +<4> After successful registration, assert on the client metadata parameters that should be populated in the response. +<5> Extract `registration_access_token` and `registration_client_uri` response parameters, for use in retrieval of the newly registered client. +<6> Retrieve the client using the `registration_access_token` and `registration_client_uri`. +<7> After client retrieval, assert on the client metadata parameters that should be populated in the response. [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>` 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>` implementation from above with client registration endpoint authentication providers. + +[[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`. + +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. + +Once the authorization server is configured as per steps outlined above, a client with custom metadata can now be registered. + +[[sample.custom.CustomMetadataClientRegistrationExample]] +[source,java] +---- +include::{examples-dir}/main/java/sample/registration/custommetadata/CustomMetadataClientRegistrationExample.java[] +---- + +<1> Example demonstrating client registration and client retrieval. +<2> A sample client registration request object with custom metadata. +<3> Register the client using the "initial" access token and client registration request object. +<4> After successful registration, assert on the client custom metadata parameters that should be populated in the response. +<5> Extract `registration_access_token` and `registration_client_uri` response parameters, for use in retrieval of the newly registered client. +<6> Retrieve the client using the `registration_access_token` and `registration_client_uri`. +<7> After client retrieval, assert on the custom client metadata parameters that should be populated in the response. + diff --git a/docs/src/main/java/sample/registration/ClientRegistrar.java b/docs/src/main/java/sample/registration/basic/BasicClientRegistrationExample.java similarity index 51% rename from docs/src/main/java/sample/registration/ClientRegistrar.java rename to docs/src/main/java/sample/registration/basic/BasicClientRegistrationExample.java index f199a4357e..84402070ae 100644 --- a/docs/src/main/java/sample/registration/ClientRegistrar.java +++ b/docs/src/main/java/sample/registration/basic/BasicClientRegistrationExample.java @@ -13,48 +13,18 @@ * 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; - -import com.fasterxml.jackson.annotation.JsonProperty; -import reactor.core.publisher.Mono; - -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; -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 grantTypes, - @JsonProperty("redirect_uris") List redirectUris, - String scope) { - } +import java.util.List; +import java.util.Objects; - 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 grantTypes, - @JsonProperty("redirect_uris") List redirectUris, - String scope) { - } +import static sample.registration.basic.ClientRegistrar.*; - public void exampleRegistration(String initialAccessToken) { // <3> - ClientRegistrationRequest clientRegistrationRequest = new ClientRegistrationRequest( // <4> +public class BasicClientRegistrationExample { + public void register(ClientRegistrar registrar, String initialAccessToken) { // <1> + ClientRegistrationRequest clientRegistrationRequest = new ClientRegistrationRequest( // <2> "client-1", List.of(AuthorizationGrantType.AUTHORIZATION_CODE.getValue()), List.of("https://client.example.org/callback", "https://client.example.org/callback2"), @@ -62,9 +32,9 @@ public void exampleRegistration(String initialAccessToken) { // <3> ); ClientRegistrationResponse clientRegistrationResponse = - registerClient(initialAccessToken, clientRegistrationRequest); // <5> + registrar.registerClient(initialAccessToken, clientRegistrationRequest); // <3> - assert (clientRegistrationResponse.clientName().contentEquals("client-1")); // <6> + assert (clientRegistrationResponse.clientName().contentEquals("client-1")); // <4> assert (!Objects.isNull(clientRegistrationResponse.clientSecret())); assert (clientRegistrationResponse.scope().contentEquals("openid profile email")); assert (clientRegistrationResponse.grantTypes().contains(AuthorizationGrantType.AUTHORIZATION_CODE.getValue())); @@ -73,12 +43,12 @@ public void exampleRegistration(String initialAccessToken) { // <3> assert (!clientRegistrationResponse.registrationAccessToken().isEmpty()); assert (!clientRegistrationResponse.registrationClientUri().isEmpty()); - String registrationAccessToken = clientRegistrationResponse.registrationAccessToken(); // <7> + String registrationAccessToken = clientRegistrationResponse.registrationAccessToken(); // <5> String registrationClientUri = clientRegistrationResponse.registrationClientUri(); - ClientRegistrationResponse retrievedClient = retrieveClient(registrationAccessToken, registrationClientUri); // <8> + ClientRegistrationResponse retrievedClient = registrar.retrieveClient(registrationAccessToken, registrationClientUri); // <6> - assert (retrievedClient.clientName().contentEquals("client-1")); // <9> + assert (retrievedClient.clientName().contentEquals("client-1")); // <7> assert (!Objects.isNull(retrievedClient.clientId())); assert (!Objects.isNull(retrievedClient.clientSecret())); assert (retrievedClient.scope().contentEquals("openid profile email")); @@ -88,28 +58,4 @@ public void exampleRegistration(String initialAccessToken) { // <3> assert (Objects.isNull(retrievedClient.registrationAccessToken())); assert (!retrievedClient.registrationClientUri().isEmpty()); } - - public ClientRegistrationResponse registerClient(String initialAccessToken, ClientRegistrationRequest 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), ClientRegistrationRequest.class) - .retrieve() - .bodyToMono(ClientRegistrationResponse.class) - .block(); - } - - public ClientRegistrationResponse retrieveClient(String registrationAccessToken, String registrationClientUri) { // <11> - return this.webClient - .get() - .uri(registrationClientUri) - .header(HttpHeaders.AUTHORIZATION, "Bearer %s".formatted(registrationAccessToken)) - .retrieve() - .bodyToMono(ClientRegistrationResponse.class) - .block(); - } - } diff --git a/docs/src/main/java/sample/registration/ClientConfig.java b/docs/src/main/java/sample/registration/basic/ClientConfig.java similarity index 98% rename from docs/src/main/java/sample/registration/ClientConfig.java rename to docs/src/main/java/sample/registration/basic/ClientConfig.java index d6f81a1b27..7dc3b49a00 100644 --- a/docs/src/main/java/sample/registration/ClientConfig.java +++ b/docs/src/main/java/sample/registration/basic/ClientConfig.java @@ -13,9 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package sample.registration; - -import java.util.UUID; +package sample.registration.basic; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -25,9 +23,10 @@ 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 ClientConfig { - @Bean public RegisteredClientRepository registeredClientRepository() { RegisteredClient registrarClient = RegisteredClient.withId(UUID.randomUUID().toString()) @@ -41,5 +40,4 @@ public RegisteredClientRepository registeredClientRepository() { return new InMemoryRegisteredClientRepository(registrarClient); } - } diff --git a/docs/src/main/java/sample/registration/basic/ClientRegistrar.java b/docs/src/main/java/sample/registration/basic/ClientRegistrar.java new file mode 100644 index 0000000000..ebf5d28527 --- /dev/null +++ b/docs/src/main/java/sample/registration/basic/ClientRegistrar.java @@ -0,0 +1,75 @@ +/* + * 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.basic; + +import com.fasterxml.jackson.annotation.JsonProperty; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.util.List; +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 grantTypes, + @JsonProperty("redirect_uris") List redirectUris, + 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 grantTypes, + @JsonProperty("redirect_uris") List redirectUris, + String scope) { + } + + public ClientRegistrationResponse registerClient(String initialAccessToken, ClientRegistrationRequest request) { // <3> + 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) { // <4> + return this.webClient + .get() + .uri(registrationClientUri) + .header(HttpHeaders.AUTHORIZATION, "Bearer %s".formatted(registrationAccessToken)) + .retrieve() + .bodyToMono(ClientRegistrationResponse.class) + .block(); + } + +} diff --git a/docs/src/main/java/sample/registration/SecurityConfig.java b/docs/src/main/java/sample/registration/basic/SecurityConfig.java similarity index 98% rename from docs/src/main/java/sample/registration/SecurityConfig.java rename to docs/src/main/java/sample/registration/basic/SecurityConfig.java index b55be7e76d..3453d330ab 100644 --- a/docs/src/main/java/sample/registration/SecurityConfig.java +++ b/docs/src/main/java/sample/registration/basic/SecurityConfig.java @@ -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; diff --git a/docs/src/main/java/sample/registration/custommetadata/ClientRegistrar.java b/docs/src/main/java/sample/registration/custommetadata/ClientRegistrar.java new file mode 100644 index 0000000000..d7115c5235 --- /dev/null +++ b/docs/src/main/java/sample/registration/custommetadata/ClientRegistrar.java @@ -0,0 +1,81 @@ +/* + * 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.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.util.List; + +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 grantTypes, + @JsonProperty("redirect_uris") List redirectUris, + @JsonProperty("logo_uri") String logoUri, // <2> + List contacts, + String scope) { + } + + public record ClientRegistrationResponse( // <3> + @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 grantTypes, + @JsonProperty("redirect_uris") List redirectUris, + @JsonProperty("logo_uri") String logoUri, // <4> + List contacts, + String scope) { + } + + // @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 +} diff --git a/docs/src/main/java/sample/registration/custommetadata/CustomMetadataClientRegistrationExample.java b/docs/src/main/java/sample/registration/custommetadata/CustomMetadataClientRegistrationExample.java new file mode 100644 index 0000000000..ffaf81c5cf --- /dev/null +++ b/docs/src/main/java/sample/registration/custommetadata/CustomMetadataClientRegistrationExample.java @@ -0,0 +1,75 @@ +/* + * 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.security.oauth2.core.AuthorizationGrantType; + +import java.util.List; +import java.util.Objects; + +import static sample.registration.custommetadata.ClientRegistrar.*; + +public class CustomMetadataClientRegistrationExample { + public void register(ClientRegistrar registrar, String initialAccessToken) { // <1> + ClientRegistrationRequest clientRegistrationRequest = new ClientRegistrationRequest( // <2> + "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 = + registrar.registerClient(initialAccessToken, clientRegistrationRequest); // <3> + + assert (clientRegistrationResponse.logoUri().contentEquals("https://client.example.org/logo")); // <4> + 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(); // <5> + String registrationClientUri = clientRegistrationResponse.registrationClientUri(); + + ClientRegistrationResponse retrievedClient = registrar.retrieveClient(registrationAccessToken, registrationClientUri); // <6> + + assert (clientRegistrationResponse.logoUri().contentEquals("https://client.example.org/logo")); // <7> + 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 + } +} diff --git a/docs/src/main/java/sample/registration/custommetadata/CustomMetadataConfig.java b/docs/src/main/java/sample/registration/custommetadata/CustomMetadataConfig.java new file mode 100644 index 0000000000..e38c2ebf4a --- /dev/null +++ b/docs/src/main/java/sample/registration/custommetadata/CustomMetadataConfig.java @@ -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> registeredClientConverters() { + List 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 { // <6> + private final List customMetadata; + + private final OidcClientRegistrationRegisteredClientConverter delegate; + + CustomRegisteredClientConverter(List 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 { // <7> + private final List customMetadata; + + private final RegisteredClientOidcClientRegistrationConverter delegate; + + CustomClientRegistrationConverter(List customMetadata) { + this.customMetadata = customMetadata; + this.delegate = new RegisteredClientOidcClientRegistrationConverter(); + } + + public OidcClientRegistration convert(RegisteredClient registeredClient) { + var clientRegistration = delegate.convert(registeredClient); + Map 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(); + } + } + +} diff --git a/docs/src/main/java/sample/registration/custommetadata/SecurityConfig.java b/docs/src/main/java/sample/registration/custommetadata/SecurityConfig.java new file mode 100644 index 0000000000..c4e63e4892 --- /dev/null +++ b/docs/src/main/java/sample/registration/custommetadata/SecurityConfig.java @@ -0,0 +1,67 @@ +/* + * 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.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.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 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.web.SecurityFilterChain; + +import java.util.UUID; + +import static sample.registration.custommetadata.CustomMetadataConfig.*; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + @Bean + public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { + OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); + http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) + .oidc(oidc -> oidc.clientRegistrationEndpoint(endpoint -> { + endpoint.authenticationProviders(registeredClientConverters()); // <1> + })); + http.oauth2ResourceServer(oauth2ResourceServer -> + oauth2ResourceServer.jwt(Customizer.withDefaults())); + + return http.build(); + } + + // @fold:on + @Bean + public RegisteredClientRepository registeredClientRepository() { + RegisteredClient registrarClient = RegisteredClient.withId(UUID.randomUUID().toString()) + .clientId("registrar-client") + .clientSecret("{noop}secret") + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .scope("client.create") + .scope("client.read") + .build(); + + return new InMemoryRegisteredClientRepository(registrarClient); + } + // @fold:off +} diff --git a/docs/src/test/java/sample/registration/DynamicClientRegistrationTests.java b/docs/src/test/java/sample/registration/basic/DynamicClientRegistrationTests.java similarity index 92% rename from docs/src/test/java/sample/registration/DynamicClientRegistrationTests.java rename to docs/src/test/java/sample/registration/basic/DynamicClientRegistrationTests.java index 41874e751e..7449c39167 100644 --- a/docs/src/test/java/sample/registration/DynamicClientRegistrationTests.java +++ b/docs/src/test/java/sample/registration/basic/DynamicClientRegistrationTests.java @@ -13,9 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package sample.registration; +package sample.registration.basic; import com.jayway.jsonpath.JsonPath; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -56,6 +57,7 @@ public class DynamicClientRegistrationTests { private String port; @Test + @DisplayName("Register a client") public void dynamicallyRegisterClient() throws Exception { MockHttpServletResponse tokenResponse = this.mvc.perform(post("/oauth2/token") .with(httpBasic("registrar-client", "secret")) @@ -72,7 +74,8 @@ public void dynamicallyRegisterClient() throws Exception { WebClient webClient = WebClient.builder().baseUrl("http://127.0.0.1:%s".formatted(this.port)).build(); ClientRegistrar clientRegistrar = new ClientRegistrar(webClient); - clientRegistrar.exampleRegistration(initialAccessToken); + BasicClientRegistrationExample example = new BasicClientRegistrationExample(); + example.register(clientRegistrar, initialAccessToken); } @EnableAutoConfiguration diff --git a/docs/src/test/java/sample/registration/custommetadata/CustomMetadataDynamicClientRegistrationTests.java b/docs/src/test/java/sample/registration/custommetadata/CustomMetadataDynamicClientRegistrationTests.java new file mode 100644 index 0000000000..dc33a748da --- /dev/null +++ b/docs/src/test/java/sample/registration/custommetadata/CustomMetadataDynamicClientRegistrationTests.java @@ -0,0 +1,86 @@ +/* + * 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.jayway.jsonpath.JsonPath; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.reactive.function.client.WebClient; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests for Dynamic Client Registration how-to guide -- including custom metadata customization. + * + * @author Dmitriy Dubson + */ +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + classes = {CustomMetadataDynamicClientRegistrationTests.AuthorizationServerConfig.class} +) +@AutoConfigureMockMvc +public class CustomMetadataDynamicClientRegistrationTests { + + @Autowired + private MockMvc mvc; + + @LocalServerPort + private String port; + + @Test + @DisplayName("Register a client, including custom metadata") + public void dynamicallyRegisterClientWithCustomMetadata() throws Exception { + MockHttpServletResponse tokenResponse = this.mvc.perform(post("/oauth2/token") + .with(httpBasic("registrar-client", "secret")) + .param(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.CLIENT_CREDENTIALS.getValue()) + .param(OAuth2ParameterNames.SCOPE, "client.create") + .contentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.access_token").isNotEmpty()) + .andReturn() + .getResponse(); + + String initialAccessToken = JsonPath.parse(tokenResponse.getContentAsString()).read("$.access_token"); + + WebClient webClient = WebClient.builder().baseUrl("http://127.0.0.1:%s".formatted(this.port)).build(); + ClientRegistrar clientRegistrar = new ClientRegistrar(webClient); + + CustomMetadataClientRegistrationExample example = new CustomMetadataClientRegistrationExample(); + example.register(clientRegistrar, initialAccessToken); + } + + @EnableAutoConfiguration + @EnableWebSecurity + @ComponentScan + static class AuthorizationServerConfig { + } + +}