1. 引言

Spring授权服务器默认提供了一系列合理的配置,几乎无需额外配置即可使用。这使得它在测试场景或需要完全控制用户登录体验时成为理想选择。

虽然动态客户端注册功能已存在,但默认并未启用。本教程将展示如何从客户端应用中启用并使用该功能。

2. 为什么需要动态注册?

当基于OAuth2的应用客户端(或OIDC术语中的依赖方RP)启动认证流程时,它会向身份提供者发送自己的客户端标识符。

通常这个标识符是通过带外流程颁发的,然后添加到配置中供需要时使用。

例如,使用Azure EntraID或Auth0等流行身份提供者时,可通过管理控制台或API配置新客户端。过程中需要提供应用名称、授权回调URL、支持的作用域等信息。

提供所需信息后,将获得新的客户端标识符,对于所谓的"机密"客户端还会获得客户端密钥。将这些信息添加到应用配置后即可部署。

当应用数量较少或始终使用单一身份提供者时,此流程运行良好。但在复杂场景中,注册过程需要动态化,这正是OpenID Connect动态客户端注册规范发挥作用的地方。

现实案例中,英国的OpenBanking标准就是一个典型,它将动态客户端注册作为核心协议之一。

3. 动态注册如何工作?

OpenID Connect标准使用单一注册URL供客户端注册。客户端通过发送包含客户端元数据的JSON对象的POST请求完成注册。

重要提示:访问注册接口需要认证,通常使用Bearer令牌。这自然引出一个问题:新客户端如何获取此操作的令牌?

遗憾的是,答案并不明确。一方面规范说明该接口是受保护资源,需要某种认证;另一方面又提到开放注册接口的可能性。

在Spring授权服务器中,注册需要带有client.create作用域的Bearer令牌。创建此令牌需使用常规OAuth2令牌接口和基本凭据。

成功注册的流程如下:

动态注册流程

客户端完成注册后,即可使用返回的客户端ID和密钥执行任何标准授权流程。

4. 实现动态注册

理解必要步骤后,我们使用两个Spring Boot应用创建测试场景:一个托管Spring授权服务器,另一个是使用Spring Security OAuth2登录模块的简单WebMVC应用。

后者不使用常规静态客户端配置,而是在启动时通过动态注册接口获取客户端标识符和密钥。

先从服务器端开始。

5. 授权服务器实现

首先添加所需Maven依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
    <version>1.3.1</version>
</dependency>

最新版本可在Maven Central获取。

对于常规Spring授权服务器应用,此依赖已足够。但出于安全考虑,动态注册默认未启用。且截至撰写本文时,无法仅通过配置属性启用。

这意味着我们必须添加一些代码——终于来了。

5.1. 启用动态注册

OAuth2AuthorizationServerConfigurer是配置授权服务器所有方面的入口,包括注册接口。此配置应在创建SecurityFilterChain Bean时完成:

@Configuration
@EnableConfigurationProperties(SecurityConfig.RegistrationProperties.class)
public class SecurityConfig {
    @Bean
    @Order(1)
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
        http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
          .oidc(oidc -> {
              oidc.clientRegistrationEndpoint(Customizer.withDefaults());
          });

        http.exceptionHandling((exceptions) -> exceptions
          .defaultAuthenticationEntryPointFor(
            new LoginUrlAuthenticationEntryPoint("/login"),
            new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
          )
        );

        http.oauth2ResourceServer((resourceServer) -> resourceServer
            .jwt(Customizer.withDefaults()));

        return http.build();
    }

    // ... 其他Bean省略
}

这里使用服务器的oidc()方法获取OidcConfigurer实例。该子配置器提供控制OpenID Connect标准相关接口的方法。启用注册接口需使用clientRegistrationEndpoint()方法并采用默认配置。这将在/connect/register路径启用注册,使用Bearer令牌授权。其他配置选项包括:

  • 定义自定义认证
  • 自定义接收注册数据的处理
  • 自定义发送给客户端的响应处理

