Use Keycloak Spring Adapter with Spring Boot 3

You can’t use Keycloak adapters with spring-boot 3 for the reason you found, plus a few others related to transitive dependencies. As most Keycloak adapters were deprecated, it is very likely that no update will be published to fix that.

Directly use spring-security OAuth2 instead. Don’t panic, it’s an easy task with spring-boot.

“Official” staters

There are 2 spring-boot starters to ease the creation of all the necessary beans:

  • spring-boot-starter-oauth2-resource-server if app is a REST API (serves resources, not the UI to manipulate it: @RestController and @Controller with @ResponseBody).
  • spring-boot-starter-oauth2-client if your app serves UI with Thymeleaf or alike (@Controller with methods returning template names). Client configuration can also be used to configure WebClient (use client-credentials or forward original access-token).

Here is how to configure a resource-server with a unique Keycloak realm as authorization-server:

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class WebSecurityConfig {

    public interface Jwt2AuthoritiesConverter extends Converter<Jwt, Collection<? extends GrantedAuthority>> {
    }

    @SuppressWarnings("unchecked")
    @Bean
    public Jwt2AuthoritiesConverter authoritiesConverter() {
        // This is a converter for roles as embedded in the JWT by a Keycloak server
        // Roles are taken from both realm_access.roles & resource_access.{client}.roles
        return jwt -> {
            final var realmAccess = (Map<String, Object>) jwt.getClaims().getOrDefault("realm_access", Map.of());
            final var realmRoles = (Collection<String>) realmAccess.getOrDefault("roles", List.of());

            final var resourceAccess = (Map<String, Object>) jwt.getClaims().getOrDefault("resource_access", Map.of());
            // We assume here you have "spring-addons-confidential" and "spring-addons-public" clients configured with "client roles" mapper in Keycloak
            final var confidentialClientAccess = (Map<String, Object>) resourceAccess.getOrDefault("spring-addons-confidential", Map.of());
            final var confidentialClientRoles = (Collection<String>) confidentialClientAccess.getOrDefault("roles", List.of());
            final var publicClientAccess = (Map<String, Object>) resourceAccess.getOrDefault("spring-addons-public", Map.of());
            final var publicClientRoles = (Collection<String>) publicClientAccess.getOrDefault("roles", List.of());

            return Stream.concat(realmRoles.stream(), Stream.concat(confidentialClientRoles.stream(), publicClientRoles.stream()))
                    .map(SimpleGrantedAuthority::new).toList();
        };
    }

    public interface Jwt2AuthenticationConverter extends Converter<Jwt, AbstractAuthenticationToken> {
    }

    @Bean
    public Jwt2AuthenticationConverter authenticationConverter(Jwt2AuthoritiesConverter authoritiesConverter) {
        return jwt -> new JwtAuthenticationToken(jwt, authoritiesConverter.convert(jwt));
    }

    @Bean
    public SecurityFilterChain apiFilterChain(HttpSecurity http, Converter<JWT, AbstractAuthenticationToken> authenticationConverter, ServerProperties serverProperties)
            throws Exception {

        // Enable OAuth2 with custom authorities mapping
        http.oauth2ResourceServer().jwt().jwtAuthenticationConverter(authenticationConverter);

        // Enable anonymous
        http.anonymous();

        // Enable and configure CORS
        http.cors().configurationSource(corsConfigurationSource());

        // State-less session (state in access-token only)
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        // Disable CSRF because of disabled sessions
        http.csrf().disable();

        // Return 401 (unauthorized) instead of 302 (redirect to login) when authorization is missing or invalid
        http.exceptionHandling().authenticationEntryPoint((request, response, authException) -> {
            response.addHeader(HttpHeaders.WWW_AUTHENTICATE, "Basic realm=\"Restricted Content\"");
            response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
        });

        // If SSL enabled, disable http (https only)
        if (serverProperties.getSsl() != null && serverProperties.getSsl().isEnabled()) {
            http.requiresChannel().anyRequest().requiresSecure();
        }

        // Route security: authenticated to all routes but actuator and Swagger-UI
        // @formatter:off
        http.authorizeRequests()
            .antMatchers("/actuator/health/readiness", "/actuator/health/liveness", "/v3/api-docs/**").permitAll()
            .anyRequest().authenticated();
        // @formatter:on

        return http.build();
    }

    private CorsConfigurationSource corsConfigurationSource() {
        // Very permissive CORS config...
        final var configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(Arrays.asList("*"));
        configuration.setAllowedMethods(Arrays.asList("*"));
        configuration.setAllowedHeaders(Arrays.asList("*"));
        configuration.setExposedHeaders(Arrays.asList("*"));

        // Limited to API routes (neither actuator nor Swagger-UI)
        final var source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/greet/**", configuration);

        return source;
    }
}
spring.security.oauth2.resourceserver.jwt.issuer-uri=https://localhost:8443/realms/master
spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://localhost:8443/realms/master/protocol/openid-connect/certs

spring-addons starters

As above configuration is quite verbose (things get even more complicated if you are in multi-tenancy scenario), error prone (easy to de-synchronise CSRF protection and sessions configuration for instance) and invasive (would be easier to maintain if all of that conf was controlled from properties file), I wrote wrappers arround “official” starter. It is very thin (each is composed of three files only) and greatly simplifies resource-servers configuration:

<dependency>
    <groupId>com.c4-soft.springaddons</groupId>
    <!-- replace "webmvc" with "weblux" if your app is reactive -->
    <!-- replace "jwt" with "introspecting" to use token introspection instead of JWT decoding -->
    <artifactId>spring-addons-webmvc-jwt-resource-server</artifactId>
    <!-- this version is to be used with spring-boot 3.0.1, use 5.4.x for spring-boot 2.6.x or before -->
    <version>6.0.10</version>
</dependency>
@Configuration
@EnableMethodSecurity
public static class WebSecurityConfig { }
com.c4-soft.springaddons.security.issuers[0].location=https://localhost:8443/realms/realm1
com.c4-soft.springaddons.security.issuers[0].authorities.claims=realm_access.roles,ressource_access.some-client.roles,ressource_access.other-client.roles


com.c4-soft.springaddons.security.cors[0].path=/some-api

And, as you can guess from this issuers property being an array, you can configure as many OIDC authorization-server instances as you like (multiple realms or instances, even not Keycloak). Bootiful, isn’t it?

Edit: add client configuration

If your Spring application also exposes secured UI elements you want to be accessible with a browser (with OAuth2 login), you’ll need to add a FilterChain with “client” configuration.

Add this to pom.xml

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

that to java conf (this is an additional SecurityFilterChain applying only to the securityMatcher list below, keep the resource-server SecurityFilterChain already defined above for REST endpoints):

    @Order(Ordered.HIGHEST_PRECEDENCE)
    @Bean
    SecurityFilterChain uiFilterChain(HttpSecurity http, ServerProperties serverProperties) throws Exception {

        // @formatter:off
        http.securityMatcher(new OrRequestMatcher(
                // add path to your UI elements instead
                new AntPathRequestMatcher("/ui/**"),
                // those two are required to access Spring generated login page
                // and OAuth2 client callback endpoints
                new AntPathRequestMatcher("/login/**"),
                new AntPathRequestMatcher("/oauth2/**")));

        http.oauth2Login();
        http.authorizeHttpRequests()
                .requestMatchers("/ui/index.html").permitAll()
                .requestMatchers("/login/**").permitAll()
                .requestMatchers("/oauth2/**").permitAll()
                .anyRequest().authenticated();
        // @formatter:on

        // If SSL enabled, disable http (https only)
        if (serverProperties.getSsl() != null && serverProperties.getSsl().isEnabled()) {
            http.requiresChannel().anyRequest().requiresSecure();
        }

        // Many defaults are kept compared to API filter-chain:
        // - sessions (and CSRF protection) are enabled
        // - unauthorized requests to secured resources will be redirected to login (302 to login is Spring's default response when access is denied)

        return http.build();
    }

and last client properties:

spring.security.oauth2.client.provider.keycloak.issuer-uri=https://localhost:8443/realms/master

spring.security.oauth2.client.registration.spring-addons-public.provider=keycloak
spring.security.oauth2.client.registration.spring-addons-public.client-name=spring-addons-public
spring.security.oauth2.client.registration.spring-addons-public.client-id=spring-addons-public
spring.security.oauth2.client.registration.spring-addons-public.scope=openid,offline_access,profile
spring.security.oauth2.client.registration.spring-addons-public.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.spring-addons-public.redirect-uri=http://bravo-ch4mp:8080/login/oauth2/code/spring-addons-public

Leave a Comment