对于最新的稳定版本,请使用 Spring Authorization Server 1.3.1Spring中文文档

对于最新的稳定版本,请使用 Spring Authorization Server 1.3.1Spring中文文档

本指南介绍如何在 Spring Authorization Server 中配置 OpenID Connect 动态客户端注册,并演练如何注册客户端的示例。 Spring Authorization Server 实现了 OpenID Connect 动态客户端注册 1.0 规范,提供了动态注册和检索 OpenID Connect 客户端的功能。Spring中文文档

启用动态客户端注册

默认情况下,在 Spring 授权服务器中禁用动态客户端注册功能。 若要启用,请添加以下配置: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

为了在注册客户端时支持自定义客户端元数据参数,需要一些额外的实现详细信息。Spring中文文档

下面的示例演示了 的示例实现,该实现支持自定义客户端元数据参数 ( 和 ),并在 和 中配置。Converterlogo_uricontactsOidcClientRegistrationAuthenticationProviderOidcClientConfigurationAuthenticationProviderSpring中文文档

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.createclient.readSpring中文文档

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作用域配置为允许客户端检索已注册的客户端。

获取初始访问令牌

客户端注册请求需要“初始”访问令牌。 访问令牌请求必须仅包含参数值。scopeclient.createSpring中文文档

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

客户端注册请求需要具有单个作用域的访问令牌。 如果访问令牌包含其他范围,则客户端注册请求将被拒绝。client.createSpring中文文档

要获取上述请求的编码凭据,请按 格式对客户端凭据进行编码。 下面是本指南中示例的编码操作。base64<clientId>:<clientSecret>Spring中文文档

echo -n "registrar-client:secret" | base64

客户端注册请求需要具有单个作用域的访问令牌。 如果访问令牌包含其他范围,则客户端注册请求将被拒绝。client.createSpring中文文档

要获取上述请求的编码凭据,请按 格式对客户端凭据进行编码。 下面是本指南中示例的编码操作。base64<clientId>:<clientSecret>Spring中文文档

echo -n "registrar-client:secret" | base64

注册客户端

使用从上一步获取的访问令牌,现在可以动态注册客户端。Spring中文文档

“初始”访问令牌只能使用一次。 注册客户端后,访问令牌失效。
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_uricontacts
2 客户端注册响应的最小表示形式。您可以根据客户端注册响应添加其他客户端元数据参数。此示例响应包含自定义客户端元数据参数和 .logo_uricontacts
3 演示客户端注册和客户端检索的示例。
4 示例客户端注册请求对象。
5 使用“初始”访问令牌和客户端注册请求对象注册客户端。
6 成功注册后,对应在响应中填充的客户端元数据参数进行断言。
7 提取和响应参数,用于检索新注册的客户端。registration_access_tokenregistration_client_uri
8 使用 和 检索客户端。registration_access_tokenregistration_client_uri
9 客户端检索后,对应在响应中填充的客户端元数据参数进行断言。
10 使用 .WebClient
11 使用 的示例客户端读取请求WebClient
客户端读取响应应包含与客户端注册响应相同的客户端元数据参数,但参数除外。registration_access_token
“初始”访问令牌只能使用一次。 注册客户端后,访问令牌失效。
1 客户端注册请求的最小表示形式。您可以根据客户端注册请求添加其他客户端元数据参数。此示例请求包含自定义客户端元数据参数和 .logo_uricontacts
2 客户端注册响应的最小表示形式。您可以根据客户端注册响应添加其他客户端元数据参数。此示例响应包含自定义客户端元数据参数和 .logo_uricontacts
3 演示客户端注册和客户端检索的示例。
4 示例客户端注册请求对象。
5 使用“初始”访问令牌和客户端注册请求对象注册客户端。
6 成功注册后,对应在响应中填充的客户端元数据参数进行断言。
7 提取和响应参数,用于检索新注册的客户端。registration_access_tokenregistration_client_uri
8 使用 和 检索客户端。registration_access_tokenregistration_client_uri
9 客户端检索后,对应在响应中填充的客户端元数据参数进行断言。
10 使用 .WebClient
11 使用 的示例客户端读取请求WebClient
客户端读取响应应包含与客户端注册响应相同的客户端元数据参数,但参数除外。registration_access_token