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 in early 2022, it is very likely that no update will be published to fix that.

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

In the following, I’ll consider you have a good understanding of OAuth2 concepts and know exactly why you need to configure an OAuth2 client or an OAuth2 resource server. In case of doubt, please refer to the OAuth2 essentials section of my tutorials.

I’ll only detail here the configuration of servlet application as a resource server, and then as a client, for a single Keycloak realm, with and then without Spring Boot Starters of mine. Browse directly to the section you are interested in (but be prepared to write much more code if you don’t want to use “my” starters).

Also refer to my tutorials for different use-cases like:

  • accepting tokens issued by multiple realms or instances (known in advance or dynamically created in a trusted domain)
  • reactive applications (webflux)
  • apps publicly serving both a REST API and a server-side rendered UI to consume it
  • advanced access-control rules
  • BFF pattern

1. OAuth2 Resource Server

App exposes a REST API secured with access tokens. It is consumed by an OAuth2 REST client. A few sample of such clients:

  • another Spring application configured as an OAuth2 client and using WebClient, @FeignClient, RestTemplate or alike
  • development tools like Postman capable of fetching OAuth2 tokens and issuing REST requests
  • Javascript based application configured as a “public” OAuth2 client with a library like angular-auth-oidc-client

1.1. With “my” starters

<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.1.0, use 5.4.x for spring-boot 2.6.x or before -->
    <version>6.1.11</version>
</dependency>
origins: http://localhost:4200
issuer: http://localhost:8442/realms/master

com:
  c4-soft:
    springaddons:
      security:
        cors:
        - path: /**
          allowed-origins: ${origins}
        issuers:
        - location: ${issuer}
          username-claim: preferred_username
          authorities:
          - path: $.realm_access.roles
          - path: $.resource_access.*.roles
        permit-all: 
        - "/actuator/health/readiness"
        - "/actuator/health/liveness"
        - "/v3/api-docs/**"
@Configuration
@EnableMethodSecurity
public static class WebSecurityConfig { }

Nothing more is needed to configure a resource-server with fine tuned CORS policy and authorities mapping. Bootiful, isn’t it?.

As you can guess from the issuer property being an array, this solution is actually compatible with “static” multi-tenancy.

1.2. With just spring-boot-starter-oauth2-resource-server

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
    <!-- used when converting Keycloak roles to Spring authorities -->
    <groupId>com.jayway.jsonpath</groupId>
    <artifactId>json-path</artifactId>
</dependency>
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:8442/realms/master
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public static class WebSecurityConfig {

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http, Converter<Jwt, ? extends AbstractAuthenticationToken> jwtAuthenticationConverter) throws Exception {

        http.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter)));

        // Enable and configure CORS
        http.cors(cors -> cors.configurationSource(corsConfigurationSource("http://localhost:4200")));

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

        // Disable CSRF because of state-less session-management
        http.csrf(csrf -> csrf.disable());

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

        // @formatter:off
        http.authorizeHttpRequests(accessManagement -> accessManagement
            .requestMatchers("/actuator/health/readiness", "/actuator/health/liveness", "/v3/api-docs/**").permitAll()
            .anyRequest().authenticated()
        );
        // @formatter:on

        return http.build();
    }

    private UrlBasedCorsConfigurationSource corsConfigurationSource(String... origins) {
        final var configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(Arrays.asList(origins));
        configuration.setAllowedMethods(List.of("*"));
        configuration.setAllowedHeaders(List.of("*"));
        configuration.setExposedHeaders(List.of("*"));

        final var source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }

    @RequiredArgsConstructor
    static class JwtGrantedAuthoritiesConverter implements Converter<Jwt, Collection<? extends GrantedAuthority>> {

        @Override
        @SuppressWarnings({ "rawtypes", "unchecked" })
        public Collection<? extends GrantedAuthority> convert(Jwt jwt) {
            return Stream.of("$.realm_access.roles", "$.resource_access.*.roles").flatMap(claimPaths -> {
                Object claim;
                try {
                    claim = JsonPath.read(jwt.getClaims(), claimPaths);
                } catch (PathNotFoundException e) {
                    claim = null;
                }
                if (claim == null) {
                    return Stream.empty();
                }
                if (claim instanceof String claimStr) {
                    return Stream.of(claimStr.split(","));
                }
                if (claim instanceof String[] claimArr) {
                    return Stream.of(claimArr);
                }
                if (Collection.class.isAssignableFrom(claim.getClass())) {
                    final var iter = ((Collection) claim).iterator();
                    if (!iter.hasNext()) {
                        return Stream.empty();
                    }
                    final var firstItem = iter.next();
                    if (firstItem instanceof String) {
                        return (Stream<String>) ((Collection) claim).stream();
                    }
                    if (Collection.class.isAssignableFrom(firstItem.getClass())) {
                        return (Stream<String>) ((Collection) claim).stream().flatMap(colItem -> ((Collection) colItem).stream()).map(String.class::cast);
                    }
                }
                return Stream.empty();
            })
            /* Insert some transformation here if you want to add a prefix like "ROLE_" or force upper-case authorities */
            .map(SimpleGrantedAuthority::new)
            .map(GrantedAuthority.class::cast).toList();
        }
    }

    @Component
    @RequiredArgsConstructor
    static class SpringAddonsJwtAuthenticationConverter implements Converter<Jwt, JwtAuthenticationToken> {

        @Override
        public JwtAuthenticationTokenconvert(Jwt jwt) {
            final var authorities = new JwtGrantedAuthoritiesConverter().convert(jwt);
            final String username = JsonPath.read(jwt.getClaims(), "preferred_username");
            return new JwtAuthenticationToken(jwt, authorities, username);
        }
    }
}

