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.
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.
The following requires that you know exactly why you need to configure a stateful client with oauth2Login
(using authorization code & refresh token flows, request authorization based on sessions) or a stateless oauth2ResourceServer
(no session, request authorization based on Bearer
tokens). In case of doubt, please refer to the OAuth2 essentials section of my tutorials or this Baeldung article I wrote. The Baeldung article also contains instructions to set up a pre-configured Keycloak instance in Docker.
I expose solutions with and then without spring-addons-starter-oidc
, a Spring Boot starter of mine that helps with OIDC configuration. 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).
App exposes a REST API secured with access tokens. It is consumed by an OAuth2 REST client. A few sample of such clients:
RestClient
, WebClient
, @FeignClient
, RestTemplate
or alike to query the resource serverspring-cloud-gateway
instance configured with oauth2Login()
and the TokenRelay
filter, which is now the recommended way to use OAuth2 for Single Page Applications (Angular, React, Vue, ...)*Note that browsers can't directly consume a stateless resource server without the help of a Javascript framework and that, as mentioned above, this not recommended anymore.
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>8.0.0</version>
</dependency>
origins: http://localhost:4200
issuer: http://localhost:8442/realms/master
com:
c4-soft:
springaddons:
oidc:
cors:
- path: /my-resources/**
allowed-origin-patterns: ${origins}
ops:
# as this is an array of issuers, we may define more than one Keycloak realm
# or even completely different authorization servers (using other private claims for user roles)
- iss: ${issuer}
# for illustration purpose only: the default (iss) is probably a better option
username-claim: preferred_username
authorities:
- path: $.realm_access.roles
# for illustration purpose, the default is an empty string
prefix: ROLE_
# any valid JsonPath expression resolved to a list of strings is acceptable
- path: $.resource_access.*.roles
resourceserver:
# default access rule is "isAuthenticated" except for the path matched here (allowed to anonymous requests)
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?.
Also, spring-addons-starter-oidc
will detect from what is on the classpath if an app is a servlet or reactive and adapt its security auto-configuration.
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,
@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") String issuerUri) 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, "OAuth realm=\"%s\"".formatted(issuerUri));
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:
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:
spring-cloud-gateway
used as Backend For Frontend)7: configured with oauth2Login
and the 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)).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>8.0.0</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}
authorities:
- path: $.realm_access.roles
client:
# allows to build OAuth2 redirection URIs using a public endpoint (reverse-proxy)
client-uri: ${client-uri}
# defines which resource should be processed by the stateful filter-chain with oauth2Login
# so we'd use another expression in an app with several filter-chains (for instance a stateful one for routes with the TokenRelay filter and a stateless one for actuator endpoints)
security-matchers: /**
permit-all:
- /
- /login/**
- /oauth2/**
# in case of mobile and single page applications we need the CSRF token in a cookie
csrf: cookie-accessible-from-js
# the post log in / out host and path can be overridden by the client with custom headers or request params
post-login-redirect-host: ${reverse-proxy}
post-login-redirect-path: /ui/home
post-logout-redirect-host: ${reverse-proxy}
post-logout-redirect-path: /
# change the status of OAuth2 flows responses to process it in mobile and single page applications
# to initiate plain navigations instead of having the user agent follow with cross-origin requests
oauth2-redirections:
authentication-entry-point: UNAUTHORIZED
pre-authorization-code: OK
rp-initiated-logout: ACCEPTED
back-channel-logout:
enabled: true
internal-logout-uri: ${client-uri}/logout/connect/back-channel/quiz-bff
@Configuration
@EnableMethodSecurity
public class WebSecurityConfig {
}
As for resource server, this solution works in reactive applications too.
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 */
}
}
}
Again, this solution is much more verbose and limited: nothing was configured here to change the default CSRF token handling (in session, not in a cookie), OAuth2 URIs & statuses, post-login and post-logout URIs, or Back-Channel Logout.
spring-addons-starter-oidc
and why using itThis 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 README, 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:
oauth2Login
(which is a security breach as, in this case, requests authorization is based on sessions, the CSRF attack vector), or wasting resources with sessions on endpoints secured with access tokens