Chuyển đến nội dung chính

Bài 20: Tích hợp Spring Boot và Quarkus

Tích hợp Keycloak với Spring Boot 3 sử dụng Spring Security OAuth2 Resource Server (spring-boot-starter-oauth2-resource-server), cấu hình JWT validation, custom JwtAuthenticationConverter, role mapping từ realm_access/resource_access claims, Spring Security method-level authorization (@PreAuthorize), tích hợp Quarkus với quarkus-oidc extension, multi-tenant configuration và testing với Testcontainers.

🔒 DevSecOps — Bài 20 Bài 20: Tích hợp Spring Boot và Quarkus

Keycloak từ Cơ bản đến Nâng cao

Phần 6: Tích hợp ứng dụng thực tế

xdev.asia

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.

FrameworkLibraryApproach
Spring Boot 3spring-boot-starter-oauth2-resource-serverJWT Resource Server
Quarkusquarkus-oidcOIDC 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:

PropertyMô tả
issuer-uriURI của Keycloak realm, dùng để validate iss claim trong JWT
jwk-set-uriEndpoint 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

#PracticeMô tả
1Sử dụng Realm RolesƯu tiên realm_access.roles cho authorization đơn giản
2Stateless sessionsLuôn dùng SessionCreationPolicy.STATELESS cho REST API
3Tắt CSRF cho APICSRF không cần thiết khi sử dụng JWT Bearer token
4Token validation cachingSpring Security tự cache JWK Set, không cần gọi Keycloak mỗi request
5Claim-based authorizationSử dụng @PreAuthorize với SpEL cho logic phức tạp
6Error handlingCustom AuthenticationEntryPoint cho response format thống nhất
7Test coverageKế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 3Quarkus
Libraryspring-boot-starter-oauth2-resource-serverquarkus-oidc
Role mappingCustom JwtAuthenticationConverterConfig roles.role-claim-path
Authorization@PreAuthorize, hasRole()@RolesAllowed, @Authenticated
Multi-tenantCustom implementationBuilt-in TenantResolver
Policy EnforcerManualquarkus-keycloak-authorization
TestingTestcontainers + Mock JWTquarkus-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.