Service-to-Service with Client Credentials

Configuration

When order-service calls payment-service, it uses its own identity. Spring Security fetches, caches and renews the client-credentials token automatically:

order-service application.yml

spring:
  security:
    oauth2:
      client:
        registration:
          payment-service:
            provider: keycloak
            client-id: order-service
            client-secret: ${ORDER_SERVICE_CLIENT_SECRET}
            authorization-grant-type: client_credentials
            scope: payments.write
        provider:
          keycloak:
            issuer-uri: http://keycloak:8080/realms/shopease

RestClient with an OAuth2 Interceptor

@Configuration
public class PaymentClientConfig {

    @Bean
    OAuth2AuthorizedClientManager authorizedClientManager(ClientRegistrationRepository registrations,
                                                          OAuth2AuthorizedClientService clientService) {
        OAuth2AuthorizedClientProvider provider = OAuth2AuthorizedClientProviderBuilder.builder()
                .clientCredentials()
                .build();
        // works outside an HTTP request too (schedulers, Kafka listeners)
        var manager = new AuthorizedClientServiceOAuth2AuthorizedClientManager(registrations, clientService);
        manager.setAuthorizedClientProvider(provider);
        return manager;
    }

    @Bean
    RestClient paymentRestClient(RestClient.Builder builder, OAuth2AuthorizedClientManager manager) {
        OAuth2ClientHttpRequestInterceptor oauth2 = new OAuth2ClientHttpRequestInterceptor(manager);
        oauth2.setClientRegistrationIdResolver(request -> "payment-service");
        return builder
                .baseUrl("http://payment-service")
                .requestInterceptor(oauth2)          // adds Authorization: Bearer <service token>
                .build();
    }
}

@Service
public class PaymentGateway {
    private final RestClient paymentRestClient;

    public PaymentGateway(RestClient paymentRestClient) { this.paymentRestClient = paymentRestClient; }

    public PaymentResult charge(Long orderId, BigDecimal amount) {
        return paymentRestClient.post().uri("/api/payments")
                .body(new ChargeRequest(orderId, amount, "INR"))
                .retrieve()
                .body(PaymentResult.class);
    }
}

Note: OAuth2ClientHttpRequestInterceptor is available from Spring Security 6.4. On WebClient use ServletOAuth2AuthorizedClientExchangeFilterFunction; with OpenFeign, add a RequestInterceptor that calls manager.authorize(...).

payment-service Protects Itself

http.authorizeHttpRequests(auth -> auth
        .requestMatchers(HttpMethod.POST, "/api/payments").hasAuthority("SCOPE_payments.write")
        .anyRequest().authenticated())
    .oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()));

Wrong token type

A user token (no payments.write) is rejected even if it reaches payment-service:

curl -i -X POST http://payment-service/api/payments -H "Authorization: Bearer $ASHA_TOKEN" -d "{}"

Example output

HTTP/1.1 403
WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"