Zum Inhalt springen

Spring Boot Integration

Dieser Inhalt ist noch nicht in deiner Sprache verfügbar.

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

  • 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

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

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

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;
    }
}
@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();
    }
}
@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);
    }
}
@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;
    }
}
@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;
    }
}
@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);
    }
}
@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());
    }
}
@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);
    }
}
@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);
    }
}
@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));
    }
}
@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();
}
@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);
    }
}
  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
logging:
  level:
    org.springframework.security: DEBUG
    org.springframework.security.oauth2: TRACE
    org.keycloak: DEBUG