forked from spring-projects/spring-authorization-server
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Adds dynamic client registration how-to guide
Closes spring-projectsgh-647
- Loading branch information
Showing
7 changed files
with
471 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
175 changes: 175 additions & 0 deletions
175
docs/modules/ROOT/pages/guides/how-to-dynamic-client-registration.adoc
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,175 @@ | ||
[[how-to-dynamic-client-registration]] | ||
= How-to: Register a client dynamically | ||
:index-link: ../how-to.html | ||
:docs-dir: .. | ||
|
||
This guide shows how to configure OpenID Connect Dynamic Client Registration 1.0 in Spring Authorization Server and | ||
walks through an example of how to register a client. Spring Authorization Server implements https://openid.net/specs/openid-connect-registration-1_0.html[OpenID Connect Dynamic Client Registration 1.0] | ||
specification, gaining the ability to dynamically register and retrieve OpenID clients. | ||
|
||
- xref:guides/how-to-dynamic-client-registration.adoc#enable[Enable Dynamic Client Registration] | ||
- xref:guides/how-to-dynamic-client-registration.adoc#configure-initial-client[Configure initial client] | ||
- xref:guides/how-to-dynamic-client-registration.adoc#obtain-initial-access-token[Obtain initial access token] | ||
- xref:guides/how-to-dynamic-client-registration.adoc#register-client[Register a client] | ||
- xref:guides/how-to-dynamic-client-registration.adoc#retrieve-client[Retrieve client] | ||
|
||
[[enable]] | ||
== Enable Dynamic Client Registration | ||
|
||
By default, dynamic client registration functionality is disabled in Spring Authorization Server. | ||
To enable, add the following configuration: | ||
|
||
[[sample.dcrAuthServerConfig]] | ||
[source,java] | ||
---- | ||
include::{examples-dir}/main/java/sample/dcr/DcrConfiguration.java[] | ||
---- | ||
|
||
<1> Add a `SecurityFilterChain` `@Bean` that registers an `OAuth2AuthorizationServerConfigurer` | ||
<2> In the configurer, apply OIDC client registration endpoint customizer with default values. | ||
This enables dynamic client registration functionality. | ||
|
||
Please refer to xref:protocol-endpoints.adoc#oidc-client-registration-endpoint[Client Registration Endpoint docs] for | ||
in-depth configuration details. | ||
|
||
[[configure-initial-client]] | ||
== Configure initial client | ||
|
||
An initial client is required in order to register new clients in the authorization server. The client must be configured | ||
with scopes `client.create` and optionally `client.read` for creating clients and reading clients, respectively. | ||
A programmatic example of such a client is below. | ||
|
||
[[sample.dcrRegisteredClientConfig]] | ||
[source,java] | ||
---- | ||
include::{examples-dir}/main/java/sample/dcr/RegisteredClientConfiguration.java[] | ||
---- | ||
|
||
<1> A `RegisteredClientRepository` `@Bean` is configured with a set of clients. | ||
<2> An initial client with client id `dcr-client` is configured. | ||
<3> `client_credentials` grant type is set to fetch access tokens directly. | ||
<4> `client.create` scope is configured for the client to ensure they are able to create clients. | ||
<5> `client.read` scope is configured for the client to ensure they are able to fetch and read clients. | ||
<6> The initial client is saved into the data store. | ||
|
||
After configuring the above, run the authorization server in your preferred environment. | ||
|
||
[[obtain-initial-access-token]] | ||
== Obtain initial access token | ||
|
||
An initial access token is required to be able to create client registration requests. The token request must contain a | ||
request for scope `client.create` only. | ||
|
||
[source,httprequest] | ||
---- | ||
POST /oauth2/token HTTP/1.1 | ||
Authorization: Basic <base64-encoded-credentials> | ||
Content-Type: application/x-www-form-urlencoded | ||
grant_type=client_credentials&scope=client.create | ||
---- | ||
|
||
[WARNING] | ||
==== | ||
If you provide more than one scope in the request, you will not be able to register a client. The client creation | ||
request requires an access token with a single scope of `client.create` | ||
==== | ||
|
||
[TIP] | ||
==== | ||
To obtain encoded credentials for the above request, `base64` encode the client credentials in the format of | ||
`<clientId>:<clientSecret>`. Below is an encoding operation for the example in this guide. | ||
[source,console] | ||
---- | ||
echo -n "initial-app:secret" | base64 | ||
---- | ||
==== | ||
|
||
[[register-client]] | ||
== Register a client | ||
|
||
With an access token obtained from the previous step, register a new client with the following request. | ||
|
||
[NOTE] | ||
The access token can only be used once. After a single registration request, the access token is invalidated. | ||
|
||
[source,console] | ||
---- | ||
curl -X POST --location "https://authserver.example.org/connect/register" --http1.1 \ | ||
-H "Content-Type: application/json" \ | ||
-H "Accept: application/json" \ | ||
-H "Authorization: Bearer <initial-access-token>" \ | ||
-d "{ | ||
\"client_name\": \"My Example\", | ||
\"grant_types\": [ | ||
\"authorization_code\", | ||
\"client_credentials\", | ||
\"refresh_token\" | ||
], | ||
\"scope\": \"openid profile email\", | ||
\"redirect_uris\": [ | ||
\"https://client.example.org/callback\", | ||
\"https://client.example.org/callback2\" | ||
], | ||
\"token_endpoint_auth_method\": \"client_secret_basic\", | ||
\"post_logout_redirect_uris\": [ | ||
\"https://client.example.org/logout\" | ||
] | ||
}" | ||
---- | ||
|
||
An example register client response may be as follows: | ||
|
||
[source,console] | ||
---- | ||
HTTP/1.1 201 | ||
... | ||
{ | ||
"client_id": "Q_AQ0wUzbTXo-IE4rNJbU9Dv8BBex1zQrjDeJs0mDbM", | ||
"client_id_issued_at": 1690726915, | ||
"client_name": "My Example", | ||
"client_secret": "XleADJhomxA2Rmyom2hmpnS6_CDnyAFBI9JsGeC-XQ0QLa9p9JExXJABiYz7fOXA", | ||
"redirect_uris": [ | ||
"https://client.example.org/callback", | ||
"https://client.example.org/callback2" | ||
], | ||
"post_logout_redirect_uris": [ | ||
"https://client.example.org/logout" | ||
], | ||
"grant_types": [ | ||
"refresh_token", | ||
"client_credentials", | ||
"authorization_code" | ||
], | ||
"response_types": [ | ||
"code" | ||
], | ||
"scope": "openid profile email", | ||
"token_endpoint_auth_method": "client_secret_basic", | ||
"id_token_signed_response_alg": "RS256", | ||
"registration_client_uri": "https://authserver.example.org/connect/register?client_id=Q_AQ0wUzbTXo-IE4rNJbU9Dv8BBex1zQrjDeJs0mDbM", | ||
"registration_access_token": "<access-token>", | ||
"client_secret_expires_at": 0 | ||
} | ||
---- | ||
|
||
With the client registered, a `registration_access_token` and a `registration_client_uri` are provided to be able to | ||
read the created client in a follow up request. The next step is optional. | ||
|
||
[[retrieve-client]] | ||
== Retrieve client | ||
|
||
Using fields `registration_access_token` and `registration_client_uri` from the previous step's response, read the client | ||
with the following request: | ||
|
||
[source,console] | ||
---- | ||
curl -X GET --location "<registration_client_uri>" \ | ||
-H "Authorization: Bearer <registration_access_token>" \ | ||
-H "Accept: application/json" | ||
---- | ||
|
||
The response should contain the same information about the client as seen when the client was first registered, with | ||
the exception of `registration_access_token` field. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,94 @@ | ||
package sample.dcr; | ||
|
||
import com.nimbusds.jose.jwk.JWKSet; | ||
import com.nimbusds.jose.jwk.RSAKey; | ||
import com.nimbusds.jose.jwk.source.ImmutableJWKSet; | ||
import com.nimbusds.jose.jwk.source.JWKSource; | ||
import com.nimbusds.jose.proc.SecurityContext; | ||
import org.springframework.context.annotation.Bean; | ||
import org.springframework.context.annotation.Configuration; | ||
import org.springframework.security.config.Customizer; | ||
import org.springframework.security.config.annotation.web.builders.HttpSecurity; | ||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; | ||
import org.springframework.security.core.userdetails.User; | ||
import org.springframework.security.core.userdetails.UserDetails; | ||
import org.springframework.security.core.userdetails.UserDetailsService; | ||
import org.springframework.security.oauth2.jwt.JwtDecoder; | ||
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration; | ||
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer; | ||
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; | ||
import org.springframework.security.provisioning.InMemoryUserDetailsManager; | ||
import org.springframework.security.web.SecurityFilterChain; | ||
|
||
import java.security.KeyPair; | ||
import java.security.KeyPairGenerator; | ||
import java.security.interfaces.RSAPrivateKey; | ||
import java.security.interfaces.RSAPublicKey; | ||
import java.util.UUID; | ||
|
||
@Configuration | ||
@EnableWebSecurity | ||
public class DcrConfiguration { | ||
@Bean // <1> | ||
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { | ||
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); | ||
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) | ||
.oidc(oidc -> oidc.clientRegistrationEndpoint(Customizer.withDefaults())); // <2> | ||
http.oauth2ResourceServer(oauth2ResourceServer -> | ||
oauth2ResourceServer.jwt(Customizer.withDefaults())); | ||
|
||
return http.build(); | ||
} | ||
// @fold:on | ||
|
||
@Bean | ||
public UserDetailsService userDetailsService() { | ||
// @formatter:off | ||
UserDetails userDetails = User.withDefaultPasswordEncoder() | ||
.username("user") | ||
.password("password") | ||
.roles("USER") | ||
.build(); | ||
// @formatter:on | ||
|
||
return new InMemoryUserDetailsManager(userDetails); | ||
} | ||
|
||
@Bean | ||
public JWKSource<SecurityContext> jwkSource() { | ||
KeyPair keyPair = generateRsaKey(); | ||
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); | ||
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); | ||
// @formatter:off | ||
RSAKey rsaKey = new RSAKey.Builder(publicKey) | ||
.privateKey(privateKey) | ||
.keyID(UUID.randomUUID().toString()) | ||
.build(); | ||
// @formatter:on | ||
JWKSet jwkSet = new JWKSet(rsaKey); | ||
return new ImmutableJWKSet<>(jwkSet); | ||
} | ||
|
||
private static KeyPair generateRsaKey() { // <6> | ||
KeyPair keyPair; | ||
try { | ||
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); | ||
keyPairGenerator.initialize(2048); | ||
keyPair = keyPairGenerator.generateKeyPair(); | ||
} catch (Exception ex) { | ||
throw new IllegalStateException(ex); | ||
} | ||
return keyPair; | ||
} | ||
|
||
@Bean | ||
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) { | ||
return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource); | ||
} | ||
|
||
@Bean | ||
public AuthorizationServerSettings authorizationServerSettings() { | ||
return AuthorizationServerSettings.builder().build(); | ||
} | ||
// @fold:off | ||
} |
91 changes: 91 additions & 0 deletions
91
docs/src/main/java/sample/dcr/DynamicClientRegistrationClient.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,91 @@ | ||
package sample.dcr; | ||
|
||
import com.fasterxml.jackson.annotation.JsonProperty; | ||
import org.springframework.http.HttpHeaders; | ||
import org.springframework.http.MediaType; | ||
import org.springframework.security.oauth2.core.AuthorizationGrantType; | ||
import org.springframework.web.reactive.function.client.WebClient; | ||
import reactor.core.publisher.Mono; | ||
|
||
import java.util.List; | ||
import java.util.Objects; | ||
|
||
public class DynamicClientRegistrationClient { | ||
|
||
public static final ClientRegistrationRequest SAMPLE_CLIENT_REGISTRATION_REQUEST = new DynamicClientRegistrationClient.ClientRegistrationRequest( | ||
"client-1", | ||
List.of(AuthorizationGrantType.AUTHORIZATION_CODE.getValue()), | ||
List.of("https://client.example.org/callback", "https://client.example.org/callback2"), | ||
"openid email profile" | ||
); | ||
|
||
private final WebClient webClient; | ||
|
||
public DynamicClientRegistrationClient(final WebClient webClient) { | ||
this.webClient = webClient; | ||
} | ||
|
||
public record ClientRegistrationRequest( | ||
@JsonProperty("client_name") String clientName, | ||
@JsonProperty("grant_types") List<String> grantTypes, | ||
@JsonProperty("redirect_uris") List<String> redirectUris, | ||
String scope) { | ||
} | ||
|
||
public record ClientRegistrationResponse( | ||
@JsonProperty("registration_access_token") String registrationAccessToken, | ||
@JsonProperty("registration_client_uri") String registrationClientUri, | ||
@JsonProperty("client_name") String clientName, | ||
@JsonProperty("grant_types") List<String> grantTypes, | ||
@JsonProperty("redirect_uris") List<String> redirectUris, | ||
String scope) { | ||
} | ||
|
||
public ClientRegistrationResponse registerClient(String initialAccessToken, ClientRegistrationRequest request) { | ||
return this.webClient | ||
.post() | ||
.uri("/connect/register") | ||
.contentType(MediaType.APPLICATION_JSON) | ||
.accept(MediaType.APPLICATION_JSON) | ||
.header(HttpHeaders.AUTHORIZATION, "Bearer %s".formatted(initialAccessToken)) | ||
.body(Mono.just(request), ClientRegistrationRequest.class) | ||
.retrieve() | ||
.bodyToMono(ClientRegistrationResponse.class) | ||
.block(); | ||
} | ||
|
||
public ClientRegistrationResponse retrieveClient(String registrationAccessToken, String registrationClientUri) { | ||
return this.webClient | ||
.get() | ||
.uri(registrationClientUri) | ||
.header(HttpHeaders.AUTHORIZATION, "Bearer %s".formatted(registrationAccessToken)) | ||
.retrieve() | ||
.bodyToMono(ClientRegistrationResponse.class) | ||
.block(); | ||
} | ||
|
||
public void exampleRegistration(String initialAccessToken) { | ||
ClientRegistrationResponse clientRegistrationResponse = this.registerClient(initialAccessToken, SAMPLE_CLIENT_REGISTRATION_REQUEST); | ||
|
||
assert(clientRegistrationResponse.clientName.contentEquals("client-1")); | ||
assert(clientRegistrationResponse.scope.contentEquals("openid profile email")); | ||
assert(clientRegistrationResponse.grantTypes.contains(AuthorizationGrantType.AUTHORIZATION_CODE.getValue())); | ||
assert(clientRegistrationResponse.redirectUris.contains("https://client.example.org/callback")); | ||
assert(clientRegistrationResponse.redirectUris.contains("https://client.example.org/callback2")); | ||
assert(!clientRegistrationResponse.registrationAccessToken.isEmpty()); | ||
assert(!clientRegistrationResponse.registrationClientUri.isEmpty()); | ||
|
||
String registrationAccessToken = clientRegistrationResponse.registrationAccessToken(); | ||
String registrationClientUri = clientRegistrationResponse.registrationClientUri(); | ||
|
||
ClientRegistrationResponse retrievedClient = this.retrieveClient(registrationAccessToken, registrationClientUri); | ||
|
||
assert(retrievedClient.clientName.contentEquals("client-1")); | ||
assert(retrievedClient.scope.contentEquals("openid profile email")); | ||
assert(retrievedClient.grantTypes.contains(AuthorizationGrantType.AUTHORIZATION_CODE.getValue())); | ||
assert(retrievedClient.redirectUris.contains("https://client.example.org/callback")); | ||
assert(retrievedClient.redirectUris.contains("https://client.example.org/callback2")); | ||
assert(Objects.isNull(retrievedClient.registrationAccessToken)); | ||
assert(!retrievedClient.registrationClientUri.isEmpty()); | ||
} | ||
} |
Oops, something went wrong.