Keycloak and OAuth2 Testing with Testcontainers
Testing Spring Security with @WithMockUser and SecurityMockMvcRequestPostProcessors.jwt() verifies that your authorization annotations are wired correctly, but it does not test JWT validation, token signing algorithms, issuer URL verification, or clock skew handling. These require a real OAuth2 provider. KeycloakContainer runs a real Keycloak instance in Docker, giving you a complete OIDC server for integration tests.
What You’ll Learn
KeycloakContainersetup from the community module- Importing a Keycloak realm for tests
- Retrieving JWT access tokens programmatically
- Testing protected endpoints with real JWTs
- Testing role-based access control (RBAC)
- Testing token expiry and refresh flows
Dependencies
KeycloakContainer is not in the Testcontainers core library. Add the community module:
<dependency>
<groupId>com.github.dasniko</groupId>
<artifactId>testcontainers-keycloak</artifactId>
<version>3.3.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
Spring Security OAuth2 Resource Server:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
Keycloak Realm Configuration
Create a realm export JSON file in src/test/resources/keycloak/:
{
"realm": "orders-realm",
"enabled": true,
"ssoSessionMaxLifespan": 36000,
"clients": [
{
"clientId": "orders-api",
"secret": "orders-api-secret",
"enabled": true,
"directAccessGrantsEnabled": true,
"serviceAccountsEnabled": true,
"publicClient": false
}
],
"roles": {
"realm": [
{ "name": "customer", "description": "Regular customer role" },
{ "name": "admin", "description": "Admin role" },
{ "name": "support", "description": "Support agent role" }
]
},
"users": [
{
"username": "customer-user",
"email": "customer@example.com",
"enabled": true,
"credentials": [{ "type": "password", "value": "customer123", "temporary": false }],
"realmRoles": ["customer"]
},
{
"username": "admin-user",
"email": "admin@example.com",
"enabled": true,
"credentials": [{ "type": "password", "value": "admin123", "temporary": false }],
"realmRoles": ["admin"]
}
]
}
Spring Security Configuration
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/api/v1/orders/**").authenticated()
.requestMatchers("/api/v1/admin/**").hasRole("admin")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
);
return http.build();
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter converter = new JwtGrantedAuthoritiesConverter();
converter.setAuthoritiesClaimName("realm_access.roles");
converter.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter();
jwtConverter.setJwtGrantedAuthoritiesConverter(converter);
return jwtConverter;
}
}
KeycloakContainer Setup
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class OrderControllerSecurityTest {
@Container
static KeycloakContainer keycloak = new KeycloakContainer("quay.io/keycloak/keycloak:24.0.1")
.withRealmImportFile("keycloak/orders-realm.json")
.waitingFor(
Wait.forHttp("/realms/orders-realm")
.forStatusCode(200)
.withStartupTimeout(Duration.ofMinutes(2))
);
@DynamicPropertySource
static void configureKeycloak(DynamicPropertyRegistry registry) {
registry.add(
"spring.security.oauth2.resourceserver.jwt.issuer-uri",
() -> keycloak.getAuthServerUrl() + "/realms/orders-realm"
);
}
@LocalServerPort
private int port;
private RestTemplate restTemplate;
@BeforeEach
void setup() {
restTemplate = new RestTemplate();
}
}
Obtaining JWT Tokens
Write a helper method to obtain access tokens from Keycloak using the Resource Owner Password Credentials (ROPC) grant:
private String getAccessToken(String username, String password) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", "password");
body.add("client_id", "orders-api");
body.add("client_secret", "orders-api-secret");
body.add("username", username);
body.add("password", password);
String tokenUrl = keycloak.getAuthServerUrl()
+ "/realms/orders-realm/protocol/openid-connect/token";
ResponseEntity<Map> response = restTemplate.postForEntity(
tokenUrl,
new HttpEntity<>(body, headers),
Map.class
);
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
return (String) response.getBody().get("access_token");
}
Testing Protected Endpoints
@Test
void shouldAllowAuthenticatedCustomerToViewOrders() {
String token = getAccessToken("customer-user", "customer123");
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(token);
ResponseEntity<String> response = restTemplate.exchange(
"http://localhost:" + port + "/api/v1/orders",
HttpMethod.GET,
new HttpEntity<>(headers),
String.class
);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void shouldReturn401ForUnauthenticatedRequest() {
ResponseEntity<String> response = restTemplate.exchange(
"http://localhost:" + port + "/api/v1/orders",
HttpMethod.GET,
new HttpEntity<>(new HttpHeaders()),
String.class
);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
void shouldReturn403WhenCustomerAccessesAdminEndpoint() {
String customerToken = getAccessToken("customer-user", "customer123");
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(customerToken);
ResponseEntity<String> response = restTemplate.exchange(
"http://localhost:" + port + "/api/v1/admin/orders",
HttpMethod.GET,
new HttpEntity<>(headers),
String.class
);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
void shouldAllowAdminToAccessAdminEndpoint() {
String adminToken = getAccessToken("admin-user", "admin123");
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(adminToken);
ResponseEntity<String> response = restTemplate.exchange(
"http://localhost:" + port + "/api/v1/admin/orders",
HttpMethod.GET,
new HttpEntity<>(headers),
String.class
);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
Testing @PreAuthorize Method Security
@Service
public class OrderService {
@PreAuthorize("hasRole('admin') or #customerId == authentication.name")
public List<Order> getOrdersForCustomer(String customerId) {
return orderRepository.findByCustomerId(customerId);
}
@PreAuthorize("hasRole('admin')")
public void cancelAllOrders(String customerId) {
orderRepository.cancelAll(customerId);
}
}
@Test
void shouldAllowCustomerToViewOwnOrders() {
String token = getAccessToken("customer-user", "customer123");
// customer-user can view their own orders (principal name = "customer-user")
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(token);
ResponseEntity<String> response = restTemplate.exchange(
"http://localhost:" + port + "/api/v1/orders?customerId=customer-user",
HttpMethod.GET,
new HttpEntity<>(headers),
String.class
);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void shouldDenyCustomerFromViewingOtherCustomersOrders() {
String token = getAccessToken("customer-user", "customer123");
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(token);
// customer-user trying to view admin-user's orders
ResponseEntity<String> response = restTemplate.exchange(
"http://localhost:" + port + "/api/v1/orders?customerId=admin-user",
HttpMethod.GET,
new HttpEntity<>(headers),
String.class
);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
Common Pitfalls
Keycloak startup time. Keycloak takes 30–60 seconds to start, especially on first run. Use withStartupTimeout(Duration.ofMinutes(2)). Never lower the timeout — Keycloak is slow and timeouts cause intermittent test failures.
OIDC discovery endpoint caching. Spring Security caches the JWKS (JSON Web Key Set) from Keycloak’s discovery endpoint. On test startup, ensure Keycloak is fully ready before the Spring context loads. The @DynamicPropertySource approach handles this correctly.
Using ROPC grant for service accounts. Resource Owner Password Credentials grant requires directAccessGrantsEnabled = true in your Keycloak client. For production, use client credentials grant. For tests, ROPC is convenient for obtaining tokens as specific users.
Summary
KeycloakContainer gives you a real OAuth2 authorization server for testing JWT validation, RBAC, and method-level security. Import a realm JSON file for reproducible test configuration. Obtain tokens via the ROPC grant. Test 401, 403, and 200 responses across user roles.
The next article covers testing strategy — understanding the differences between unit testing, integration testing, and system testing, and how to structure your test suite.
Next: Unit Testing vs Integration Testing vs System Testing in Java