Registering Clients

A Public SPA Client and a Service Client

@Bean
RegisteredClientRepository registeredClientRepository(PasswordEncoder encoder) {
    RegisteredClient reactApp = RegisteredClient.withId(UUID.randomUUID().toString())
            .clientId("shopease-web")
            .clientAuthenticationMethod(ClientAuthenticationMethod.NONE)       // public client, no secret
            .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
            .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
            .redirectUri("http://localhost:5173/callback")
            .postLogoutRedirectUri("http://localhost:5173/")
            .scope(OidcScopes.OPENID)
            .scope(OidcScopes.PROFILE)
            .scope("orders.read")
            .scope("orders.write")
            .clientSettings(ClientSettings.builder()
                    .requireProofKey(true)                  // PKCE is mandatory
                    .requireAuthorizationConsent(false)     // first-party app: skip consent screen
                    .build())
            .tokenSettings(TokenSettings.builder()
                    .accessTokenTimeToLive(Duration.ofMinutes(10))
                    .refreshTokenTimeToLive(Duration.ofDays(7))
                    .reuseRefreshTokens(false)              // rotate refresh tokens
                    .build())
            .build();

    RegisteredClient orderService = RegisteredClient.withId(UUID.randomUUID().toString())
            .clientId("order-service")
            .clientSecret(encoder.encode(System.getenv("ORDER_SERVICE_SECRET")))
            .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
            .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
            .scope("payments.write")
            .scope("inventory.reserve")
            .tokenSettings(TokenSettings.builder().accessTokenTimeToLive(Duration.ofMinutes(5)).build())
            .build();

    return new InMemoryRegisteredClientRepository(reactApp, orderService);
}

Tip: In production use JdbcRegisteredClientRepository, JdbcOAuth2AuthorizationService and JdbcOAuth2AuthorizationConsentService. The SQL schema files ship inside the SAS jar under org/springframework/security/oauth2/server/authorization/. With in-memory storage, all issued refresh tokens are lost on restart.

Client Credentials in Action

Request a service token

curl -s -u order-service:$ORDER_SERVICE_SECRET \
  -d grant_type=client_credentials -d scope=payments.write \
  http://localhost:9000/oauth2/token

Example output

{
  "access_token": "eyJraWQiOiJzaG9wZWFzZS0yMDI2LTAzIiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiJvcmRlci1zZXJ2aWNlIiwiYXVkIjoib3JkZXItc2VydmljZSIsIm5iZiI6MTc3MjM0MDAwMCwic2NvcGUiOlsicGF5bWVudHMud3JpdGUiXSwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo5MDAwIiwiZXhwIjoxNzcyMzQwMzAwLCJpYXQiOjE3NzIzNDAwMDB9.Qm9...",
  "scope": "payments.write",
  "token_type": "Bearer",
  "expires_in": 299
}

Properties-Only Registration

application.yml

Spring Boot can also register clients from configuration, which is handy for small setups:

spring:
  security:
    oauth2:
      authorizationserver:
        issuer: http://localhost:9000
        client:
          shopease-web:
            registration:
              client-id: shopease-web
              client-authentication-methods: none
              authorization-grant-types: authorization_code, refresh_token
              redirect-uris: http://localhost:5173/callback
              scopes: openid, profile, orders.read, orders.write
            require-proof-key: true