由于我们提供了自定义SecurityFilterChain,Spring Boot的自动配置将退让,由我们负责添加额外配置。

特别需要添加表单登录认证逻辑:

@Bean
@Order(2)
SecurityFilterChain loginFilterChain(HttpSecurity http) throws Exception {
    return http.authorizeHttpRequests(r -> r.anyRequest().authenticated())
      .formLogin(Customizer.withDefaults())
      .build();
}

5.2. 注册客户端配置

如前所述,注册机制本身要求客户端发送Bearer令牌。Spring授权服务器通过要求客户端使用客户端凭据流程生成此令牌来解决这个"先有鸡还是先有蛋"的问题。

此令牌请求所需作用域为client.create,且客户端必须使用服务器支持的认证方案。这里使用基本凭据,但实际场景中可使用其他方法。

从授权服务器角度看,此注册客户端只是另一个客户端。因此我们使用RegisteredClient流式API创建:

@Bean
public RegisteredClientRepository registeredClientRepository(RegistrationProperties props) {
    RegisteredClient registrarClient = RegisteredClient.withId(UUID.randomUUID().toString())
      .clientId(props.getRegistrarClientId())
      .clientSecret(props.getRegistrarClientSecret())
      .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
      .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
      .clientSettings(ClientSettings.builder()
        .requireProofKey(false)
        .requireAuthorizationConsent(false)
        .build())
      .scope("client.create")
      .scope("client.read")
      .build();

    RegisteredClientRepository delegate = new  InMemoryRegisteredClientRepository(registrarClient);
    return new CustomRegisteredClientRepository(delegate);
}

我们使用@ConfigurationProperties类,通过Spring标准Environment机制配置客户端ID和密钥属性。

此引导注册将是启动时创建的唯一注册。在返回前将其添加到自定义RegisteredClientRepository

5.3. 自定义RegisteredClientRepository

Spring授权服务器使用配置的RegisteredClientRepository实现存储所有注册客户端。开箱即用提供内存和JDBC实现,覆盖基本用例。

但这些实现在保存前无法自定义注册。我们希望修改默认ClientProperties设置,使得授权用户时无需同意或PKCE

我们的实现将大多数方法委托给构造时传入的实际存储库。重要例外是save()方法:

@Override
public void save(RegisteredClient registeredClient) {
    Set<String> scopes = ( registeredClient.getScopes() == null || registeredClient.getScopes().isEmpty())?
      Set.of("openid","email","profile"):
      registeredClient.getScopes();

    // 禁用PKCE和同意
    RegisteredClient modifiedClient = RegisteredClient.from(registeredClient)
      .scopes(s -> s.addAll(scopes))
      .clientSettings(ClientSettings
        .withSettings(registeredClient.getClientSettings().getSettings())
        .requireAuthorizationConsent(false)
        .requireProofKey(false)
        .build())
      .build();

    delegate.save(modifiedClient);
}

**这里我们基于接收的客户端创建新RegisteredClient,按需修改ClientSettings**。然后将新注册传递给后端存储。

至此服务器实现完成。现在转向客户端。

6. 动态注册客户端实现

客户端也是标准Spring Web MVC应用,包含显示当前用户信息的页面。Spring Security(更具体说是OAuth2登录模块)将处理所有安全方面。

先添加所需Maven依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <version>3.3.2</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
    <version>3.3.2</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-client</artifactId>
    <version>3.3.2</version>
</dependency>

这些依赖的最新版本可在Maven Central获取:

6.1. 安全配置

默认情况下,Spring Boot的自动配置机制从可用PropertySources收集数据创建一个或多个ClientRegistration实例,存储在基于内存的ClientRegistrationRepository中。

例如给定此application.yaml

