spring-bootkeycloak

Use Keycloak Spring Adapter with Spring Boot 3


I updated to Spring Boot 3 in a project that uses the Keycloak Spring Adapter. Unfortunately, it doesn't start because the KeycloakWebSecurityConfigurerAdapter extends WebSecurityConfigurerAdapter which was first deprecated in Spring Security and then removed. Is there currently another way to implement security with Keycloak? Or to put it in other words: How can I use Spring Boot 3 in combination with the Keycloak adapter?

I searched the Internet, but couldn't find any other version of the adapter.


Solution

  • 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.

    Instead, use spring-security 6 libs for OAuth2. 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 with oauth2Login (using authorization code flow, request authorization based on a session) or an OAuth2 resource server (no session, request authorization based on a Bearer token). 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-addons-starter-oidc, a Spring Boot starter 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" starter).

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

    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:

    1.1. With spring-addons-starter-oidc

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
    </dependency>
    <dependency>
        <groupId>com.c4-soft.springaddons</groupId>
        <artifactId>spring-addons-starter-oidc</artifactId>
        <version>7.8.5</version>
    </dependency>
    
    origins: http://localhost:4200
    issuer: http://localhost:8442/realms/master
    
    com:
      c4-soft:
        springaddons:
          oidc:
            ops:
            - iss: ${issuer}
              username-claim: preferred_username
              authorities:
              - path: $.realm_access.roles
                prefix: ROLE_
              - path: $.resource_access.*.roles
            resourceserver:
              cors:
              - path: /my-resources/**
                allowed-origin-patterns: ${origins}
              permit-all: 
              - "/actuator/health/readiness"
              - "/actuator/health/liveness"
              - "/v3/api-docs/**"
    

    Prefix for realm roles in the conf above are there only for illustration purposes, you might remove it. The CORS configuration would need some refinements too.

    @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 ops property being an array, this solution is actually compatible with "static" multi-tenancy: you can declare as many trusted issuers as you need and it can be heterogeneous (use different claims for username and authorities).

    Also, this solution is compatible with reactive application: spring-addons-starter-oidc will detect it from what is on the classpath and adapt its security auto-configuration.

    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, "Bearer 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("/my-resources/**", 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 JwtAuthenticationToken convert(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:

    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:

    2.1. With spring-addons-starter-oidc

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-client</artifactId>
    </dependency>
    <dependency>
        <groupId>com.c4-soft.springaddons</groupId>
        <artifactId>spring-addons-starter-oidc</artifactId>
        <version>7.8.5</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:
                provider: keycloak
                authorization-grant-type: authorization_code
                client-id: ${client-id}
                client-secret: ${client-secret}
                scope: openid,profile,email,offline_access
    
    com:
      c4-soft:
        springaddons:
          oidc:
            ops:
            - iss: ${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: /
    
    @Configuration
    @EnableMethodSecurity
    public class WebSecurityConfig {
    }
    

    As for resource server, this solution works in reactive applications too.

    There is also an optional support for multi-tenancy on clients: allow a user to be logged in simultaneously on several OpenID Providers, on which he might have different usernames (subject by default, which is a UUID in Keycloak, and changes with each realm).

    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:
                provider: keycloak
                authorization-grant-type: authorization_code
                client-id: ${client-id}
                client-secret: ${client-secret}
                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 is spring-addons-starter-oidc and why using it

    This starter is a standard Spring Boot starter with additional application properties used to auto-configure default beans and provide it to Spring Security. It is important to note that the auto-configured @Beans are almost all @ConditionalOnMissingBean which enables you to override it in your conf.

    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. Start with imports resource, it defines what is loaded by Spring Boot for auto-configuration.

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