In addition to being much more verbose than preceding one, this solution is also less flexible:

  • not adapted to multi-tenancy (multiple Keycloak realms or instances)
  • hardcoded allowed origins
  • hardcoded claim names to fetch autorities from
  • hardcoded “permitAll” path matchers

2. OAuth2 Client

App exposes any kind of resources secured with sessions (not access tokens). It is consumed directly by a browser (or any other user agent capable of maintaining a session) without the need of a scripting language or OAuth2 client lib (authorization-code flow, logout and token storage are handled by Spring on the server). Common uses-cases are:

  • applications with server-side rendered UI (with Thymeleaf, JSF, or whatever)
  • spring-cloud-gateway used as Backend For Frontend: configured as OAuth2 client with TokenRelay filter (hides OAuth2 tokens from the browser and replaces session cookie with an access token before forwarding a request to downstream resource server(s))

Note that the Back-Channel Logout is not implemented by Spring yet. If you need it, use “my” starters (or copy from it).

2.1. With “my” starters

<dependency>
    <groupId>com.c4-soft.springaddons</groupId>
    <!-- replace "webmvc" with "weblux" if your app is reactive -->
    <artifactId>spring-addons-webmvc-client</artifactId>
    <version>6.1.11</version>
</dependency>
issuer: http://localhost:8442/realms/master
client-id: spring-addons-confidential
client-secret: change-me
client-uri: http://localhost:8080

spring:
  security:
    oauth2:
      client:
        provider:
          keycloak:
            issuer-uri: ${issuer}
        registration:
          keycloak-login:
            authorization-grant-type: authorization_code
            client-name: My Keycloak instance
            client-id: ${client-id}
            client-secret: ${client-secret}
            provider: keycloak
            scope: openid,profile,email,offline_access

