1. Backend integration overview
Keycloak provides a standard OAuth2/OIDC mechanism, allowing integration with any backend framework that supports JWT verification. In this article, we will integrate with the two most popular frameworks in the Java ecosystem: Spring Boot 3 and Quarkus.
| Framework | Library | Approach |
|---|---|---|
| Spring Boot 3 | spring-boot-starter-oauth2-resource-server | JWT Resource Server |
| Quarkus | quarkus-oidc | OIDC Extension |
Overview architecture:
┌──────────┐ ┌───────────┐ ┌──────────────┐
│ Client │────▶│ Keycloak │ │ Backend API │
│ (SPA/ │ │ Server │ │ (Spring Boot │
│ Mobile) │ └─────┬─────┘ │ / Quarkus) │
│ │ │ └───────┬──────┘
│ │ ┌─────▼─────┐ │
│ │ │ Access │ ┌───────▼──────┐
│ │────▶│ Token │────▶│ JWT Verify │
│ │ │ (JWT) │ │ + Role Check │
└──────────┘ └───────────┘ └──────────────┘
2. Spring Boot 3 + Keycloak Integration
2.1 Maven Dependencies
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.0</version>
</parent>
<groupId>com.example</groupId>
<artifactId>keycloak-spring-demo</artifactId>
<version>1.0.0</version>
<dependencies>
<!-- Spring Boot Starter Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- OAuth2 Resource Server (JWT validation) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Test dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Testcontainers Keycloak -->
<dependency>
<groupId>com.github.dasniko</groupId>
<artifactId>testcontainers-keycloak</artifactId>
<version>3.3.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Important note: From Keycloak 20+, the dedicated adapter for Spring Boot (keycloak-spring-boot-starter) has been deprecated. The current standard method is to use Spring Security's spring-boot-starter-oauth2-resource-server.
2.2 Application.yml Configuration
# src/main/resources/application.yml
server:
port: 8081
spring:
security:
oauth2:
resourceserver:
jwt:
# Keycloak OIDC issuer URI
issuer-uri: http://localhost:8080/realms/my-realm
# JWK Set URI để verify JWT signature
jwk-set-uri: http://localhost:8080/realms/my-realm/protocol/openid-connect/certs
logging:
level:
org.springframework.security: DEBUG
Attributes explained:
| Property | Description |
|---|---|
issuer-uri | URI of Keycloak realm, used to validate iss claim in JWT |
jwk-set-uri | Endpoint contains public keys (JWKS) to verify JWT signature |
2.3 Security Configuration
package com.example.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // Bật @PreAuthorize, @Secured, @RolesAllowed
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// Stateless session - không lưu session trên server
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// Tắt CSRF cho REST API (stateless)
.csrf(csrf -> csrf.disable())
// Cấu hình authorization rules
.authorizeHttpRequests(auth -> auth
// Public endpoints
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
// Admin endpoints yêu cầu role ADMIN
.requestMatchers("/api/admin/**").hasRole("ADMIN")
// User endpoints yêu cầu role USER hoặc ADMIN
.requestMatchers(HttpMethod.GET, "/api/users/**").hasAnyRole("USER", "ADMIN")
.requestMatchers(HttpMethod.POST, "/api/users/**").hasRole("ADMIN")
// Tất cả request khác cần authenticated
.anyRequest().authenticated()
)
// Cấu hình OAuth2 Resource Server với JWT
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
);
return http.build();
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(new KeycloakRoleConverter());
return converter;
}
}
2.4 Custom JwtAuthenticationConverter
Keycloak stores roles in JWT claims in a special structure. Need custom converter to extract correct roles:
JWT Claims từ Keycloak:
{
"realm_access": {
"roles": ["ADMIN", "USER", "offline_access"]
},
"resource_access": {
"my-client": {
"roles": ["client_admin", "client_user"]
},
"account": {
"roles": ["manage-account"]
}
},
"preferred_username": "john",
"email": "[email protected]",
...
}
package com.example.config;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class KeycloakRoleConverter implements Converter<Jwt, Collection<GrantedAuthority>> {
// Client ID trong Keycloak
private static final String CLIENT_ID = "my-client";
@Override
public Collection<GrantedAuthority> convert(Jwt jwt) {
// Extract realm roles
Collection<String> realmRoles = extractRealmRoles(jwt);
// Extract client roles
Collection<String> clientRoles = extractClientRoles(jwt, CLIENT_ID);
// Combine và convert thành GrantedAuthority
return Stream.concat(realmRoles.stream(), clientRoles.stream())
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.collect(Collectors.toSet());
}
@SuppressWarnings("unchecked")
private Collection<String> extractRealmRoles(Jwt jwt) {
Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
if (realmAccess == null) {
return Collections.emptyList();
}
Object roles = realmAccess.get("roles");
if (roles instanceof Collection) {
return (Collection<String>) roles;
}
return Collections.emptyList();
}
@SuppressWarnings("unchecked")
private Collection<String> extractClientRoles(Jwt jwt, String clientId) {
Map<String, Object> resourceAccess = jwt.getClaimAsMap("resource_access");
if (resourceAccess == null) {
return Collections.emptyList();
}
Object clientAccess = resourceAccess.get(clientId);
if (clientAccess instanceof Map) {
Map<String, Object> clientMap = (Map<String, Object>) clientAccess;
Object roles = clientMap.get("roles");
if (roles instanceof Collection) {
return (Collection<String>) roles;
}
}
return Collections.emptyList();
}
}
How role mapping works:
Keycloak JWT claim → Spring Security Authority
─────────────────────────────────────────────────────────────
realm_access.roles["ADMIN"] → ROLE_ADMIN
realm_access.roles["USER"] → ROLE_USER
resource_access.my-client → ROLE_client_admin
.roles["client_admin"]
resource_access.my-client → ROLE_client_user
.roles["client_user"]
2.5 REST Controller with RBAC
package com.example.controller;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api")
public class DemoController {
// ========== Public Endpoints ==========
@GetMapping("/public/health")
public ResponseEntity<Map<String, String>> health() {
return ResponseEntity.ok(Map.of("status", "UP"));
}
// ========== Authenticated Endpoints ==========
@GetMapping("/me")
public ResponseEntity<Map<String, Object>> getCurrentUser(
@AuthenticationPrincipal Jwt jwt) {
return ResponseEntity.ok(Map.of(
"username", jwt.getClaimAsString("preferred_username"),
"email", jwt.getClaimAsString("email"),
"roles", jwt.getClaimAsMap("realm_access"),
"token_id", jwt.getId()
));
}
// ========== Role-based Endpoints ==========
@GetMapping("/users")
@PreAuthorize("hasRole('USER') or hasRole('ADMIN')")
public ResponseEntity<Map<String, String>> getUsers() {
return ResponseEntity.ok(Map.of(
"message", "User list - accessible by USER and ADMIN roles"
));
}
@PostMapping("/users")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Map<String, String>> createUser(
@RequestBody Map<String, String> user) {
return ResponseEntity.ok(Map.of(
"message", "User created - ADMIN only",
"username", user.getOrDefault("username", "unknown")
));
}
@DeleteMapping("/users/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Map<String, String>> deleteUser(@PathVariable String id) {
return ResponseEntity.ok(Map.of(
"message", "User deleted",
"userId", id
));
}
// ========== Admin Endpoints ==========
@GetMapping("/admin/dashboard")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Map<String, String>> adminDashboard() {
return ResponseEntity.ok(Map.of(
"message", "Admin Dashboard - ADMIN only"
));
}
// ========== Advanced Authorization ==========
// SpEL expression kiểm tra nhiều điều kiện
@PutMapping("/users/{id}")
@PreAuthorize("hasRole('ADMIN') or " +
"(hasRole('USER') and #jwt.getClaimAsString('preferred_username') == #id)")
public ResponseEntity<Map<String, String>> updateUser(
@PathVariable String id,
@AuthenticationPrincipal Jwt jwt,
@RequestBody Map<String, String> updates) {
return ResponseEntity.ok(Map.of(
"message", "User updated",
"userId", id,
"updatedBy", jwt.getClaimAsString("preferred_username")
));
}
// Kiểm tra client role
@GetMapping("/reports")
@PreAuthorize("hasAuthority('ROLE_client_admin')")
public ResponseEntity<Map<String, String>> getReports() {
return ResponseEntity.ok(Map.of(
"message", "Reports - requires client_admin role"
));
}
}
2.6 CORS Configuration
When the frontend (React/Angular) calls the backend API, CORS configuration is required:
package com.example.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration
public class CorsConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of(
"http://localhost:3000", // React dev
"http://localhost:4200" // Angular dev
));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
}
Add CORS to SecurityFilterChain:
// Trong SecurityConfig.securityFilterChain()
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// ... các config khác
2.7 Handling Token Expiration
Spring Security automatically validates exp claim. When the token expires, the server returns HTTP 401:
package com.example.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.time.Instant;
import java.util.Map;
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
Map<String, Object> body = Map.of(
"status", 401,
"error", "Unauthorized",
"message", authException.getMessage(),
"path", request.getRequestURI(),
"timestamp", Instant.now().toString()
);
objectMapper.writeValue(response.getOutputStream(), body);
}
}
Register in SecurityFilterChain:
// Trong SecurityConfig
@Autowired
private CustomAuthenticationEntryPoint authEntryPoint;
// Trong securityFilterChain()
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
.authenticationEntryPoint(authEntryPoint)
)
3. Quarkus + Keycloak Integration
3.1 Quarkus OIDC Extension
Quarkus provides the quarkus-oidc extension that integrates with Keycloak:
<!-- pom.xml -->
<dependencies>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-oidc</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest</artifactId>
</dependency>
<!-- Optional: Keycloak Authorization Policy Enforcer -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-keycloak-authorization</artifactId>
</dependency>
<!-- Test -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-test-keycloak-server</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
3.2 Application Properties
# src/main/resources/application.properties
# ===== OIDC Configuration =====
quarkus.oidc.auth-server-url=http://localhost:8080/realms/my-realm
quarkus.oidc.client-id=my-quarkus-client
quarkus.oidc.credentials.secret=my-client-secret
# Application type: service (Resource Server) hoặc web-app (OIDC login)
quarkus.oidc.application-type=service
# Token verification
quarkus.oidc.token.issuer=http://localhost:8080/realms/my-realm
quarkus.oidc.token.audience=my-quarkus-client
# Role mapping - Keycloak roles source
quarkus.oidc.roles.role-claim-path=realm_access/roles
quarkus.oidc.roles.source=accesstoken
# ===== HTTP Configuration =====
quarkus.http.port=8081
quarkus.http.cors=true
quarkus.http.cors.origins=http://localhost:3000,http://localhost:4200
quarkus.http.cors.methods=GET,POST,PUT,DELETE,OPTIONS
quarkus.http.cors.headers=Authorization,Content-Type
3.3 REST Resource with @RolesAllowed
package com.example.resource;
import io.quarkus.security.Authenticated;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.annotation.security.PermitAll;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.Map;
import java.util.Set;
import org.eclipse.microprofile.jwt.JsonWebToken;
@Path("/api")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class DemoResource {
@Inject
SecurityIdentity securityIdentity;
@Inject
JsonWebToken jwt;
// ========== Public ==========
@GET
@Path("/public/health")
@PermitAll
public Response health() {
return Response.ok(Map.of("status", "UP")).build();
}
// ========== Authenticated ==========
@GET
@Path("/me")
@Authenticated
public Response getCurrentUser() {
return Response.ok(Map.of(
"username", jwt.getClaim("preferred_username"),
"email", jwt.getClaim("email"),
"roles", securityIdentity.getRoles(),
"token_id", jwt.getTokenID()
)).build();
}
// ========== Role-based ==========
@GET
@Path("/users")
@RolesAllowed({"USER", "ADMIN"})
public Response getUsers() {
return Response.ok(Map.of(
"message", "User list - USER and ADMIN roles"
)).build();
}
@POST
@Path("/users")
@RolesAllowed("ADMIN")
public Response createUser(Map<String, String> user) {
return Response.ok(Map.of(
"message", "User created",
"username", user.getOrDefault("username", "unknown")
)).build();
}
@GET
@Path("/admin/dashboard")
@RolesAllowed("ADMIN")
public Response adminDashboard() {
return Response.ok(Map.of(
"message", "Admin Dashboard - ADMIN only",
"identity", securityIdentity.getPrincipal().getName()
)).build();
}
}
3.4 Multi-tenant OIDC Configuration
Quarkus supports multi-tenant OIDC for SaaS systems that need to connect multiple Keycloak realms:
# application.properties - Multi-tenant setup
# Default tenant
quarkus.oidc.auth-server-url=http://localhost:8080/realms/default-realm
quarkus.oidc.client-id=default-client
quarkus.oidc.application-type=service
# Tenant A
quarkus.oidc.tenant-a.auth-server-url=http://localhost:8080/realms/tenant-a
quarkus.oidc.tenant-a.client-id=tenant-a-client
quarkus.oidc.tenant-a.credentials.secret=tenant-a-secret
quarkus.oidc.tenant-a.application-type=service
# Tenant B
quarkus.oidc.tenant-b.auth-server-url=http://localhost:8080/realms/tenant-b
quarkus.oidc.tenant-b.client-id=tenant-b-client
quarkus.oidc.tenant-b.credentials.secret=tenant-b-secret
quarkus.oidc.tenant-b.application-type=service
package com.example.config;
import io.quarkus.oidc.OidcTenantConfig;
import io.quarkus.oidc.TenantResolver;
import io.vertx.ext.web.RoutingContext;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class CustomTenantResolver implements TenantResolver {
@Override
public String resolve(RoutingContext context) {
// Resolve tenant từ request path
String path = context.request().path();
if (path.startsWith("/api/tenant-a")) {
return "tenant-a";
}
if (path.startsWith("/api/tenant-b")) {
return "tenant-b";
}
// Hoặc resolve từ header
String tenantHeader = context.request().getHeader("X-Tenant-ID");
if (tenantHeader != null) {
return tenantHeader;
}
// Default tenant
return null;
}
}
3.5 Keycloak Authorization Policy Enforcer
Use quarkus-keycloak-authorization to enforce Keycloak Authorization Services policies:
# application.properties
quarkus.keycloak.policy-enforcer.enable=true
quarkus.keycloak.policy-enforcer.enforcement-mode=ENFORCING
# Policy paths
quarkus.keycloak.policy-enforcer.paths.users.path=/api/users/*
quarkus.keycloak.policy-enforcer.paths.users.enforcement-mode=ENFORCING
quarkus.keycloak.policy-enforcer.paths.admin.path=/api/admin/*
quarkus.keycloak.policy-enforcer.paths.admin.enforcement-mode=ENFORCING
quarkus.keycloak.policy-enforcer.paths.public.path=/api/public/*
quarkus.keycloak.policy-enforcer.paths.public.enforcement-mode=DISABLED
4. Testing with Testcontainers
4.1 Spring Boot + Testcontainers Keycloak
Use testcontainers-keycloak to run real Keycloak in integration tests:
package com.example;
import com.github.dasniko.testcontainers.keycloak.KeycloakContainer;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest
@AutoConfigureMockMvc
@Testcontainers
class KeycloakIntegrationTest {
@Container
static KeycloakContainer keycloak = new KeycloakContainer("quay.io/keycloak/keycloak:25.0")
.withRealmImportFile("test-realm.json");
@Autowired
MockMvc mockMvc;
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.security.oauth2.resourceserver.jwt.issuer-uri",
() -> keycloak.getAuthServerUrl() + "/realms/test-realm");
registry.add("spring.security.oauth2.resourceserver.jwt.jwk-set-uri",
() -> keycloak.getAuthServerUrl()
+ "/realms/test-realm/protocol/openid-connect/certs");
}
static String adminToken;
static String userToken;
@BeforeAll
static void obtainTokens() {
// Lấy admin token
adminToken = getAccessToken("admin-user", "admin-pass");
// Lấy user token
userToken = getAccessToken("regular-user", "user-pass");
}
static String getAccessToken(String username, String password) {
// Sử dụng Keycloak Admin Client hoặc HTTP request
// để lấy token từ Keycloak container
String tokenEndpoint = keycloak.getAuthServerUrl()
+ "/realms/test-realm/protocol/openid-connect/token";
// HTTP POST to token endpoint
// grant_type=password&client_id=test-client
// &username=...&password=...
// Return access_token from response
// (Implementation chi tiết sử dụng RestTemplate hoặc WebClient)
return ""; // Placeholder
}
@Test
void publicEndpoint_shouldReturnOk() throws Exception {
mockMvc.perform(get("/api/public/health"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("UP"));
}
@Test
void protectedEndpoint_withoutToken_shouldReturn401() throws Exception {
mockMvc.perform(get("/api/me"))
.andExpect(status().isUnauthorized());
}
@Test
void protectedEndpoint_withValidToken_shouldReturnOk() throws Exception {
mockMvc.perform(get("/api/me")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.username").exists());
}
@Test
void adminEndpoint_withUserToken_shouldReturn403() throws Exception {
mockMvc.perform(get("/api/admin/dashboard")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken))
.andExpect(status().isForbidden());
}
@Test
void adminEndpoint_withAdminToken_shouldReturnOk() throws Exception {
mockMvc.perform(get("/api/admin/dashboard")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").exists());
}
}
4.2 Test Realm JSON
Create file src/test/resources/test-realm.json to import realm for test:
{
"realm": "test-realm",
"enabled": true,
"clients": [
{
"clientId": "test-client",
"enabled": true,
"publicClient": true,
"directAccessGrantsEnabled": true,
"redirectUris": ["*"]
}
],
"roles": {
"realm": [
{ "name": "ADMIN", "composite": false },
{ "name": "USER", "composite": false }
]
},
"users": [
{
"username": "admin-user",
"enabled": true,
"credentials": [
{ "type": "password", "value": "admin-pass", "temporary": false }
],
"realmRoles": ["ADMIN", "USER"]
},
{
"username": "regular-user",
"enabled": true,
"credentials": [
{ "type": "password", "value": "user-pass", "temporary": false }
],
"realmRoles": ["USER"]
}
]
}
4.3 Unit Test with Mock JWT
For unit tests without real Keycloak, use @WithMockUser or custom JWT:
package com.example;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Map;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest
@AutoConfigureMockMvc
class MockJwtTest {
@Autowired
MockMvc mockMvc;
@Test
void testWithMockJwt_AdminRole() throws Exception {
mockMvc.perform(get("/api/admin/dashboard")
.with(jwt()
.jwt(builder -> builder
.claim("preferred_username", "test-admin")
.claim("email", "[email protected]")
.claim("realm_access", Map.of(
"roles", List.of("ADMIN", "USER")
))
)
.authorities(new KeycloakRoleConverter())
))
.andExpect(status().isOk());
}
@Test
void testWithMockJwt_UserRole_AccessDenied() throws Exception {
mockMvc.perform(get("/api/admin/dashboard")
.with(jwt()
.jwt(builder -> builder
.claim("preferred_username", "test-user")
.claim("realm_access", Map.of(
"roles", List.of("USER")
))
)
.authorities(new KeycloakRoleConverter())
))
.andExpect(status().isForbidden());
}
}
5. Best Practices and Troubleshooting
5.1 Best Practices
| # | Practice | Description |
|---|---|---|
| 1 | Use Realm Roles | Priority realm_access.roles for simple authorization |
| 2 | Stateless sessions | Always use SessionCreationPolicy.STATELESS for REST API |
| 3 | Disable CSRF for API | CSRF is not needed when using JWT Bearer token |
| 4 | Token validation caching | Spring Security automatically caches JWK Set, no need to call Keycloak for each request |
| 5 | Claim-based authorization | Use @PreAuthorize with SpEL for complex logic |
| 6 | Error handling | Custom AuthenticationEntryPoint for uniform response format |
| 7 | Test coverage | Combining unit tests (mock JWT) and integration tests (Testcontainers) |
5.2 Troubleshooting Common Issues
Lỗi: "An error occurred while attempting to decode the Jwt"
→ Kiểm tra issuer-uri có đúng realm URL không
→ Đảm bảo Keycloak server đang chạy và accessible
Lỗi: "Jwt expired"
→ Token đã hết hạn, client cần refresh token
→ Kiểm tra Access Token Lifespan trong Keycloak Realm Settings
Lỗi: "Access Denied" dù đúng role
→ Kiểm tra role name trong JWT có match với @PreAuthorize không
→ Debug: log SecurityContext.getAuthentication().getAuthorities()
→ Kiểm tra KeycloakRoleConverter có prefix "ROLE_" đúng không
Lỗi: "CORS error" khi gọi từ frontend
→ Kiểm tra CorsConfiguration có include frontend origin không
→ Đảm bảo Authorization header được allow
5.3 Curl Testing Commands
# 1. Lấy Access Token từ Keycloak
ACCESS_TOKEN=$(curl -s -X POST \
"http://localhost:8080/realms/my-realm/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=password" \
-d "client_id=my-client" \
-d "username=admin" \
-d "password=admin" \
| jq -r '.access_token')
echo $ACCESS_TOKEN
# 2. Decode JWT (kiểm tra claims)
echo $ACCESS_TOKEN | cut -d'.' -f2 | base64 -d 2>/dev/null | jq .
# 3. Gọi public endpoint
curl -s http://localhost:8081/api/public/health | jq .
# 4. Gọi protected endpoint với token
curl -s http://localhost:8081/api/me \
-H "Authorization: Bearer $ACCESS_TOKEN" | jq .
# 5. Gọi admin endpoint
curl -s http://localhost:8081/api/admin/dashboard \
-H "Authorization: Bearer $ACCESS_TOKEN" | jq .
# 6. Gọi endpoint không có token (expect 401)
curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/api/me
6. Summary
| Criteria | Spring Boot 3 | Quarkus |
|---|---|---|
| Library | spring-boot-starter-oauth2-resource-server | quarkus-oidc |
| Role mapping | Custom JwtAuthenticationConverter | Config roles.role-claim-path |
| Authorization | @PreAuthorize, hasRole() | @RolesAllowed, @Authenticated |
| Multi-tenant | Custom implementation | Built-in TenantResolver |
| Policy Enforcer | Manual | quarkus-keycloak-authorization |
| Testing | Testcontainers + Mock JWT | quarkus-test-keycloak-server |
| Startup time | ~2-5s | ~0.5-1s (native ~0.01s) |
In the next article, we will learn how to integrate Keycloak with frontend frameworks (React, Angular) and Node.js backend.