Spring Boot Integration

Spring Boot Integration

This guide covers how to integrate Skycloak authentication and authorization into your Spring Boot applications using Spring Security OAuth2.

⚠️

Important: Keycloak Spring Boot Adapter is Deprecated

The keycloak-spring-boot-starter adapter has been officially deprecated by the Keycloak team and is no longer maintained. It does not work with Spring Boot 3.x.

Recommended approach: Use Spring Security OAuth2 (shown below) for all new projects and for upgrading existing applications.

Prerequisites

  • Spring Boot 3.x application (recommended)
  • Java 17 or higher (Java 21 recommended for new projects)
  • Skycloak cluster with configured realm and client
  • Basic understanding of Spring Security

Quick Start

1. Add Dependencies

Add Spring Security OAuth2 dependencies to your pom.xml:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

Or for Gradle (build.gradle.kts):

implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server")

2. Configure Application Properties

Add the following to your application.yml:

spring:
  security:
    oauth2:
      client:
        registration:
          skycloak:
            client-id: your-client-id
            client-secret: your-client-secret
            authorization-grant-type: authorization_code
            redirect-uri: '{baseUrl}/login/oauth2/code/{registrationId}'
            scope: openid, profile, email
        provider:
          skycloak:
            issuer-uri: https://your-cluster-id.app.skycloak.io/realms/your-realm
      resourceserver:
        jwt:
          issuer-uri: https://your-cluster-id.app.skycloak.io/realms/your-realm

3. Security Configuration

Create your security configuration class:

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasAuthority("ROLE_ADMIN")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt
                    .jwtAuthenticationConverter(jwtAuthenticationConverter())
                )
            )
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            );

        return http.build();
    }

    @Bean
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
        grantedAuthoritiesConverter.setAuthoritiesClaimName("realm_access.roles");
        grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");

        JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
        return jwtAuthenticationConverter;
    }
}

Controller Examples

Protected REST API

@RestController
@RequestMapping("/api")
public class ApiController {

    @GetMapping("/user/profile")
    @PreAuthorize("hasRole('USER')")
    public ResponseEntity<UserProfile> getUserProfile(Authentication authentication) {
        Jwt jwt = (Jwt) authentication.getPrincipal();

        UserProfile profile = new UserProfile();
        profile.setId(jwt.getSubject());
        profile.setUsername(jwt.getClaimAsString("preferred_username"));
        profile.setEmail(jwt.getClaimAsString("email"));
        profile.setRoles(extractRoles(jwt));

        return ResponseEntity.ok(profile);
    }

    @PostMapping("/admin/users")
    @PreAuthorize("hasRole('ADMIN')")
    public ResponseEntity<User> createUser(@RequestBody CreateUserRequest request) {
        // Admin-only endpoint
        User user = userService.createUser(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(user);
    }

    private List<String> extractRoles(Jwt jwt) {
        Map<String, Object> realmAccess = jwt.getClaim("realm_access");
        if (realmAccess != null && realmAccess.containsKey("roles")) {
            return (List<String>) realmAccess.get("roles");
        }
        return Collections.emptyList();
    }
}

Method-Level Security

@Service
public class UserService {

    @PreAuthorize("hasRole('ADMIN') or #username == authentication.name")
    public UserData getUserData(String username) {
        // Users can access their own data, admins can access any
        return userRepository.findByUsername(username);
    }

    @PostAuthorize("returnObject.owner == authentication.name")
    public Document getDocument(Long documentId) {
        // Ensures users can only retrieve their own documents
        return documentRepository.findById(documentId);
    }

    @PreAuthorize("@securityService.hasPermission(#resourceId, 'WRITE')")
    public void updateResource(Long resourceId, ResourceData data) {
        // Custom permission checking
        resourceRepository.update(resourceId, data);
    }
}

Advanced Configuration

Custom JWT Claims Converter

@Component
public class KeycloakJwtAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> {

