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

Lesson 20: Integrating Spring Boot and Quarkus

Integrate Keycloak with Spring Boot 3 using Spring Security OAuth2 Resource Server (spring-boot-starter-oauth2-resource-server), configure JWT validation, custom JwtAuthenticationConverter, role mapping from realm_access/resource_access claims, Spring Security method-level authorization (@PreAuthorize), integrate Quarkus with quarkus-oidc extension, multi-tenant configuration and testing with Testcontainers.

🔒 DevSecOps — Lesson 20 Lesson 20: Integrating Spring Boot and Quarkus

Keycloak from Basic to Advanced

Part 6: Integrating practical applications

xdev.asia

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.

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

PropertyDescription
issuer-uriURI of Keycloak realm, used to validate iss claim in JWT
jwk-set-uriEndpoint 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

#PracticeDescription
1Use Realm RolesPriority realm_access.roles for simple authorization
2Stateless sessionsAlways use SessionCreationPolicy.STATELESS for REST API
3Disable CSRF for APICSRF is not needed when using JWT Bearer token
4Token validation cachingSpring Security automatically caches JWK Set, no need to call Keycloak for each request
5Claim-based authorizationUse @PreAuthorize with SpEL for complex logic
6Error handlingCustom AuthenticationEntryPoint for uniform response format
7Test coverageCombining 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

CriteriaSpring 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)

In the next article, we will learn how to integrate Keycloak with frontend frameworks (React, Angular) and Node.js backend.