com:
  c4-soft:
    springaddons:
      security:
        issuers:
        - location: ${issuer}
          username-claim: preferred_username
          authorities:
          - path: $.realm_access.roles
          - path: $.resource_access.*.roles
        client:
          client-uri: ${client-uri}
          security-matchers: /**
          permit-all:
          - /
          - /login/**
          - /oauth2/**
          csrf: cookie-accessible-from-js
          post-login-redirect-path: /home
          post-logout-redirect-path: /
          back-channel-logout-enabled: true
@Configuration
@EnableMethodSecurity
public class WebSecurityConfig {
}

2.2. With just spring-boot-starter-oauth2-client

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
    <!-- used when converting Keycloak roles to Spring authorities -->
    <groupId>com.jayway.jsonpath</groupId>
    <artifactId>json-path</artifactId>
</dependency>
issuer: http://localhost:8442/realms/master
client-id: spring-addons-confidential
client-secret: change-me

spring:
  security:
    oauth2:
      client:
        provider:
          keycloak:
            issuer-uri: ${issuer}
        registration:
          keycloak-login:
            authorization-grant-type: authorization_code
            client-name: My Keycloak instance
            client-id: ${client-id}
            client-secret: ${client-secret}
            provider: keycloak
            scope: openid,profile,email,offline_access
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class WebSecurityConfig {

    @Bean
    SecurityFilterChain
            clientSecurityFilterChain(HttpSecurity http, InMemoryClientRegistrationRepository clientRegistrationRepository)
                    throws Exception {
        http.oauth2Login(withDefaults());
        http.logout(logout -> {
            logout.logoutSuccessHandler(new OidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository));
        });
        // @formatter:off
        http.authorizeHttpRequests(ex -> ex
                .requestMatchers("/", "/login/**", "/oauth2/**").permitAll()
                .requestMatchers("/nice.html").hasAuthority("NICE")
                .anyRequest().authenticated());
        // @formatter:on
        return http.build();
    }

    @Component
    @RequiredArgsConstructor
    static class GrantedAuthoritiesMapperImpl implements GrantedAuthoritiesMapper {

        @Override
        public Collection<? extends GrantedAuthority> mapAuthorities(Collection<? extends GrantedAuthority> authorities) {
            Set<GrantedAuthority> mappedAuthorities = new HashSet<>();

            authorities.forEach(authority -> {
                if (OidcUserAuthority.class.isInstance(authority)) {
                    final var oidcUserAuthority = (OidcUserAuthority) authority;
                    final var issuer = oidcUserAuthority.getIdToken().getClaimAsURL(JwtClaimNames.ISS);
                    mappedAuthorities.addAll(extractAuthorities(oidcUserAuthority.getIdToken().getClaims()));

                } else if (OAuth2UserAuthority.class.isInstance(authority)) {
                    try {
                        final var oauth2UserAuthority = (OAuth2UserAuthority) authority;
                        final var userAttributes = oauth2UserAuthority.getAttributes();
                        final var issuer = new URL(userAttributes.get(JwtClaimNames.ISS).toString());
                        mappedAuthorities.addAll(extractAuthorities(userAttributes));

                    } catch (MalformedURLException e) {
                        throw new RuntimeException(e);
                    }
                }
            });

            return mappedAuthorities;
        };

        @SuppressWarnings({ "rawtypes", "unchecked" })
        private static Collection<GrantedAuthority> extractAuthorities(Map<String, Object> claims) {
            /* See resource server solution above for authorities mapping */
        }
    }
}

3. What are “my” starters and why using it

This starters are nothing more than thin wrappers around spring-boot-starter-oauth2-resource-server and spring-boot-starter-oauth2-client. All it does is defining a few beans, according to application properties, to configure spring-security. It is open-source and you can change everything it pre-configures for you (refer to the Javadoc, the starter READMEs, or the many samples). You should read the starters source before deciding not to trust it, it is not that big.

In my opinion (and as demonstrated above), Spring Boot auto-configuration for OAuth2 can be pushed one step further to:

  • make OAuth2 configuration more portable: with a configurable authorities converter, switching from an OIDC provider to another is just a matter of editing properties (Keycloak, Auth0, Cognito, Azure AD, etc.)
  • ease app deployment on different environments: CORS configuration is controlled from properties file
  • reduce drastically the amount of Java code (things get even more complicated if you are in multi-tenancy scenario)
  • reduce chances of misconfiguration (easy to de-synchronise CSRF protection and sessions configuration for instance)

Leave a Comment