对于最新的稳定版本,请使用 Spring Authorization Server 1.3.1! |
对于最新的稳定版本,请使用 Spring Authorization Server 1.3.1! |
本指南介绍如何在 Spring Authorization Server 中配置 OpenID Connect 动态客户端注册,并演练如何注册客户端的示例。 Spring Authorization Server 实现了 OpenID Connect 动态客户端注册 1.0 规范,提供了动态注册和检索 OpenID Connect 客户端的功能。
启用动态客户端注册
默认情况下,在 Spring 授权服务器中禁用动态客户端注册功能。 若要启用,请添加以下配置:
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.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 static sample.registration.CustomClientMetadataConfig.configureCustomClientMetadataConverters;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.oidc(oidc -> oidc.clientRegistrationEndpoint(clientRegistrationEndpoint -> { (1)
clientRegistrationEndpoint
.authenticationProviders(configureCustomClientMetadataConverters()); (2)
}));
http.oauth2ResourceServer(oauth2ResourceServer ->
oauth2ResourceServer.jwt(Customizer.withDefaults()));
return http.build();
}
}
1 | 使用默认配置启用 OpenID Connect 1.0 客户端注册终端节点。 |
2 | (可选)自定义默认值以支持自定义客户端元数据参数。AuthenticationProvider |
为了在注册客户端时支持自定义客户端元数据参数,需要一些额外的实现详细信息。
下面的示例演示了 的示例实现,该实现支持自定义客户端元数据参数 ( 和 ),并在 和 中配置。Converter
logo_uri
contacts
OidcClientRegistrationAuthenticationProvider
OidcClientConfigurationAuthenticationProvider
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;
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.OidcClientConfigurationAuthenticationProvider;
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;
public class CustomClientMetadataConfig {
public static Consumer<List<AuthenticationProvider>> configureCustomClientMetadataConverters() { (1)
List<String> customClientMetadata = List.of("logo_uri", "contacts"); (2)
return (authenticationProviders) -> {
CustomRegisteredClientConverter registeredClientConverter =
new CustomRegisteredClientConverter(customClientMetadata);
CustomClientRegistrationConverter clientRegistrationConverter =
new CustomClientRegistrationConverter(customClientMetadata);
authenticationProviders.forEach((authenticationProvider) -> {
if (authenticationProvider instanceof OidcClientRegistrationAuthenticationProvider provider) {
provider.setRegisteredClientConverter(registeredClientConverter); (3)
provider.setClientRegistrationConverter(clientRegistrationConverter); (4)
}
if (authenticationProvider instanceof OidcClientConfigurationAuthenticationProvider provider) {
provider.setClientRegistrationConverter(clientRegistrationConverter); (5)
}
});
};
}
private static class CustomRegisteredClientConverter
implements Converter<OidcClientRegistration, RegisteredClient> {
private final List<String> customClientMetadata;
private final OidcClientRegistrationRegisteredClientConverter delegate;
private CustomRegisteredClientConverter(List<String> customClientMetadata) {
this.customClientMetadata = customClientMetadata;
this.delegate = new OidcClientRegistrationRegisteredClientConverter();
}
@Override
public RegisteredClient convert(OidcClientRegistration clientRegistration) {
RegisteredClient registeredClient = this.delegate.convert(clientRegistration);
ClientSettings.Builder clientSettingsBuilder = ClientSettings.withSettings(
registeredClient.getClientSettings().getSettings());
if (!CollectionUtils.isEmpty(this.customClientMetadata)) {
clientRegistration.getClaims().forEach((claim, value) -> {
if (this.customClientMetadata.contains(claim)) {
clientSettingsBuilder.setting(claim, value);
}
});
}
return RegisteredClient.from(registeredClient)
.clientSettings(clientSettingsBuilder.build())
.build();
}
}
private static class CustomClientRegistrationConverter
implements Converter<RegisteredClient, OidcClientRegistration> {
private final List<String> customClientMetadata;
private final RegisteredClientOidcClientRegistrationConverter delegate;
private CustomClientRegistrationConverter(List<String> customClientMetadata) {
this.customClientMetadata = customClientMetadata;
this.delegate = new RegisteredClientOidcClientRegistrationConverter();
}
@Override
public OidcClientRegistration convert(RegisteredClient registeredClient) {
OidcClientRegistration clientRegistration = this.delegate.convert(registeredClient);
Map<String, Object> claims = new HashMap<>(clientRegistration.getClaims());
if (!CollectionUtils.isEmpty(this.customClientMetadata)) {
ClientSettings clientSettings = registeredClient.getClientSettings();
claims.putAll(this.customClientMetadata.stream()
.filter(metadata -> clientSettings.getSetting(metadata) != null)
.collect(Collectors.toMap(Function.identity(), clientSettings::getSetting)));
}
return OidcClientRegistration.withClaims(claims).build();
}
}
}
1 | 定义一个提供自定义默认值的功能。Consumer<List<AuthenticationProvider>> AuthenticationProvider |
2 | 定义客户端注册支持的自定义客户端元数据参数。 |
3 | 使用 .OidcClientRegistrationAuthenticationProvider.setRegisteredClientConverter() CustomRegisteredClientConverter |
4 | 使用 .OidcClientRegistrationAuthenticationProvider.setClientRegistrationConverter() CustomClientRegistrationConverter |
5 | 使用 .OidcClientConfigurationAuthenticationProvider.setClientRegistrationConverter() CustomClientRegistrationConverter |
1 | 使用默认配置启用 OpenID Connect 1.0 客户端注册终端节点。 |
2 | (可选)自定义默认值以支持自定义客户端元数据参数。AuthenticationProvider |
1 | 定义一个提供自定义默认值的功能。Consumer<List<AuthenticationProvider>> AuthenticationProvider |
2 | 定义客户端注册支持的自定义客户端元数据参数。 |
3 | 使用 .OidcClientRegistrationAuthenticationProvider.setRegisteredClientConverter() CustomRegisteredClientConverter |
4 | 使用 .OidcClientRegistrationAuthenticationProvider.setClientRegistrationConverter() CustomClientRegistrationConverter |
5 | 使用 .OidcClientConfigurationAuthenticationProvider.setClientRegistrationConverter() CustomClientRegistrationConverter |
配置客户端注册器
现有客户端用于向授权服务器注册新客户端。
必须为客户端配置作用域,并可选择分别用于注册客户端和检索客户端。
以下清单显示了一个示例客户端:client.create
client.read
import java.util.UUID;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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;
@Configuration
public class ClientConfig {
@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) (1)
.scope("client.create") (2)
.scope("client.read") (3)
.build();
return new InMemoryRegisteredClientRepository(registrarClient);
}
}
1 | client_credentials 授权类型配置为直接获取访问令牌。 |
2 | client.create 范围配置为允许客户端注册新客户端。 |
3 | client.read 作用域配置为允许客户端检索已注册的客户端。 |
1 | client_credentials 授权类型配置为直接获取访问令牌。 |
2 | client.create 范围配置为允许客户端注册新客户端。 |
3 | client.read 作用域配置为允许客户端检索已注册的客户端。 |
获取初始访问令牌
客户端注册请求需要“初始”访问令牌。
访问令牌请求必须仅包含参数值。scope
client.create
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
客户端注册请求需要具有单个作用域的访问令牌。
如果访问令牌包含其他范围,则客户端注册请求将被拒绝。 |
要获取上述请求的编码凭据,请按 格式对客户端凭据进行编码。
下面是本指南中示例的编码操作。
|
客户端注册请求需要具有单个作用域的访问令牌。
如果访问令牌包含其他范围,则客户端注册请求将被拒绝。 |
要获取上述请求的编码凭据,请按 格式对客户端凭据进行编码。
下面是本指南中示例的编码操作。
|
注册客户端
使用从上一步获取的访问令牌,现在可以动态注册客户端。
“初始”访问令牌只能使用一次。 注册客户端后,访问令牌失效。 |
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 {
private final WebClient webClient;
public ClientRegistrar(WebClient webClient) {
this.webClient = webClient;
}
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) {
}
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.clientName().contentEquals("client-1")); (6)
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());
assert (clientRegistrationResponse.logoUri().contentEquals("https://client.example.org/logo"));
assert (clientRegistrationResponse.contacts().size() == 2);
assert (clientRegistrationResponse.contacts().contains("contact-1"));
assert (clientRegistrationResponse.contacts().contains("contact-2"));
String registrationAccessToken = clientRegistrationResponse.registrationAccessToken(); (7)
String registrationClientUri = clientRegistrationResponse.registrationClientUri();
ClientRegistrationResponse retrievedClient = retrieveClient(registrationAccessToken, registrationClientUri); (8)
assert (retrievedClient.clientName().contentEquals("client-1")); (9)
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 (retrievedClient.logoUri().contentEquals("https://client.example.org/logo"));
assert (retrievedClient.contacts().size() == 2);
assert (retrievedClient.contacts().contains("contact-1"));
assert (retrievedClient.contacts().contains("contact-2"));
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();
}
}
1 | 客户端注册请求的最小表示形式。您可以根据客户端注册请求添加其他客户端元数据参数。此示例请求包含自定义客户端元数据参数和 .logo_uri contacts |
2 | 客户端注册响应的最小表示形式。您可以根据客户端注册响应添加其他客户端元数据参数。此示例响应包含自定义客户端元数据参数和 .logo_uri contacts |
3 | 演示客户端注册和客户端检索的示例。 |
4 | 示例客户端注册请求对象。 |
5 | 使用“初始”访问令牌和客户端注册请求对象注册客户端。 |
6 | 成功注册后,对应在响应中填充的客户端元数据参数进行断言。 |
7 | 提取和响应参数,用于检索新注册的客户端。registration_access_token registration_client_uri |
8 | 使用 和 检索客户端。registration_access_token registration_client_uri |
9 | 客户端检索后,对应在响应中填充的客户端元数据参数进行断言。 |
10 | 使用 .WebClient |
11 | 使用 的示例客户端读取请求。WebClient |
“初始”访问令牌只能使用一次。 注册客户端后,访问令牌失效。 |
1 | 客户端注册请求的最小表示形式。您可以根据客户端注册请求添加其他客户端元数据参数。此示例请求包含自定义客户端元数据参数和 .logo_uri contacts |
2 | 客户端注册响应的最小表示形式。您可以根据客户端注册响应添加其他客户端元数据参数。此示例响应包含自定义客户端元数据参数和 .logo_uri contacts |
3 | 演示客户端注册和客户端检索的示例。 |
4 | 示例客户端注册请求对象。 |
5 | 使用“初始”访问令牌和客户端注册请求对象注册客户端。 |
6 | 成功注册后,对应在响应中填充的客户端元数据参数进行断言。 |
7 | 提取和响应参数,用于检索新注册的客户端。registration_access_token registration_client_uri |
8 | 使用 和 检索客户端。registration_access_token registration_client_uri |
9 | 客户端检索后,对应在响应中填充的客户端元数据参数进行断言。 |
10 | 使用 .WebClient |
11 | 使用 的示例客户端读取请求。WebClient |
客户端读取响应应包含与客户端注册响应相同的客户端元数据参数,但参数除外。registration_access_token |