1. Tổng quan tích hợp Backend
Keycloak cung cấp cơ chế OAuth2/OIDC chuẩn, cho phép tích hợp với bất kỳ backend framework nào hỗ trợ JWT verification. Trong bài này, chúng ta sẽ tích hợp với hai framework phổ biến nhất trong hệ sinh thái Java: Spring Boot 3 và Quarkus.
| Framework | Library | Approach |
|---|---|---|
| Spring Boot 3 | spring-boot-starter-oauth2-resource-server | JWT Resource Server |
| Quarkus | quarkus-oidc | OIDC Extension |
Kiến trúc tổng quan:
┌──────────┐ ┌───────────┐ ┌──────────────┐
│ 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>
Lưu ý quan trọng: Từ Keycloak 20+, adapter chuyên dụng cho Spring Boot (keycloak-spring-boot-starter) đã bị deprecated. Phương pháp chuẩn hiện tại là sử dụng spring-boot-starter-oauth2-resource-server của Spring Security.
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
Giải thích các thuộc tính:
| Property | Mô tả |
|---|---|
issuer-uri | URI của Keycloak realm, dùng để validate iss claim trong JWT |
jwk-set-uri | Endpoint chứa public keys (JWKS) để 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 lưu roles trong JWT claims theo cấu trúc đặc biệt. Cần custom converter để extract đúng 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();
}
}
Cách hoạt động role mapping:
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 với 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
Khi frontend (React/Angular) gọi API backend, cần cấu hình CORS:
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;
}
}
Thêm CORS vào SecurityFilterChain:
// Trong SecurityConfig.securityFilterChain()
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// ... các config khác
2.7 Xử lý Token Expiration
Spring Security tự động validate exp claim. Khi token hết hạn, server trả về 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);
}
}
Đăng ký trong 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 cung cấp quarkus-oidc extension tích hợp sẵn với 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 với @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 hỗ trợ multi-tenant OIDC cho các hệ thống SaaS cần kết nối nhiều 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
Sử dụng quarkus-keycloak-authorization để 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 với Testcontainers
4.1 Spring Boot + Testcontainers Keycloak
Sử dụng testcontainers-keycloak để chạy Keycloak thật trong 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
Tạo file src/test/resources/test-realm.json để import realm cho 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 với Mock JWT
Cho unit tests không cần Keycloak thật, sử dụng @WithMockUser hoặc 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 và Troubleshooting
5.1 Best Practices
| # | Practice | Mô tả |
|---|---|---|
| 1 | Sử dụng Realm Roles | Ưu tiên realm_access.roles cho authorization đơn giản |
| 2 | Stateless sessions | Luôn dùng SessionCreationPolicy.STATELESS cho REST API |
| 3 | Tắt CSRF cho API | CSRF không cần thiết khi sử dụng JWT Bearer token |
| 4 | Token validation caching | Spring Security tự cache JWK Set, không cần gọi Keycloak mỗi request |
| 5 | Claim-based authorization | Sử dụng @PreAuthorize với SpEL cho logic phức tạp |
| 6 | Error handling | Custom AuthenticationEntryPoint cho response format thống nhất |
| 7 | Test coverage | Kết hợp unit tests (mock JWT) và 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. Tổng kết
| Tiêu chí | 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) |
Trong bài tiếp theo, chúng ta sẽ tìm hiểu cách tích hợp Keycloak với frontend frameworks (React, Angular) và Node.js backend.