spring:
  security:
    oauth2:
      client:
        provider:
          spring-auth-server:
            issuer-uri: http://localhost:8080
        registration:
          test-client:
            provider: spring-auth-server
            client-name: test-client
            client-id: xxxxx
            client-secret: yyyy
            authorization-grant-type:
              - authorization_code
              - refresh_token
              - client_credentials
            scope:
              - openid
              - email
              - profile

Spring将创建名为test-clientClientRegistration并传递给存储库。

后续需要启动认证流程时,OAuth2引擎查询此存储库并通过注册标识符(本例中为test-client)恢复注册。

关键点在于此时授权服务器应已知晓返回的ClientRegistration这意味着要支持动态客户端,必须实现替代存储库并将其暴露为@Bean

这样做后,Spring Boot自动配置将自动使用它替代默认实现。

6.2. 动态客户端注册存储库

如预期,我们的实现必须实现ClientRegistrationRepository接口,仅包含单个方法:findByRegistrationId()。这引出一个问题:OAuth2引擎如何知道有哪些可用注册?毕竟它可以在默认登录页列出它们。

实际上,Spring Security期望存储库同时实现Iterable<ClientRegistration>以便枚举可用客户端:

public class DynamicClientRegistrationRepository implements ClientRegistrationRepository, Iterable<ClientRegistration> {
    private final RegistrationDetails registrationDetails;
    private final Map<String, ClientRegistration> staticClients;
    private final RegistrationRestTemplate registrationClient;
    private final Map<String, ClientRegistration> registrations = new HashMap<>();

    // ... 实现省略
}

我们的类需要几个输入才能工作:

  • 包含执行动态注册所需参数的RegistrationDetails记录
  • 将动态注册的客户端Map
  • 用于访问授权服务器的RestTemplate

注意:本示例假设所有客户端将在同一授权服务器注册。

另一个重要设计决策是定义动态注册的执行时机。这里采用简单方法,暴露公共doRegistrations()方法注册所有已知客户端,并保存返回的客户端ID和密钥供后续使用:

public void doRegistrations() {
    staticClients.forEach((key, value) -> findByRegistrationId(key));
}

实现为构造时传入的每个静态客户端调用findByRegistrationId()。此方法检查给定标识符是否有有效注册,若缺失则触发实际注册流程。

6.3. 执行动态注册

doRegistration()函数是真正执行操作的地方:

private ClientRegistration doRegistration(String registrationId) {
    String token = createRegistrationToken();
    var staticRegistration = staticClients.get(registrationId);

    var body = Map.of(
      "client_name", staticRegistration.getClientName(),
      "grant_types", List.of(staticRegistration.getAuthorizationGrantType()),
      "scope", String.join(" ", staticRegistration.getScopes()),
      "redirect_uris", List.of(resolveCallbackUri(staticRegistration)));

    var headers = new HttpHeaders();
    headers.setBearerAuth(token);
    headers.setContentType(MediaType.APPLICATION_JSON);

    var request = new RequestEntity<>(
      body,
      headers,
      HttpMethod.POST,
      registrationDetails.registrationEndpoint());

    var response = registrationClient.exchange(request, ObjectNode.class);
    // ... 错误处理省略
    return createClientRegistration(staticRegistration, response.getBody());
}

首先必须获取调用注册接口所需的注册令牌。注意每次注册尝试都需要获取新令牌,因为如Spring授权服务器文档所述,此令牌只能使用一次。

接着使用静态注册对象的数据构建注册负载,添加必需的authorizationcontent-type头,向注册接口发送请求。

最后使用响应数据创建最终ClientRegistration,保存在存储库缓存中并返回给OAuth2引擎。

6.4. 注册动态存储库@Bean

完成客户端的最后一步是将DynamicClientRegistrationRepository暴露为@Bean。创建@Configuration类:

@Bean
ClientRegistrationRepository dynamicClientRegistrationRepository( DynamicClientRegistrationRepository.RegistrationRestTemplate restTemplate) {
    var registrationDetails = new DynamicClientRegistrationRepository.RegistrationDetails(
      registrationProperties.getRegistrationEndpoint(),
      registrationProperties.getRegistrationUsername(),
      registrationProperties.getRegistrationPassword(),
      registrationProperties.getRegistrationScopes(),
      registrationProperties.getGrantTypes(),
      registrationProperties.getRedirectUris(),
      registrationProperties.getTokenEndpoint());

    Map<String,ClientRegistration> staticClients = (new OAuth2ClientPropertiesMapper(clientProperties)).asClientRegistrations();
    var repo =  new DynamicClientRegistrationRepository(registrationDetails, staticClients, restTemplate);
    repo.doRegistrations();
    return repo;
}

@Bean注解的dynamicClientRegistrationRepository()方法首先从可用属性填充RegistrationDetails记录创建存储库。

其次利用Spring Boot自动配置模块中的OAuth2ClientPropertiesMapper类创建staticClient映射。这种方法允许我们以最小代价在静态和动态客户端间切换,因为两者的配置结构相同。

7. 测试

最后进行集成测试。首先启动配置为监听8080端口的服务器应用:

[ server ] $ mvn spring-boot:run
... 大量消息省略
[           main] c.b.s.s.a.AuthorizationServerApplication : Started AuthorizationServerApplication in 2.222 seconds (process running for 2.454)
[           main] o.s.b.a.ApplicationAvailabilityBean      : Application availability state LivenessState changed to CORRECT
[           main] o.s.b.a.ApplicationAvailabilityBean      : Application availability state ReadinessState changed to ACCEPTING_TRAFFIC

然后在另一个shell启动客户端:

[client] $ mvn spring-boot:run
// ... 大量消息省略
[  restartedMain] o.s.b.d.a.OptionalLiveReloadServer       : LiveReload server is running on port 35729
[  restartedMain] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8090 (http) with context path ''
[  restartedMain] d.c.DynamicRegistrationClientApplication : Started DynamicRegistrationClientApplication in 2.063 seconds (process running for 2.425)

两个应用都启用了调试属性,因此会产生大量日志消息。特别是可以看到对授权服务器/connect/register接口的调用:

[nio-8080-exec-3] o.s.security.web.FilterChainProxy        : Securing POST /connect/register
// ... 大量消息省略
[nio-8080-exec-3] ClientRegistrationAuthenticationProvider : Retrieved authorization with initial access token
[nio-8080-exec-3] ClientRegistrationAuthenticationProvider : Validated client registration request parameters
[nio-8080-exec-3] s.s.a.r.CustomRegisteredClientRepository : Saving registered client: id=30OTlhO1Fb7UF110YdXULEDbFva4Uc8hPBGMfi60Wik, name=test-client

客户端侧可看到包含注册标识符(test-client)和对应client_id的消息:

[  restartedMain] s.d.c.c.OAuth2DynamicClientConfiguration : Creating a dynamic client registration repository
[  restartedMain] .c.s.DynamicClientRegistrationRepository : findByRegistrationId: test-client
[  restartedMain] .c.s.DynamicClientRegistrationRepository : doRegistration: registrationId=test-client
[  restartedMain] .c.s.DynamicClientRegistrationRepository : creating ClientRegistration: registrationId=test-client, client_id=30OTlhO1Fb7UF110YdXULEDbFva4Uc8hPBGMfi60Wik

若打开浏览器访问http://localhost:8090,将被重定向到登录页。注意地址栏URL变为http://localhost:8080,说明此页面来自授权服务器。

测试凭据为user1/password。输入并提交后,将返回客户端主页。由于已认证,会看到包含授权令牌提取信息的页面。

8. 结论

本教程展示了如何启用Spring授权服务器的动态注册功能,并在基于Spring Security的客户端应用中使用。

所有代码可在GitHub获取。


原始标题:Dynamic Client Registration in Spring Authorization Server | Baeldung