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.