The Client Credentials flow is used for machine-to-machine authentication where a client application accesses protected resources using its own credentials.
1. Manual Implementation (Without Spring Security)
Core OAuth2 Client Implementation
// Token Response DTO
@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;
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);
}
}
// OAuth2 Client Service
@Service
@Slf4j
public class OAuth2ClientService {
private final OAuth2Config config;
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
private OAuth2TokenResponse cachedToken;
private final Object tokenLock = new Object();
public OAuth2ClientService(OAuth2Config config, RestTemplate restTemplate, ObjectMapper objectMapper) {
this.config = config;
this.restTemplate = restTemplate;
this.objectMapper = objectMapper;
}
public String getAccessToken() {
synchronized (tokenLock) {
if (cachedToken != null && !cachedToken.willExpireSoon()) {
log.debug("Using cached access token");
return cachedToken.getAccessToken();
}
try {
log.info("Requesting new access token from: {}", config.getTokenUri());
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
headers.setBasicAuth(config.getClientId(), config.getClientSecret());
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", "client_credentials");
body.add("scope", String.join(" ", config.getScopes()));
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(body, headers);
ResponseEntity<String> response = restTemplate.postForEntity(
config.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.getAccessToken();
} else {
log.error("Failed to obtain access token. Status: {}, Body: {}",
response.getStatusCode(), response.getBody());
throw new OAuth2Exception("Failed to obtain access token: " + response.getStatusCode());
}
} catch (HttpClientErrorException e) {
log.error("HTTP client error while obtaining token: {}", e.getResponseBodyAsString());
throw new OAuth2Exception("HTTP client error: " + e.getMessage(), e);
} catch (Exception e) {
log.error("Unexpected error while obtaining token", e);
throw new OAuth2Exception("Unexpected error: " + e.getMessage(), e);
}
}
}
public void clearCachedToken() {
synchronized (tokenLock) {
cachedToken = null;
log.info("Cleared cached access token");
}
}
public OAuth2TokenResponse getTokenInfo() {
return cachedToken;
}
}
// Configuration Class
@Data
@Configuration
@ConfigurationProperties(prefix = "app.oauth2")
public class OAuth2Config {
private String tokenUri;
private String clientId;
private String clientSecret;
private List<String> scopes = List.of("read", "write");
private int connectTimeout = 5000;
private int readTimeout = 10000;
}
// Custom Exceptions
public class OAuth2Exception extends RuntimeException {
public OAuth2Exception(String message) {
super(message);
}
public OAuth2Exception(String message, Throwable cause) {
super(message, cause);
}
}
HTTP Client Configuration
@Configuration
public class HttpClientConfig {
@Bean
public RestTemplate oauth2RestTemplate(OAuth2Config config) {
RestTemplate restTemplate = new RestTemplate();
// Configure timeouts
HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();
factory.setConnectTimeout(config.getConnectTimeout());
factory.setReadTimeout(config.getReadTimeout());
restTemplate.setRequestFactory(factory);
// Add logging interceptor
restTemplate.getInterceptors().add(new LoggingInterceptor());
// Add error handler
restTemplate.setErrorHandler(new OAuth2ErrorHandler());
return restTemplate;
}
// Logging Interceptor
private static class LoggingInterceptor implements ClientHttpRequestInterceptor {
@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;
}
}
// Error Handler
private static class OAuth2ErrorHandler implements ResponseErrorHandler {
@Override
public boolean hasError(ClientHttpResponse response) throws IOException {
return response.getStatusCode().isError();
}
@Override
public void handleError(ClientHttpResponse response) throws IOException {
String body = getResponseBody(response);
log.error("OAuth2 API error - Status: {}, Body: {}", response.getStatusCode(), body);
if (response.getStatusCode().is4xxClientError()) {
throw new HttpClientErrorException(response.getStatusCode(), body);
} else if (response.getStatusCode().is5xxServerError()) {
throw new HttpServerErrorException(response.getStatusCode(), body);
}
}
private String getResponseBody(ClientHttpResponse response) {
try {
return StreamUtils.copyToString(response.getBody(), StandardCharsets.UTF_8);
} catch (IOException e) {
return "Unable to read response body";
}
}
}
}
Protected API Client
@Service
@Slf4j
public class ProtectedApiClient {
private final OAuth2ClientService oauth2Service;
private final RestTemplate restTemplate;
private final String apiBaseUrl;
public ProtectedApiClient(OAuth2ClientService oauth2Service,
RestTemplate restTemplate,
@Value("${app.api.base-url}") String apiBaseUrl) {
this.oauth2Service = oauth2Service;
this.restTemplate = restTemplate;
this.apiBaseUrl = apiBaseUrl;
}
public <T> T get(String path, Class<T> responseType) {
return executeWithToken(token -> {
String url = buildUrl(path);
log.debug("GET request to: {}", url);
HttpHeaders headers = createHeaders(token);
HttpEntity<?> entity = new HttpEntity<>(headers);
ResponseEntity<T> response = restTemplate.exchange(
url, HttpMethod.GET, entity, responseType);
return response.getBody();
});
}
public <T> T post(String path, Object requestBody, Class<T> responseType) {
return executeWithToken(token -> {
String url = buildUrl(path);
log.debug("POST request to: {}", url);
HttpHeaders headers = createHeaders(token);
HttpEntity<Object> entity = new HttpEntity<>(requestBody, headers);
ResponseEntity<T> response = restTemplate.exchange(
url, HttpMethod.POST, entity, responseType);
return response.getBody();
});
}
public <T> T put(String path, Object requestBody, Class<T> responseType) {
return executeWithToken(token -> {
String url = buildUrl(path);
log.debug("PUT request to: {}", url);
HttpHeaders headers = createHeaders(token);
HttpEntity<Object> entity = new HttpEntity<>(requestBody, headers);
ResponseEntity<T> response = restTemplate.exchange(
url, HttpMethod.PUT, entity, responseType);
return response.getBody();
});
}
public void delete(String path) {
executeWithToken(token -> {
String url = buildUrl(path);
log.debug("DELETE request to: {}", url);
HttpHeaders headers = createHeaders(token);
HttpEntity<?> entity = new HttpEntity<>(headers);
restTemplate.exchange(url, HttpMethod.DELETE, entity, Void.class);
return null;
});
}
private <T> T executeWithToken(Function<String, T> operation) {
try {
String accessToken = oauth2Service.getAccessToken();
return operation.apply(accessToken);
} catch (HttpClientErrorException.Unauthorized e) {
log.warn("Access token expired, clearing cache and retrying");
oauth2Service.clearCachedToken();
// Retry with new token
String newAccessToken = oauth2Service.getAccessToken();
return operation.apply(newAccessToken);
} catch (Exception e) {
log.error("API request failed", e);
throw new ApiClientException("API request failed: " + e.getMessage(), e);
}
}
private HttpHeaders createHeaders(String accessToken) {
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken);
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setAccept(List.of(MediaType.APPLICATION_JSON));
return headers;
}
private String buildUrl(String path) {
return apiBaseUrl + path;
}
}
// API Client Exception
public class ApiClientException extends RuntimeException {
public ApiClientException(String message) {
super(message);
}
public ApiClientException(String message, Throwable cause) {
super(message, cause);
}
}
2. Spring Security OAuth2 Client Implementation
Dependencies (pom.xml)
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-client</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> </dependencies>
Application Configuration (application.yml)
spring:
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}
app:
api:
base-url: http://localhost:8082/api
Spring Security Configuration
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(authz -> authz
.anyRequest().authenticated()
)
.oauth2Client(withDefaults());
return http.build();
}
@Bean
public OAuth2AuthorizedClientManager authorizedClientManager(
ClientRegistrationRepository clientRegistrationRepository,
OAuth2AuthorizedClientRepository authorizedClientRepository) {
OAuth2AuthorizedClientProvider authorizedClientProvider =
OAuth2AuthorizedClientProviderBuilder.builder()
.clientCredentials()
.build();
DefaultOAuth2AuthorizedClientManager authorizedClientManager =
new DefaultOAuth2AuthorizedClientManager(
clientRegistrationRepository, authorizedClientRepository);
authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);
return authorizedClientManager;
}
}
Spring WebClient Implementation
@Configuration
public class WebClientConfig {
@Bean
public WebClient webClient(OAuth2AuthorizedClientManager authorizedClientManager) {
ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2Client =
new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
oauth2Client.setDefaultClientRegistrationId("my-api-client");
return WebClient.builder()
.apply(oauth2Client.oauth2Configuration())
.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);
});
}
}
@Service
public class SpringOAuth2ApiClient {
private final WebClient webClient;
public SpringOAuth2ApiClient(WebClient webClient) {
this.webClient = webClient;
}
public <T> Mono<T> get(String path, Class<T> responseType) {
return webClient.get()
.uri(path)
.retrieve()
.bodyToMono(responseType)
.doOnSubscribe(sub -> log.debug("GET request to: {}", path))
.doOnSuccess(response -> log.debug("GET request successful"))
.doOnError(error -> log.error("GET request failed", error));
}
public <T> Mono<T> post(String path, Object body, Class<T> responseType) {
return webClient.post()
.uri(path)
.bodyValue(body)
.retrieve()
.bodyToMono(responseType)
.doOnSubscribe(sub -> log.debug("POST request to: {}", path))
.doOnSuccess(response -> log.debug("POST request successful"))
.doOnError(error -> log.error("POST request failed", error));
}
public <T> Mono<T> put(String path, Object body, Class<T> responseType) {
return webClient.put()
.uri(path)
.bodyValue(body)
.retrieve()
.bodyToMono(responseType)
.doOnSubscribe(sub -> log.debug("PUT request to: {}", path))
.doOnSuccess(response -> log.debug("PUT request successful"))
.doOnError(error -> log.error("PUT request failed", error));
}
public Mono<Void> delete(String path) {
return webClient.delete()
.uri(path)
.retrieve()
.bodyToMono(Void.class)
.doOnSubscribe(sub -> log.debug("DELETE request to: {}", path))
.doOnSuccess(response -> log.debug("DELETE request successful"))
.doOnError(error -> log.error("DELETE request failed", error));
}
}
3. Advanced Features
Token Refresh Scheduler
@Component
@Slf4j
public class TokenRefreshScheduler {
private final OAuth2ClientService oauth2Service;
public TokenRefreshScheduler(OAuth2ClientService oauth2Service) {
this.oauth2Service = oauth2Service;
}
@Scheduled(fixedRate = 300000) // 5 minutes
public void refreshTokenIfNeeded() {
try {
OAuth2TokenResponse token = oauth2Service.getTokenInfo();
if (token != null && token.willExpireSoon()) {
log.info("Token will expire soon, refreshing...");
oauth2Service.clearCachedToken();
oauth2Service.getAccessToken(); // This will get a new token
}
} catch (Exception e) {
log.error("Error in token refresh scheduler", e);
}
}
}
@Configuration
@EnableScheduling
public class SchedulingConfig {
@Bean
public TaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(2);
scheduler.setThreadNamePrefix("oauth2-scheduler-");
return scheduler;
}
}
Circuit Breaker Implementation
@Service
@Slf4j
public class ResilientOAuth2Client {
private final OAuth2ClientService oauth2Service;
private final CircuitBreaker circuitBreaker;
public ResilientOAuth2Client(OAuth2ClientService oauth2Service, CircuitBreakerRegistry circuitBreakerRegistry) {
this.oauth2Service = oauth2Service;
this.circuitBreaker = circuitBreakerRegistry.circuitBreaker("oauth2-client");
}
public String getAccessTokenWithCircuitBreaker() {
return circuitBreaker.executeSupplier(() -> {
try {
return oauth2Service.getAccessToken();
} catch (OAuth2Exception e) {
log.warn("OAuth2 token request failed", e);
throw new CallNotPermittedException("OAuth2 service unavailable", circuitBreaker.getState());
}
});
}
public <T> T executeWithRetry(Supplier<T> operation) {
Retry retry = Retry.of("oauth2-retry", RetryConfig.custom()
.maxAttempts(3)
.waitDuration(Duration.ofSeconds(2))
.retryOnException(e -> e instanceof OAuth2Exception)
.build());
return retry.executeSupplier(() -> {
try {
return operation.get();
} catch (Exception e) {
log.warn("Operation failed, will retry", e);
oauth2Service.clearCachedToken(); // Clear token on failure
throw e;
}
});
}
}
@Configuration
public class ResilienceConfig {
@Bean
public CircuitBreakerRegistry circuitBreakerRegistry() {
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.failureRateThreshold(50)
.waitDurationInOpenState(Duration.ofSeconds(60))
.slidingWindowSize(10)
.build();
return CircuitBreakerRegistry.of(config);
}
}
4. REST Controller Examples
OAuth2 Management Controller
@RestController
@RequestMapping("/api/oauth2")
@Slf4j
public class OAuth2ManagementController {
private final OAuth2ClientService oauth2Service;
public OAuth2ManagementController(OAuth2ClientService oauth2Service) {
this.oauth2Service = oauth2Service;
}
@GetMapping("/token")
public ResponseEntity<Map<String, Object>> getTokenInfo() {
try {
String token = oauth2Service.getAccessToken();
OAuth2TokenResponse tokenInfo = oauth2Service.getTokenInfo();
Map<String, Object> response = new HashMap<>();
response.put("access_token", maskToken(token));
response.put("token_type", tokenInfo.getTokenType());
response.put("expires_in", tokenInfo.getExpiresIn());
response.put("scope", tokenInfo.getScope());
response.put("issued_at", tokenInfo.getIssuedAt());
response.put("expires_at", tokenInfo.getExpiresAt());
return ResponseEntity.ok(response);
} catch (OAuth2Exception e) {
log.error("Failed to get token info", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", e.getMessage()));
}
}
@PostMapping("/token/refresh")
public ResponseEntity<Map<String, Object>> refreshToken() {
try {
oauth2Service.clearCachedToken();
String newToken = oauth2Service.getAccessToken();
Map<String, Object> response = new HashMap<>();
response.put("message", "Token refreshed successfully");
response.put("access_token", maskToken(newToken));
return ResponseEntity.ok(response);
} catch (OAuth2Exception e) {
log.error("Failed to refresh token", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", e.getMessage()));
}
}
@GetMapping("/health")
public ResponseEntity<Map<String, Object>> healthCheck() {
try {
OAuth2TokenResponse token = oauth2Service.getTokenInfo();
boolean healthy = token != null && !token.isExpired();
Map<String, Object> health = new HashMap<>();
health.put("status", healthy ? "UP" : "DOWN");
health.put("token_available", token != null);
health.put("token_expired", token != null && token.isExpired());
health.put("timestamp", LocalDateTime.now());
return ResponseEntity.status(healthy ? HttpStatus.OK : HttpStatus.SERVICE_UNAVAILABLE)
.body(health);
} catch (Exception e) {
log.error("Health check failed", e);
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(Map.of("status", "DOWN", "error", e.getMessage()));
}
}
private String maskToken(String token) {
if (token == null || token.length() <= 8) {
return "***";
}
return token.substring(0, 4) + "..." + token.substring(token.length() - 4);
}
}
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.get("/users", List.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 User user) {
try {
User createdUser = apiClient.post("/users", user, User.class);
return ResponseEntity.status(HttpStatus.CREATED).body(createdUser);
} catch (ApiClientException e) {
log.error("Failed to create user", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
}
@Data
class User {
private String id;
private String username;
private String email;
private String firstName;
private String lastName;
}
5. Testing
Unit Tests
@ExtendWith(MockitoExtension.class)
class OAuth2ClientServiceTest {
@Mock
private RestTemplate restTemplate;
@Mock
private ObjectMapper objectMapper;
private OAuth2ClientService oauth2Service;
private OAuth2Config config;
@BeforeEach
void setUp() {
config = new OAuth2Config();
config.setTokenUri("http://auth-server.com/token");
config.setClientId("test-client");
config.setClientSecret("test-secret");
config.setScopes(List.of("read", "write"));
oauth2Service = new OAuth2ClientService(config, restTemplate, objectMapper);
}
@Test
void shouldGetAccessTokenSuccessfully() throws Exception {
// Given
OAuth2TokenResponse tokenResponse = new OAuth2TokenResponse();
tokenResponse.setAccessToken("test-token-123");
tokenResponse.setTokenType("Bearer");
tokenResponse.setExpiresIn(3600L);
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(tokenResponse);
// When
String token = oauth2Service.getAccessToken();
// Then
assertThat(token).isEqualTo("test-token-123");
verify(restTemplate).postForEntity(
eq("http://auth-server.com/token"),
any(HttpEntity.class),
eq(String.class));
}
@Test
void shouldUseCachedTokenWhenNotExpired() throws Exception {
// Given - First call
OAuth2TokenResponse tokenResponse = new OAuth2TokenResponse();
tokenResponse.setAccessToken("cached-token");
tokenResponse.setExpiresIn(3600L); // 1 hour
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(tokenResponse);
// When - Make two calls
oauth2Service.getAccessToken();
oauth2Service.getAccessToken();
// Then - Should only make one REST call
verify(restTemplate, times(1))
.postForEntity(anyString(), any(HttpEntity.class), eq(String.class));
}
}
@WebMvcTest(OAuth2ManagementController.class)
class OAuth2ManagementControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private OAuth2ClientService oauth2Service;
@Test
void shouldReturnTokenInfo() throws Exception {
// Given
OAuth2TokenResponse tokenResponse = new OAuth2TokenResponse();
tokenResponse.setAccessToken("test-token");
tokenResponse.setTokenType("Bearer");
tokenResponse.setExpiresIn(3600L);
tokenResponse.setScope("read write");
when(oauth2Service.getAccessToken()).thenReturn("test-token");
when(oauth2Service.getTokenInfo()).thenReturn(tokenResponse);
// When & Then
mockMvc.perform(get("/api/oauth2/token"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.access_token").value("test...oken"))
.andExpect(jsonPath("$.token_type").value("Bearer"))
.andExpect(jsonPath("$.expires_in").value(3600));
}
}
Integration Test with WireMock
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestPropertySource(properties = {
"app.oauth2.token-uri=http://localhost:${wiremock.server.port}/oauth2/token",
"app.oauth2.client-id=test-client",
"app.oauth2.client-secret=test-secret"
})
@AutoConfigureWireMock(port = 0)
class OAuth2IntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
@Test
void shouldObtainTokenFromAuthServer() {
// Given
String tokenResponse = """
{
"access_token": "integration-test-token",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "read write"
}
""";
stubFor(post(urlEqualTo("/oauth2/token"))
.withBasicAuth("test-client", "test-secret")
.withRequestBody(containing("grant_type=client_credentials"))
.willReturn(aResponse()
.withHeader("Content-Type", "application/json")
.withBody(tokenResponse)));
// When
ResponseEntity<Map> response = restTemplate.getForEntity("/api/oauth2/token", Map.class);
// Then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().get("access_token")).isNotNull();
verify(postRequestedFor(urlEqualTo("/oauth2/token")));
}
}
6. Application Properties
# application.yml
app:
oauth2:
token-uri: ${TOKEN_URI:http://localhost:8081/oauth2/token}
client-id: ${CLIENT_ID:client-id}
client-secret: ${CLIENT_SECRET:client-secret}
scopes: read,write
connect-timeout: 5000
read-timeout: 10000
api:
base-url: ${API_BASE_URL:http://localhost:8082/api}
server:
port: 8080
logging:
level:
com.example.oauth2: DEBUG
org.springframework.web.client: DEBUG
management:
endpoints:
web:
exposure:
include: health,info,metrics
endpoint:
health:
show-details: always
This comprehensive implementation provides a complete OAuth2 Client Credentials flow in Java with both manual and Spring Security approaches, including advanced features like token refresh scheduling, circuit breakers, and comprehensive testing.