    @Override
    public AbstractAuthenticationToken convert(Jwt jwt) {
        Collection<GrantedAuthority> authorities = extractAuthorities(jwt);
        Map<String, Object> claims = jwt.getClaims();

        // Extract custom claims
        String tenant = jwt.getClaimAsString("tenant");
        List<String> groups = jwt.getClaimAsStringList("groups");

        // Create custom authentication token
        CustomAuthenticationToken token = new CustomAuthenticationToken(
            jwt.getSubject(),
            authorities,
            tenant,
            groups
        );

        return token;
    }

    private Collection<GrantedAuthority> extractAuthorities(Jwt jwt) {
        Set<GrantedAuthority> authorities = new HashSet<>();

        // Extract realm roles
        Map<String, Object> realmAccess = jwt.getClaim("realm_access");
        if (realmAccess != null) {
            List<String> roles = (List<String>) realmAccess.get("roles");
            roles.forEach(role -> authorities.add(new SimpleGrantedAuthority("ROLE_" + role)));
        }

        // Extract resource roles
        Map<String, Object> resourceAccess = jwt.getClaim("resource_access");
        if (resourceAccess != null) {
            resourceAccess.forEach((resource, access) -> {
                Map<String, Object> resourceRoles = (Map<String, Object>) access;
                List<String> roles = (List<String>) resourceRoles.get("roles");
                roles.forEach(role -> authorities.add(
                    new SimpleGrantedAuthority(resource + "_" + role)
                ));
            });
        }

        return authorities;
    }
}

Multi-Tenancy Support

@Configuration
public class MultiTenantSecurityConfig {

    @Bean
    public JwtIssuerAuthenticationManagerResolver authenticationManagerResolver() {
        Map<String, AuthenticationManager> authenticationManagers = new ConcurrentHashMap<>();

        return new JwtIssuerAuthenticationManagerResolver(
            issuer -> authenticationManagers.computeIfAbsent(issuer, this::createAuthenticationManager)
        );
    }

    private AuthenticationManager createAuthenticationManager(String issuer) {
        JwtDecoder jwtDecoder = JwtDecoders.fromIssuerLocation(issuer);

        JwtAuthenticationProvider provider = new JwtAuthenticationProvider(jwtDecoder);
        provider.setJwtAuthenticationConverter(jwtAuthenticationConverter());

        return provider::authenticate;
    }
}

Service-to-Service Authentication

@Configuration
public class ServiceAuthenticationConfig {

    @Value("${keycloak.client-id}")
    private String clientId;

    @Value("${keycloak.client-secret}")
    private String clientSecret;

    @Value("${keycloak.token-uri}")
    private String tokenUri;

    @Bean
    public OAuth2AuthorizedClientManager authorizedClientManager(
            ClientRegistrationRepository clientRegistrationRepository,
            OAuth2AuthorizedClientRepository authorizedClientRepository) {

        OAuth2AuthorizedClientProvider authorizedClientProvider =
            OAuth2AuthorizedClientProviderBuilder.builder()
                .clientCredentials()
                .build();

        DefaultOAuth2AuthorizedClientManager authorizedClientManager =
            new DefaultOAuth2AuthorizedClientManager(
                clientRegistrationRepository, authorizedClientRepository);

        authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);

        return authorizedClientManager;
    }

    @Bean
    public WebClient webClient(OAuth2AuthorizedClientManager authorizedClientManager) {
        ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2Client =
            new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);

        oauth2Client.setDefaultClientRegistrationId("skycloak");

        return WebClient.builder()
            .filter(oauth2Client)
            .build();
    }
}

Using the WebClient for authenticated requests:

@Service
public class ExternalApiService {

    private final WebClient webClient;

    public ExternalApiService(WebClient webClient) {
        this.webClient = webClient;
    }

    public Mono<ApiResponse> callProtectedApi() {
        return webClient
            .get()
            .uri("https://api.example.com/protected")
            .retrieve()
            .bodyToMono(ApiResponse.class);
    }
}

Testing

Integration Tests

