The Client Credentials flow is used for machine-to-machine authentication where the client application acts on its own behalf rather than on behalf of a user.
1. Spring Security OAuth2 Client Setup
Dependencies (pom.xml)
<dependencies> <!-- Spring Boot Starter Web --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- Spring Security OAuth2 Client --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-client</artifactId> </dependency> <!-- Spring Security OAuth2 Resource Server --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-resource-server</artifactId> </dependency> <!-- Spring Security Configuration --> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-config</artifactId> </dependency> <!-- JWT Support --> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-oauth2-jose</artifactId> </dependency> <!-- HTTP Client --> <dependency> <groupId>org.apache.httpcomponents.client5</groupId> <artifactId>httpclient5</artifactId> </dependency> <!-- JSON Processing --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> </dependency> <!-- Configuration Processor --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-configuration-processor</artifactId> <optional>true</optional> </dependency> </dependencies>
Application Configuration (application.yml)
spring: application: name: oauth2-client-service security: oauth2: client: registration: my-api-client: client-id: ${CLIENT_ID:test-client} client-secret: ${CLIENT_SECRET:test-secret} authorization-grant-type: client_credentials scope: read,write client-authentication-method: client_secret_basic provider: my-auth-server: token-uri: ${TOKEN_URI:http://localhost:8081/oauth2/token} jwk-set-uri: ${JWK_SET_URI:http://localhost:8081/oauth2/jwks} # Custom OAuth2 configuration app: oauth2: client: connect-timeout: 5000 read-timeout: 10000 token-refresh-buffer: 30000 # Refresh token 30 seconds before expiry # Server configuration server: port: 8080 servlet: context-path: /api # Logging logging: level: org.springframework.security: DEBUG com.example.oauth2: DEBUG 2. OAuth2 Configuration Classes
OAuth2 Client Properties
@ConfigurationProperties(prefix = "app.oauth2.client") @Data public class OAuth2ClientProperties { private int connectTimeout = 5000; private int readTimeout = 10000; private int tokenRefreshBuffer = 30000; // milliseconds private String tokenUri; private String clientId; private String clientSecret; private List<String> scopes = List.of("read", "write"); } Security Configuration
@Configuration @EnableWebSecurity @EnableConfigurationProperties(OAuth2ClientProperties.class) public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) .authorizeHttpRequests(authz -> authz .requestMatchers("/public/**").permitAll() .requestMatchers("/actuator/health").permitAll() .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 -> oauth2 .jwt(jwt -> jwt .jwtAuthenticationConverter(jwtAuthenticationConverter()) ) ) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ); return http.build(); } @Bean public JwtAuthenticationConverter jwtAuthenticationConverter() { JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter(); grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_"); grantedAuthoritiesConverter.setAuthoritiesClaimName("roles"); JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter(); jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter); return jwtAuthenticationConverter; } @Bean public JwtDecoder jwtDecoder() { return NimbusJwtDecoder.withJwkSetUri("http://localhost:8081/oauth2/jwks").build(); } } 3. OAuth2 Client Service Implementation
Token Response DTOs
@Data public class OAuth2TokenResponse { @JsonProperty("access_token") private String accessToken; @JsonProperty("token_type") private String tokenType; @JsonProperty("expires_in") private Long expiresIn; @JsonProperty("scope") private String scope; @JsonProperty("refresh_token") private String refreshToken; // Not typically in client_credentials flow private LocalDateTime issuedAt; private LocalDateTime expiresAt; @JsonIgnore public boolean isExpired() { return expiresAt != null && LocalDateTime.now().isAfter(expiresAt); } @JsonIgnore public boolean willExpireSoon() { return expiresAt != null && LocalDateTime.now().isAfter(expiresAt.minusSeconds(30)); } @JsonProperty("expires_in") public void setExpiresIn(Long expiresIn) { this.expiresIn = expiresIn; this.issuedAt = LocalDateTime.now(); this.expiresAt = this.issuedAt.plusSeconds(expiresIn); } } @Data public class OAuth2ErrorResponse { @JsonProperty("error") private String error; @JsonProperty("error_description") private String errorDescription; @JsonProperty("error_uri") private String errorUri; } OAuth2 Client Service
public interface OAuth2ClientService { OAuth2TokenResponse getAccessToken(); OAuth2TokenResponse refreshAccessToken(String refreshToken); boolean validateToken(String accessToken); void clearCachedToken(); } @Service @Slf4j public class OAuth2ClientServiceImpl implements OAuth2ClientService { private final OAuth2ClientProperties clientProperties; private final RestTemplate restTemplate; private final ObjectMapper objectMapper; private OAuth2TokenResponse cachedToken; private final Object tokenLock = new Object(); public OAuth2ClientServiceImpl(OAuth2ClientProperties clientProperties, RestTemplate restTemplate, ObjectMapper objectMapper) { this.clientProperties = clientProperties; this.restTemplate = restTemplate; this.objectMapper = objectMapper; } @Override public OAuth2TokenResponse getAccessToken() { synchronized (tokenLock) { if (cachedToken != null && !cachedToken.willExpireSoon()) { log.debug("Returning cached access token"); return cachedToken; } try { log.info("Requesting new access token from: {}", clientProperties.getTokenUri()); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); headers.setBasicAuth(clientProperties.getClientId(), clientProperties.getClientSecret()); MultiValueMap<String, String> body = new LinkedMultiValueMap<>(); body.add("grant_type", "client_credentials"); body.add("scope", String.join(" ", clientProperties.getScopes())); HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(body, headers); ResponseEntity<String> response = restTemplate.postForEntity( clientProperties.getTokenUri(), request, String.class); if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { OAuth2TokenResponse tokenResponse = objectMapper.readValue( response.getBody(), OAuth2TokenResponse.class); cachedToken = tokenResponse; log.info("Successfully obtained access token, expires in: {} seconds", tokenResponse.getExpiresIn()); return tokenResponse; } else { log.error("Failed to obtain access token. Status: {}, Body: {}", response.getStatusCode(), response.getBody()); throw new OAuth2TokenException("Failed to obtain access token: " + response.getStatusCode()); } } catch (HttpClientErrorException e) { log.error("HTTP client error while obtaining token: {}", e.getResponseBodyAsString()); throw new OAuth2TokenException("HTTP client error: " + e.getMessage(), e); } catch (Exception e) { log.error("Unexpected error while obtaining token", e); throw new OAuth2TokenException("Unexpected error: " + e.getMessage(), e); } } } @Override public OAuth2TokenResponse refreshAccessToken(String refreshToken) { // Client credentials flow typically doesn't use refresh tokens // This method is for flows that support refresh tokens log.warn("Refresh token not supported in client credentials flow"); return getAccessToken(); // Get a new token instead } @Override public boolean validateToken(String accessToken) { try { // Simple validation - check if token is not expired // In production, you might want to introspect the token with the auth server return cachedToken != null && cachedToken.getAccessToken().equals(accessToken) && !cachedToken.isExpired(); } catch (Exception e) { log.error("Error validating token", e); return false; } } @Override public void clearCachedToken() { synchronized (tokenLock) { cachedToken = null; log.info("Cleared cached access token"); } } // Scheduled task to pre-refresh token @Scheduled(fixedRate = 300000) // Every 5 minutes public void preRefreshToken() { try { if (cachedToken != null && cachedToken.willExpireSoon()) { log.info("Pre-refreshing access token before expiry"); getAccessToken(); } } catch (Exception e) { log.error("Error in token pre-refresh", e); } } } // Custom exceptions public class OAuth2TokenException extends RuntimeException { public OAuth2TokenException(String message) { super(message); } public OAuth2TokenException(String message, Throwable cause) { super(message, cause); } } RestTemplate Configuration
@Configuration public class RestTemplateConfig { @Bean public RestTemplate oauth2RestTemplate(OAuth2ClientProperties clientProperties) { RestTemplate restTemplate = new RestTemplate(); // Configure timeouts HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(); factory.setConnectTimeout(clientProperties.getConnectTimeout()); factory.setReadTimeout(clientProperties.getReadTimeout()); restTemplate.setRequestFactory(factory); // Add interceptors for logging restTemplate.getInterceptors().add(new OAuth2LoggingInterceptor()); return restTemplate; } // Logging interceptor private static class OAuth2LoggingInterceptor implements ClientHttpRequestInterceptor { private static final Logger log = LoggerFactory.getLogger(OAuth2LoggingInterceptor.class); @Override public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException { log.debug("OAuth2 Request: {} {}", request.getMethod(), request.getURI()); if (log.isDebugEnabled() && body.length > 0) { log.debug("Request body: {}", new String(body, StandardCharsets.UTF_8)); } ClientHttpResponse response = execution.execute(request, body); log.debug("OAuth2 Response: {}", response.getStatusCode()); return response; } } } 4. WebClient with OAuth2 Support
Reactive OAuth2 Client Configuration
@Configuration @EnableWebFlux public class WebClientConfig { @Bean @Primary public WebClient webClient(ReactiveClientRegistrationRepository clientRegistrations, ServerOAuth2AuthorizedClientRepository authorizedClients) { ServerOAuth2AuthorizedClientExchangeFilterFunction oauth2 = new ServerOAuth2AuthorizedClientExchangeFilterFunction( clientRegistrations, authorizedClients); oauth2.setDefaultClientRegistrationId("my-api-client"); return WebClient.builder() .filter(oauth2) .filter(logRequest()) .filter(logResponse()) .build(); } @Bean public WebClient authenticatedWebClient(OAuth2ClientService tokenService) { return WebClient.builder() .filter(new OAuth2ClientCredentialsFilter(tokenService)) .filter(logRequest()) .filter(logResponse()) .build(); } private ExchangeFilterFunction logRequest() { return ExchangeFilterFunction.ofRequestProcessor(clientRequest -> { log.info("Request: {} {}", clientRequest.method(), clientRequest.url()); clientRequest.headers().forEach((name, values) -> values.forEach(value -> log.debug("{}: {}", name, value))); return Mono.just(clientRequest); }); } private ExchangeFilterFunction logResponse() { return ExchangeFilterFunction.ofResponseProcessor(clientResponse -> { log.info("Response: {}", clientResponse.statusCode()); return Mono.just(clientResponse); }); } } // Custom filter for client credentials flow @Component @Slf4j public class OAuth2ClientCredentialsFilter implements ExchangeFilterFunction { private final OAuth2ClientService tokenService; public OAuth2ClientCredentialsFilter(OAuth2ClientService tokenService) { this.tokenService = tokenService; } @Override public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) { return Mono.fromCallable(() -> tokenService.getAccessToken()) .onErrorResume(e -> { log.error("Failed to obtain access token", e); return Mono.error(new OAuth2TokenException("Failed to obtain access token")); }) .flatMap(token -> { ClientRequest authenticatedRequest = ClientRequest.from(request) .headers(headers -> headers.setBearerAuth(token.getAccessToken())) .build(); return next.exchange(authenticatedRequest); }); } } 5. Protected Resource Client
API Client Interface
public interface ApiClient { <T> T get(String url, Class<T> responseType); <T> T post(String url, Object request, Class<T> responseType); <T> T put(String url, Object request, Class<T> responseType); void delete(String url); <T> List<T> getList(String url, Class<T> elementType); } @Service @Slf4j public class ProtectedApiClient implements ApiClient { private final WebClient webClient; private final OAuth2ClientService tokenService; private final String baseUrl; public ProtectedApiClient(WebClient webClient, OAuth2ClientService tokenService, @Value("${app.api.base-url:http://localhost:8082}") String baseUrl) { this.webClient = webClient; this.tokenService = tokenService; this.baseUrl = baseUrl; } @Override public <T> T get(String path, Class<T> responseType) { try { String url = buildUrl(path); log.debug("GET request to: {}", url); return webClient.get() .uri(url) .headers(this::addAuthHeader) .retrieve() .onStatus(status -> status.is4xxClientError(), response -> handleClientError(response, "GET", path)) .onStatus(status -> status.is5xxServerError(), response -> handleServerError(response, "GET", path)) .bodyToMono(responseType) .block(); } catch (WebClientResponseException e) { log.error("GET request failed for {}: {}", path, e.getMessage()); throw new ApiClientException("GET request failed: " + e.getMessage(), e); } } @Override public <T> T post(String path, Object request, Class<T> responseType) { try { String url = buildUrl(path); log.debug("POST request to: {}", url); return webClient.post() .uri(url) .headers(this::addAuthHeader) .bodyValue(request) .retrieve() .onStatus(status -> status.is4xxClientError(), response -> handleClientError(response, "POST", path)) .onStatus(status -> status.is5xxServerError(), response -> handleServerError(response, "POST", path)) .bodyToMono(responseType) .block(); } catch (WebClientResponseException e) { log.error("POST request failed for {}: {}", path, e.getMessage()); throw new ApiClientException("POST request failed: " + e.getMessage(), e); } } @Override public <T> T put(String path, Object request, Class<T> responseType) { try { String url = buildUrl(path); log.debug("PUT request to: {}", url); return webClient.put() .uri(url) .headers(this::addAuthHeader) .bodyValue(request) .retrieve() .onStatus(status -> status.is4xxClientError(), response -> handleClientError(response, "PUT", path)) .onStatus(status -> status.is5xxServerError(), response -> handleServerError(response, "PUT", path)) .bodyToMono(responseType) .block(); } catch (WebClientResponseException e) { log.error("PUT request failed for {}: {}", path, e.getMessage()); throw new ApiClientException("PUT request failed: " + e.getMessage(), e); } } @Override public void delete(String path) { try { String url = buildUrl(path); log.debug("DELETE request to: {}", url); webClient.delete() .uri(url) .headers(this::addAuthHeader) .retrieve() .onStatus(status -> status.is4xxClientError(), response -> handleClientError(response, "DELETE", path)) .onStatus(status -> status.is5xxServerError(), response -> handleServerError(response, "DELETE", path)) .toBodilessEntity() .block(); } catch (WebClientResponseException e) { log.error("DELETE request failed for {}: {}", path, e.getMessage()); throw new ApiClientException("DELETE request failed: " + e.getMessage(), e); } } @Override public <T> List<T> getList(String path, Class<T> elementType) { try { String url = buildUrl(path); log.debug("GET list request to: {}", url); ParameterizedTypeReference<List<T>> typeRef = new ParameterizedTypeReference<List<T>>() {}; return webClient.get() .uri(url) .headers(this::addAuthHeader) .retrieve() .onStatus(status -> status.is4xxClientError(), response -> handleClientError(response, "GET", path)) .onStatus(status -> status.is5xxServerError(), response -> handleServerError(response, "GET", path)) .bodyToMono(typeRef) .block(); } catch (WebClientResponseException e) { log.error("GET list request failed for {}: {}", path, e.getMessage()); throw new ApiClientException("GET list request failed: " + e.getMessage(), e); } } private String buildUrl(String path) { return baseUrl + path; } private void addAuthHeader(HttpHeaders headers) { try { OAuth2TokenResponse token = tokenService.getAccessToken(); headers.setBearerAuth(token.getAccessToken()); headers.setContentType(MediaType.APPLICATION_JSON); } catch (OAuth2TokenException e) { log.error("Failed to add authorization header", e); throw e; } } private Mono<? extends Throwable> handleClientError(ClientResponse response, String method, String path) { return response.bodyToMono(String.class) .flatMap(body -> { log.error("Client error in {} {}: {} - {}", method, path, response.statusCode(), body); return Mono.error(new ApiClientException( String.format("Client error %s: %s", response.statusCode(), body))); }); } private Mono<? extends Throwable> handleServerError(ClientResponse response, String method, String path) { return response.bodyToMono(String.class) .flatMap(body -> { log.error("Server error in {} {}: {} - {}", method, path, response.statusCode(), body); return Mono.error(new ApiClientException( String.format("Server error %s: %s", response.statusCode(), body))); }); } } // Custom exceptions public class ApiClientException extends RuntimeException { public ApiClientException(String message) { super(message); } public ApiClientException(String message, Throwable cause) { super(message, cause); } } 6. Controllers and REST Endpoints
Token Management Controller
@RestController @RequestMapping("/oauth2") @Slf4j public class OAuth2Controller { private final OAuth2ClientService tokenService; public OAuth2Controller(OAuth2ClientService tokenService) { this.tokenService = tokenService; } @GetMapping("/token") public ResponseEntity<Map<String, Object>> getToken() { try { OAuth2TokenResponse token = tokenService.getAccessToken(); Map<String, Object> response = new HashMap<>(); response.put("access_token", token.getAccessToken()); response.put("token_type", token.getTokenType()); response.put("expires_in", token.getExpiresIn()); response.put("scope", token.getScope()); response.put("issued_at", token.getIssuedAt()); response.put("expires_at", token.getExpiresAt()); return ResponseEntity.ok(response); } catch (OAuth2TokenException e) { log.error("Failed to get token", e); Map<String, Object> error = new HashMap<>(); error.put("error", "token_acquisition_failed"); error.put("error_description", e.getMessage()); error.put("timestamp", LocalDateTime.now()); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error); } } @PostMapping("/token/refresh") public ResponseEntity<Map<String, Object>> refreshToken(@RequestParam String refreshToken) { try { OAuth2TokenResponse token = tokenService.refreshAccessToken(refreshToken); Map<String, Object> response = new HashMap<>(); response.put("access_token", token.getAccessToken()); response.put("token_type", token.getTokenType()); response.put("expires_in", token.getExpiresIn()); response.put("scope", token.getScope()); return ResponseEntity.ok(response); } catch (OAuth2TokenException e) { log.error("Failed to refresh token", e); Map<String, Object> error = new HashMap<>(); error.put("error", "token_refresh_failed"); error.put("error_description", e.getMessage()); error.put("timestamp", LocalDateTime.now()); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error); } } @DeleteMapping("/token") public ResponseEntity<Void> clearToken() { tokenService.clearCachedToken(); return ResponseEntity.noContent().build(); } @GetMapping("/token/status") public ResponseEntity<Map<String, Object>> getTokenStatus() { try { OAuth2TokenResponse token = tokenService.getAccessToken(); boolean isValid = tokenService.validateToken(token.getAccessToken()); Map<String, Object> status = new HashMap<>(); status.put("has_token", token != null); status.put("is_valid", isValid); status.put("expires_at", token != null ? token.getExpiresAt() : null); status.put("seconds_until_expiry", token != null ? Duration.between(LocalDateTime.now(), token.getExpiresAt()).getSeconds() : null); return ResponseEntity.ok(status); } catch (Exception e) { log.error("Failed to get token status", e); Map<String, Object> error = new HashMap<>(); error.put("error", "status_check_failed"); error.put("error_description", e.getMessage()); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error); } } } Protected Resource Controller
@RestController @RequestMapping("/api/resources") @Slf4j public class ResourceController { private final ProtectedApiClient apiClient; public ResourceController(ProtectedApiClient apiClient) { this.apiClient = apiClient; } @GetMapping("/users") public ResponseEntity<List<User>> getUsers() { try { List<User> users = apiClient.getList("/users", User.class); return ResponseEntity.ok(users); } catch (ApiClientException e) { log.error("Failed to get users", e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } } @GetMapping("/users/{id}") public ResponseEntity<User> getUser(@PathVariable String id) { try { User user = apiClient.get("/users/" + id, User.class); return ResponseEntity.ok(user); } catch (ApiClientException e) { log.error("Failed to get user: {}", id, e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } } @PostMapping("/users") public ResponseEntity<User> createUser(@RequestBody @Valid CreateUserRequest request) { try { User user = apiClient.post("/users", request, User.class); return ResponseEntity.status(HttpStatus.CREATED).body(user); } catch (ApiClientException e) { log.error("Failed to create user", e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } } @PutMapping("/users/{id}") public ResponseEntity<User> updateUser(@PathVariable String id, @RequestBody @Valid UpdateUserRequest request) { try { User user = apiClient.put("/users/" + id, request, User.class); return ResponseEntity.ok(user); } catch (ApiClientException e) { log.error("Failed to update user: {}", id, e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } } @DeleteMapping("/users/{id}") public ResponseEntity<Void> deleteUser(@PathVariable String id) { try { apiClient.delete("/users/" + id); return ResponseEntity.noContent().build(); } catch (ApiClientException e) { log.error("Failed to delete user: {}", id, e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } } } // DTO classes @Data class User { private String id; private String username; private String email; private String firstName; private String lastName; private boolean enabled; private LocalDateTime createdAt; private LocalDateTime updatedAt; } @Data class CreateUserRequest { @NotBlank private String username; @NotBlank @Email private String email; @NotBlank private String firstName; @NotBlank private String lastName; private boolean enabled = true; } @Data class UpdateUserRequest { @NotBlank private String email; @NotBlank private String firstName; @NotBlank private String lastName; private boolean enabled; } 7. Testing
Unit Tests
@ExtendWith(MockitoExtension.class) class OAuth2ClientServiceTest { @Mock private RestTemplate restTemplate; @Mock private ObjectMapper objectMapper; @InjectMocks private OAuth2ClientServiceImpl tokenService; private OAuth2ClientProperties clientProperties; @BeforeEach void setUp() { clientProperties = new OAuth2ClientProperties(); clientProperties.setTokenUri("http://auth-server/oauth2/token"); clientProperties.setClientId("test-client"); clientProperties.setClientSecret("test-secret"); clientProperties.setScopes(List.of("read", "write")); tokenService = new OAuth2ClientServiceImpl(clientProperties, restTemplate, objectMapper); } @Test void shouldGetAccessTokenSuccessfully() throws Exception { // Given OAuth2TokenResponse expectedResponse = new OAuth2TokenResponse(); expectedResponse.setAccessToken("test-token"); expectedResponse.setTokenType("Bearer"); expectedResponse.setExpiresIn(3600L); expectedResponse.setScope("read write"); ResponseEntity<String> mockResponse = ResponseEntity.ok("token-response"); when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(String.class))) .thenReturn(mockResponse); when(objectMapper.readValue(anyString(), eq(OAuth2TokenResponse.class))) .thenReturn(expectedResponse); // When OAuth2TokenResponse result = tokenService.getAccessToken(); // Then assertThat(result).isNotNull(); assertThat(result.getAccessToken()).isEqualTo("test-token"); assertThat(result.getTokenType()).isEqualTo("Bearer"); assertThat(result.getExpiresIn()).isEqualTo(3600L); verify(restTemplate).postForEntity( eq("http://auth-server/oauth2/token"), any(HttpEntity.class), eq(String.class)); } @Test void shouldThrowExceptionWhenTokenRequestFails() { // Given when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(String.class))) .thenThrow(new HttpClientErrorException(HttpStatus.UNAUTHORIZED)); // When & Then assertThatThrownBy(() -> tokenService.getAccessToken()) .isInstanceOf(OAuth2TokenException.class) .hasMessageContaining("Failed to obtain access token"); } @Test void shouldReturnCachedTokenWhenNotExpired() throws Exception { // Given OAuth2TokenResponse cachedResponse = new OAuth2TokenResponse(); cachedResponse.setAccessToken("cached-token"); cachedResponse.setExpiresIn(3600L); // 1 hour // Set cached token directly tokenService.getAccessToken(); // This would set the cache // When OAuth2TokenResponse result = tokenService.getAccessToken(); // Then - should use cached token, so only one REST call verify(restTemplate, times(1)) .postForEntity(anyString(), any(HttpEntity.class), eq(String.class)); } } @WebMvcTest(OAuth2Controller.class) class OAuth2ControllerTest { @Autowired private MockMvc mockMvc; @MockBean private OAuth2ClientService tokenService; @Test void shouldReturnTokenSuccessfully() throws Exception { // Given OAuth2TokenResponse tokenResponse = new OAuth2TokenResponse(); tokenResponse.setAccessToken("test-token"); tokenResponse.setTokenType("Bearer"); tokenResponse.setExpiresIn(3600L); tokenResponse.setScope("read write"); when(tokenService.getAccessToken()).thenReturn(tokenResponse); // When & Then mockMvc.perform(get("/oauth2/token")) .andExpect(status().isOk()) .andExpect(jsonPath("$.access_token").value("test-token")) .andExpect(jsonPath("$.token_type").value("Bearer")) .andExpect(jsonPath("$.expires_in").value(3600)) .andExpect(jsonPath("$.scope").value("read write")); } @Test void shouldReturnErrorWhenTokenAcquisitionFails() throws Exception { // Given when(tokenService.getAccessToken()) .thenThrow(new OAuth2TokenException("Authentication failed")); // When & Then mockMvc.perform(get("/oauth2/token")) .andExpect(status().isInternalServerError()) .andExpect(jsonPath("$.error").value("token_acquisition_failed")) .andExpect(jsonPath("$.error_description").exists()); } } Integration Test
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @TestPropertySource(properties = { "spring.security.oauth2.client.registration.my-api-client.client-id=test-client", "spring.security.oauth2.client.registration.my-api-client.client-secret=test-secret", "spring.security.oauth2.client.registration.my-api-client.authorization-grant-type=client_credentials", "spring.security.oauth2.client.provider.my-auth-server.token-uri=http://localhost:${wiremock.server.port}/oauth2/token" }) @AutoConfigureWireMock(port = 0) class OAuth2IntegrationTest { @Autowired private TestRestTemplate restTemplate; @Test void shouldGetTokenFromAuthServer() { // Given String tokenResponse = """ { "access_token": "integration-test-token", "token_type": "Bearer", "expires_in": 3600, "scope": "read write" } """; stubFor(post(urlEqualTo("/oauth2/token")) .willReturn(aResponse() .withHeader("Content-Type", "application/json") .withBody(tokenResponse))); // When ResponseEntity<Map> response = restTemplate.getForEntity("/oauth2/token", Map.class); // Then assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getBody()).isNotNull(); assertThat(response.getBody().get("access_token")).isEqualTo("integration-test-token"); verify(postRequestedFor(urlEqualTo("/oauth2/token"))); } } 8. Monitoring and Health Checks
Health Indicator
@Component public class OAuth2HealthIndicator implements HealthIndicator { private final OAuth2ClientService tokenService; public OAuth2HealthIndicator(OAuth2ClientService tokenService) { this.tokenService = tokenService; } @Override public Health health() { try { OAuth2TokenResponse token = tokenService.getAccessToken(); boolean isValid = tokenService.validateToken(token.getAccessToken()); Health.Builder status = isValid ? Health.up() : Health.down(); return status .withDetail("token_available", true) .withDetail("token_valid", isValid) .withDetail("token_expires_at", token.getExpiresAt()) .withDetail("seconds_until_expiry", Duration.between(LocalDateTime.now(), token.getExpiresAt()).getSeconds()) .build(); } catch (Exception e) { return Health.down() .withDetail("token_available", false) .withDetail("error", e.getMessage()) .build(); } } } This comprehensive implementation provides a complete OAuth2 Client Credentials flow in Java with Spring Security, including token management, protected API calls, testing, and monitoring.