@SpringBootTest
@AutoConfigureMockMvc
@TestPropertySource(properties = {
    "spring.security.oauth2.resourceserver.jwt.issuer-uri=https://test.skycloak.io/realms/test"
})
class ApiControllerIntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    @WithMockJwtAuth(authorities = "ROLE_USER", claims = @OpenIdClaims(sub = "user123"))
    void getUserProfile_WithValidToken_ReturnsProfile() throws Exception {
        mockMvc.perform(get("/api/user/profile"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.id").value("user123"));
    }

    @Test
    void getProtectedEndpoint_WithoutToken_ReturnsUnauthorized() throws Exception {
        mockMvc.perform(get("/api/user/profile"))
            .andExpect(status().isUnauthorized());
    }
}

Mock JWT Authentication

@TestConfiguration
public class SecurityTestConfig {

    @Bean
    public JwtDecoder jwtDecoder() {
        return mock(JwtDecoder.class);
    }

    @Bean
    public JwtAuthenticationToken mockJwtAuthentication() {
        Jwt jwt = Jwt.withTokenValue("mock-token")
            .header("alg", "RS256")
            .claim("sub", "test-user")
            .claim("preferred_username", "testuser")
            .claim("realm_access", Map.of("roles", List.of("USER", "ADMIN")))
            .build();

        return new JwtAuthenticationToken(jwt);
    }
}

Production Best Practices

1. Token Validation Caching

@Configuration
public class JwtCacheConfig {

    @Bean
    public JwtDecoder cachedJwtDecoder() {
        NimbusJwtDecoder jwtDecoder = JwtDecoders.fromIssuerLocation(issuerUri);

        // Configure caching
        jwtDecoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
            JwtValidators.createDefaultWithIssuer(issuerUri),
            new JwtTimestampValidator(Duration.ofMinutes(5))
        ));

        // Add cache
        return new CachingJwtDecoder(jwtDecoder);
    }
}

2. Circuit Breaker for Token Validation

@Component
public class ResilientJwtDecoder implements JwtDecoder {

    private final JwtDecoder delegate;
    private final CircuitBreaker circuitBreaker;

    public ResilientJwtDecoder(JwtDecoder delegate) {
        this.delegate = delegate;
        this.circuitBreaker = CircuitBreaker.ofDefaults("jwt-decoder");
        circuitBreaker.getEventPublisher()
            .onError(event -> log.error("JWT decoding error: {}", event.getThrowable().getMessage()));
    }

    @Override
    public Jwt decode(String token) throws JwtException {
        return circuitBreaker.executeSupplier(() -> delegate.decode(token));
    }
}

3. Security Headers

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .headers(headers -> headers
            .frameOptions().deny()
            .xssProtection().and()
            .contentSecurityPolicy("default-src 'self'")
        )
        .cors(cors -> cors.configurationSource(corsConfigurationSource()))
        .csrf(csrf -> csrf
            .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
        );

    return http.build();
}

4. Audit Logging

@Component
@Slf4j
public class SecurityAuditListener {

    @EventListener
    public void handleAuthenticationSuccess(AuthenticationSuccessEvent event) {
        Authentication auth = event.getAuthentication();
        log.info("User {} successfully authenticated", auth.getName());
        auditService.logAuthenticationSuccess(auth);
    }

    @EventListener
    public void handleAuthenticationFailure(AbstractAuthenticationFailureEvent event) {
        log.warn("Authentication failed: {}", event.getException().getMessage());
        auditService.logAuthenticationFailure(event.getException());
    }

    @EventListener
    public void handleAuthorizationFailure(AuthorizationDeniedEvent event) {
        log.warn("Authorization denied for user {}: {}",
            event.getAuthentication().getName(),
            event.getAccessDeniedException().getMessage());
        auditService.logAuthorizationFailure(event);
    }
}

Troubleshooting

Common Issues

  1. 401 Unauthorized Errors

    • Verify token issuer URI matches exactly
    • Check token expiration and clock skew
    • Ensure client credentials are correct
  2. Role Mapping Issues

    • Verify role claim path (realm_access.roles vs resource_access)
    • Check role prefix configuration (ROLE_ prefix)
    • Ensure roles are assigned in Skycloak
  3. CORS Problems

    • Configure allowed origins properly
    • Include Authorization in allowed headers
    • Handle preflight requests

Debug Configuration

logging:
  level:
    org.springframework.security: DEBUG
    org.springframework.security.oauth2: TRACE
    org.keycloak: DEBUG

Next Steps