I have a Spring Boot + Keycloak project and I found out that the Spring Boot does not validate the JWT with the keycloak. For example if I get a token from Keycloak and turn off the Keycloak, I still can use this JWT token to access my end points. I have this security configurer class:
#Configuration
#EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
#RequiredArgsConstructor
public class KeycloakSecurityConfigurer extends WebSecurityConfigurerAdapter {
private final RoleConverter converter;
#Value("${spring.security.oauth2.keycloak.jwt.issuer-uri}")
private String issuerUri;
#Override
public void configure(final HttpSecurity http) throws Exception {
http.headers().frameOptions().disable()
.and()
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.oauth2ResourceServer(
oauth2ResourceServer -> oauth2ResourceServer.jwt(
jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())));
http.authorizeRequests().antMatchers("/**").authenticated();
}
private Converter<Jwt, ? extends AbstractAuthenticationToken> jwtAuthenticationConverter() {
JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter();
jwtConverter.setJwtGrantedAuthoritiesConverter(converter);
return jwtConverter;
}
#Bean
public JwtDecoder jwtDecoder() {
return JwtDecoders.fromOidcIssuerLocation(issuerUri);
}
}
The "converter" is nothing special, just extracts the roles out of JWT token and returns a list of them.
How to force the Spring Security to validate the JWT token?
application.yml:
spring:
security:
oauth2:
keycloak:
jwt:
issuer-uri: http://localhost:8180/auth/realms/test-realm
You can look at the implementation of JwtDecoders.fromOidcIssuerLocation(issuerUri).
What is happening is that the keys are being fetched at the startup of your application and the application caches them in order to perform the validation after. With this in mind, even if you turn off Keycloak the JWT will still be validated because the keys are still cached.
The JWT tokens are been cached in your springboot application, this is the default cache store. In order to delete this token from your springboot app use should use some custom caches like redis cache to be configured in your app instead of default. There is no possible way to delete the tokens stored in default caches. The token will automatically get invalidate only after the timeout that's been set inside token
JWTs are meant to be validated offline, and it is what usually happens. The receiving application (consumer of JWT), does not need constant access to the Authorization Server in order to be able to validate a JWT. Even though Spring can't talk to your Keycloak, it does not mean that tokens are not validated. As others pointed out, Spring caches the keys used to validate JWTs' signature and will use the cache if it can.
If, for some reason, you want your service / API to validate the JWT online (maybe because you want to implement a mechanism to revoke tokens), you could switch to using opaque tokens with Token Introspection. On every request your service will have to call Keycloak to exchange the opaque token for a JWT. Mind that this solution will use much more resources, and you should use it only if you have strong reasons for it.
Related
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.
Directly use spring-security OAuth2 instead. Don't panic, it's an easy task with spring-boot.
spring-addons starters for resource server (app exposes a REST API)
I maintain 4 thin wrappers around "official" boot resource-server starter because, in my opinion, auto-configuration 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)
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.13</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
com.c4-soft.springaddons.security.permit=all=/actuator/health/readiness,/actuator/health/liveness,/v3/api-docs/**
Nothing more is needed to configure a multi-tenant resource-server with fine tuned CORS policy and authorities mapping. Bootiful, isn't it?
By "multi-tenant", I mean that, as you can guess from this issuers property being an array, you can trust as many OIDC authorization-server instances as you need (multiple Keycloak realms & instances, or even mix with other OIDC providers like Auth0, Cognito, etc.), each with it's own authorities mapping configuration.
Client configuration (UI with oauth2Login())
If your Spring application exposes secured UI elements you want to be accessible with a browser (with OAuth2 login), you'll have to provide a FilterChain with "client" configuration.
If this app exposes both a REST API and a UI to manipulate it (with oauth2Login()), then you'll have to setup two security filter-chains: one with client config and the other with resource-server config.
Add this to pom.xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
Here we demo a SecurityFilterChain applying only to a list of routes defined with a securityMatcher.
This assumes that an additional resource-server SecurityFilterChain is defined, with lower order and no securityMatcher so that all routes are intercepted after all filter chains are evaluated in order. This other filter chain could be defined either implicitly (by spring-addons as described above) or explicitly (with Spring Boot official starter as described below).
Remove the securityMatcher section if your app is solely a client:
// Give higher precedence to security filter-chains with "securityMatcher"
#Order(Ordered.HIGHEST_PRECEDENCE)
#Bean
SecurityFilterChain uiFilterChain(
HttpSecurity http,
ServerProperties serverProperties,
GrantedAuthoritiesMapper authoritiesMapper) throws Exception {
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().userInfoEndpoint().userAuthoritiesMapper(authoritiesMapper);
http.authorizeHttpRequests()
.requestMatchers("/ui/index.html").permitAll()
.requestMatchers("/login/**").permitAll()
.requestMatchers("/oauth2/**").permitAll()
.anyRequest().authenticated();
// 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 authorisation is missing or invalid)
return http.build();
}
#Bean
GrantedAuthoritiesMapper userAuthoritiesMapper(Converter<Map<String, Object>, Collection<? extends GrantedAuthority>> authoritiesConverter) {
return (authorities) -> {
Set<GrantedAuthority> mappedAuthorities = new HashSet<>();
authorities.forEach(authority -> {
if (authority instanceof OidcUserAuthority oidcAuth) {
mappedAuthorities.addAll(authoritiesConverter.convert(oidcAuth.getIdToken().getClaims()));
} else if (authority instanceof OAuth2UserAuthority oauth2Auth) {
mappedAuthorities.addAll(authoritiesConverter.convert(oauth2Auth.getAttributes()));
}
});
return mappedAuthorities;
};
}
The code above assumes that a Converter<Map<String, Object>, Collection<? extends GrantedAuthority>> bean is exposed. One is auto-configured by spring-addons starters for resource-server and the "official" starter section below defines one. Take the later as sample if your app is solely a client.
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
"Official" Spring Boot resource-server starter
As spring-addons-{webmvc|webflux}-{jwt|introspecting}-resource-server are thin wrappers around spring-boot-starter-oauth2-resource-server, you can of course do the same with just the later.
Here is what it takes 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());
// Merge the 3 sources of roles and map it to spring-security authorities
return Stream.concat(
realmRoles.stream(),
Stream.concat(confidentialClientRoles.stream(), publicClientRoles.stream()))
.map(SimpleGrantedAuthority::new).toList();
};
}
// spring-boot looks for a Converter<Jwt, ? extends AbstractAuthenticationToken> bean
// that is a converter from Jwt to something extending AbstractAuthenticationToken (and not AbstractAuthenticationToken itself)
// In this conf, we use JwtAuthenticationToken as AbstractAuthenticationToken implementation
public interface Jwt2AuthenticationConverter extends Converter<Jwt, JwtAuthenticationToken> {
}
#Bean
public Jwt2AuthenticationConverter authenticationConverter(Jwt2AuthoritiesConverter authoritiesConverter) {
return jwt -> new JwtAuthenticationToken(jwt, authoritiesConverter.convert(jwt));
}
// Give lower precedence to security filter-chains without "securityMatcher" so that the filter-chains with a "securityMatcher" get a chance to be matched
#Order(Ordered.LOWEST_PRECEDENCE)
#Bean
public SecurityFilterChain apiFilterChain(
HttpSecurity http,
ServerProperties serverProperties,
Converter<Jwt, ? extends AbstractAuthenticationToken> authenticationConverter) throws Exception {
// Enable OAuth2 with custom authorities mapping
http.oauth2ResourceServer().jwt().jwtAuthenticationConverter(authenticationConverter);
// As the authentication bean is the one expected by spring-boot,
// an alternative would be to use just
// http.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);
// Enable anonymous
http.anonymous();
// Enable and configure CORS
http.cors().configurationSource(corsConfigurationSource());
// State-less session (state in access-token only)
// with Disable CSRF because of disabled sessions
http
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.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
http.authorizeRequests()
.antMatchers("/actuator/health/readiness", "/actuator/health/liveness", "/v3/api-docs/**").permitAll()
.anyRequest().authenticated();
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
As mentioned in preamble, this is quite more verbose than spring-addons starters, it's not ready for multi-tenancy and each time CORS policy changes (new API routes for instance) or when the claims source for authorities change (new OAuth2 client with client-roles mapping or other OIDC provider than Keycloak), you'll have to edit source-code and re-publish your app...
Use the standard Spring Security OAuth2 client instead of a specific Keycloak adapter and SecurityFilterChain instead of WebSecurityAdapter.
Something like this:
#Configuration
#EnableWebSecurity
#EnableGlobalMethodSecurity(jsr250Enabled = true, prePostEnabled = true)
class OAuth2SecurityConfig {
#Bean
fun customOauth2FilterChain(http: HttpSecurity): SecurityFilterChain {
log.info("Configure HttpSecurity with OAuth2")
http {
oauth2ResourceServer {
jwt { jwtAuthenticationConverter = CustomBearerJwtAuthenticationConverter() }
}
oauth2Login {}
csrf { disable() }
authorizeRequests {
// Kubernetes
authorize("/readiness", permitAll)
authorize("/liveness", permitAll)
authorize("/actuator/health/**", permitAll)
// ...
// everything else needs at least a valid login, roles are checked at method level
authorize(anyRequest, authenticated)
}
}
return http.build()
}
And then in application.yml:
spring:
security:
oauth2:
client:
provider:
abc:
issuer-uri: https://keycloak.../auth/realms/foo
registration:
abc:
client-secret: ...
provider: abc
client-id: foo
scope: [ openid, profile, email ]
resourceserver:
jwt:
issuer-uri: https://keycloak.../auth/realms/foo
Using Keycloak adapters is not possible because the KeycloakWebSecurityConfigurerAdapter inherited from the WebSecurityConfigurerAdapter class, which was deprecated in Spring Security and subsequently removed in the newer release.
I have published a detailed article on integrating Keycloak with Spring Boot 3.0 on Medium, which provides a step-by-step guide on how to integrate Keycloak with Spring Boot 3.0.
This guide is particularly helpful for those who are new to integrating Keycloak with Spring Boot 3.0 or migrating to Spring Boot 3.0 from an older version.
You can check out the article (https://medium.com/geekculture/using-keycloak-with-spring-boot-3-0-376fa9f60e0b) for a comprehensive explanation of the integration process.
Hope this helps! If you have any questions, further clarifications or suggestions, Please feel free to leave a comment.
I am currently using a rather simple approach to restrict a certain suburl (everything under /api/rest) and all of its subpaths via WebFluxSecurity. Some paths (everything directly under the root NOT in /api/rest) are excluded so that they can be access without authorization. However, sometimes the accessing party might send an empty authorization header which leads to unsecured endpoints returning a 401.
See the relevant code here:
#Configuration
#EnableWebFluxSecurity
public class SecurityConfiguration {
#Value(value = "${...}")
private String user;
#Value(value = "${...}")
private String pw;
#Bean
public MapReactiveUserDetailsService userDetailsService() {
PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
UserDetails user = User
.withUsername(user)
.password(encoder.encode(pw))
.roles("USER")
.build();
return new MapReactiveUserDetailsService(user);
}
#Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
http
.authorizeExchange(exchanges -> exchanges
.pathMatchers("/api/rest/**")
.authenticated()
.anyExchange()
.permitAll()
)
.httpBasic(withDefaults());
return http.build();
}
}
On stackoverflow I've only found a few suggestions how to handle this with WebSecurity. However, this is not possible for me as I use webflux security.
See e.g.
Springboot webflux throwing 401 when Authorization header sent to unrestricted endpoint
Spring Boot 2: Basic Http Auth causes unprotected endpoints to respond with 401 "Unauthorized" if Authorization header is attached
TL;DR
If you pass invalid credentials to any endpoint with httpBasic() enabled, it will return a 401 response.
One important distinction that's relevant here is the difference between authentication and authorization. The httpBasic() DSL method adds the AuthenticationWebFilter configured for HTTP Basic. The authorizeExchange(...) DSL method defines authorization rules, such as authenticated() and permitAll().
The authentication filter appears earlier in the Spring Security filter chain than the authorization filter, and so authentication happens first which we would expect. Based on your comments, it seems you are expecting authentication not to happen if you mark an endpoint as permitAll(), but this is not the case.
Whether authentication is actually attempted against a particular request depends on how the authentication filter matches the request. In the case of AuthenticationWebFilter, a ServerWebExchangeMatcher (requiresAuthenticationMatcher) determines whether authentication is required. For httpBasic(), every request requires authentication. If you pass invalid credentials to any endpoint with httpBasic() enabled, it will return a 401 response.
Additionally, a ServerAuthenticationConverter (authenticationConverter) is used to read the Authorization header and parse the credentials. This is what would fail if an invalid token (or Authorization header) is given. ServerHttpBasicAuthenticationConverter is used for httpBasic() and is fairly forgiving of invalid header values. I don't find any scenarios that fail and produce a 401 response except invalid credentials.
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.
Directly use spring-security OAuth2 instead. Don't panic, it's an easy task with spring-boot.
spring-addons starters for resource server (app exposes a REST API)
I maintain 4 thin wrappers around "official" boot resource-server starter because, in my opinion, auto-configuration 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)
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.13</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
com.c4-soft.springaddons.security.permit=all=/actuator/health/readiness,/actuator/health/liveness,/v3/api-docs/**
Nothing more is needed to configure a multi-tenant resource-server with fine tuned CORS policy and authorities mapping. Bootiful, isn't it?
By "multi-tenant", I mean that, as you can guess from this issuers property being an array, you can trust as many OIDC authorization-server instances as you need (multiple Keycloak realms & instances, or even mix with other OIDC providers like Auth0, Cognito, etc.), each with it's own authorities mapping configuration.
Client configuration (UI with oauth2Login())
If your Spring application exposes secured UI elements you want to be accessible with a browser (with OAuth2 login), you'll have to provide a FilterChain with "client" configuration.
If this app exposes both a REST API and a UI to manipulate it (with oauth2Login()), then you'll have to setup two security filter-chains: one with client config and the other with resource-server config.
Add this to pom.xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
Here we demo a SecurityFilterChain applying only to a list of routes defined with a securityMatcher.
This assumes that an additional resource-server SecurityFilterChain is defined, with lower order and no securityMatcher so that all routes are intercepted after all filter chains are evaluated in order. This other filter chain could be defined either implicitly (by spring-addons as described above) or explicitly (with Spring Boot official starter as described below).
Remove the securityMatcher section if your app is solely a client:
// Give higher precedence to security filter-chains with "securityMatcher"
#Order(Ordered.HIGHEST_PRECEDENCE)
#Bean
SecurityFilterChain uiFilterChain(
HttpSecurity http,
ServerProperties serverProperties,
GrantedAuthoritiesMapper authoritiesMapper) throws Exception {
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().userInfoEndpoint().userAuthoritiesMapper(authoritiesMapper);
http.authorizeHttpRequests()
.requestMatchers("/ui/index.html").permitAll()
.requestMatchers("/login/**").permitAll()
.requestMatchers("/oauth2/**").permitAll()
.anyRequest().authenticated();
// 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 authorisation is missing or invalid)
return http.build();
}
#Bean
GrantedAuthoritiesMapper userAuthoritiesMapper(Converter<Map<String, Object>, Collection<? extends GrantedAuthority>> authoritiesConverter) {
return (authorities) -> {
Set<GrantedAuthority> mappedAuthorities = new HashSet<>();
authorities.forEach(authority -> {
if (authority instanceof OidcUserAuthority oidcAuth) {
mappedAuthorities.addAll(authoritiesConverter.convert(oidcAuth.getIdToken().getClaims()));
} else if (authority instanceof OAuth2UserAuthority oauth2Auth) {
mappedAuthorities.addAll(authoritiesConverter.convert(oauth2Auth.getAttributes()));
}
});
return mappedAuthorities;
};
}
The code above assumes that a Converter<Map<String, Object>, Collection<? extends GrantedAuthority>> bean is exposed. One is auto-configured by spring-addons starters for resource-server and the "official" starter section below defines one. Take the later as sample if your app is solely a client.
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
"Official" Spring Boot resource-server starter
As spring-addons-{webmvc|webflux}-{jwt|introspecting}-resource-server are thin wrappers around spring-boot-starter-oauth2-resource-server, you can of course do the same with just the later.
Here is what it takes 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());
// Merge the 3 sources of roles and map it to spring-security authorities
return Stream.concat(
realmRoles.stream(),
Stream.concat(confidentialClientRoles.stream(), publicClientRoles.stream()))
.map(SimpleGrantedAuthority::new).toList();
};
}
// spring-boot looks for a Converter<Jwt, ? extends AbstractAuthenticationToken> bean
// that is a converter from Jwt to something extending AbstractAuthenticationToken (and not AbstractAuthenticationToken itself)
// In this conf, we use JwtAuthenticationToken as AbstractAuthenticationToken implementation
public interface Jwt2AuthenticationConverter extends Converter<Jwt, JwtAuthenticationToken> {
}
#Bean
public Jwt2AuthenticationConverter authenticationConverter(Jwt2AuthoritiesConverter authoritiesConverter) {
return jwt -> new JwtAuthenticationToken(jwt, authoritiesConverter.convert(jwt));
}
// Give lower precedence to security filter-chains without "securityMatcher" so that the filter-chains with a "securityMatcher" get a chance to be matched
#Order(Ordered.LOWEST_PRECEDENCE)
#Bean
public SecurityFilterChain apiFilterChain(
HttpSecurity http,
ServerProperties serverProperties,
Converter<Jwt, ? extends AbstractAuthenticationToken> authenticationConverter) throws Exception {
// Enable OAuth2 with custom authorities mapping
http.oauth2ResourceServer().jwt().jwtAuthenticationConverter(authenticationConverter);
// As the authentication bean is the one expected by spring-boot,
// an alternative would be to use just
// http.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);
// Enable anonymous
http.anonymous();
// Enable and configure CORS
http.cors().configurationSource(corsConfigurationSource());
// State-less session (state in access-token only)
// with Disable CSRF because of disabled sessions
http
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.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
http.authorizeRequests()
.antMatchers("/actuator/health/readiness", "/actuator/health/liveness", "/v3/api-docs/**").permitAll()
.anyRequest().authenticated();
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
As mentioned in preamble, this is quite more verbose than spring-addons starters, it's not ready for multi-tenancy and each time CORS policy changes (new API routes for instance) or when the claims source for authorities change (new OAuth2 client with client-roles mapping or other OIDC provider than Keycloak), you'll have to edit source-code and re-publish your app...
Use the standard Spring Security OAuth2 client instead of a specific Keycloak adapter and SecurityFilterChain instead of WebSecurityAdapter.
Something like this:
#Configuration
#EnableWebSecurity
#EnableGlobalMethodSecurity(jsr250Enabled = true, prePostEnabled = true)
class OAuth2SecurityConfig {
#Bean
fun customOauth2FilterChain(http: HttpSecurity): SecurityFilterChain {
log.info("Configure HttpSecurity with OAuth2")
http {
oauth2ResourceServer {
jwt { jwtAuthenticationConverter = CustomBearerJwtAuthenticationConverter() }
}
oauth2Login {}
csrf { disable() }
authorizeRequests {
// Kubernetes
authorize("/readiness", permitAll)
authorize("/liveness", permitAll)
authorize("/actuator/health/**", permitAll)
// ...
// everything else needs at least a valid login, roles are checked at method level
authorize(anyRequest, authenticated)
}
}
return http.build()
}
And then in application.yml:
spring:
security:
oauth2:
client:
provider:
abc:
issuer-uri: https://keycloak.../auth/realms/foo
registration:
abc:
client-secret: ...
provider: abc
client-id: foo
scope: [ openid, profile, email ]
resourceserver:
jwt:
issuer-uri: https://keycloak.../auth/realms/foo
Using Keycloak adapters is not possible because the KeycloakWebSecurityConfigurerAdapter inherited from the WebSecurityConfigurerAdapter class, which was deprecated in Spring Security and subsequently removed in the newer release.
I have published a detailed article on integrating Keycloak with Spring Boot 3.0 on Medium, which provides a step-by-step guide on how to integrate Keycloak with Spring Boot 3.0.
This guide is particularly helpful for those who are new to integrating Keycloak with Spring Boot 3.0 or migrating to Spring Boot 3.0 from an older version.
You can check out the article (https://medium.com/geekculture/using-keycloak-with-spring-boot-3-0-376fa9f60e0b) for a comprehensive explanation of the integration process.
Hope this helps! If you have any questions, further clarifications or suggestions, Please feel free to leave a comment.
I am trying to secure some of my spring cloud gateway routes:
Users must be authenticated using OAUTH2 to be able to use those routes (if not -> respond with http 401)
The JWT access token must include a specific value in the "scp" claim ("2fa" in my case) (if not, respond with http 403)
The JSON payload contains one property "user" that must have the same value as the "sub" claim in the JWT access token. (if not, respond with http 403)
Reading the documentation I found out how I can set up 1. and 2.
Unfortunately, there seems to be very little information on how to achieve 3.
Where I could I find a working example?
Here's my spring security setup from application.yaml file:
...
spring:
profiles: production
security:
oauth2:
resourceserver:
jwt:
issuer-uri: ${AUTH_URL}/oidc
jwk-set-uri: ${AUTH_URL}/oidc/jwks.json
...
Configuation of my SecurityWebFilterChain:
...
#Bean
#Order(Ordered.HIGHEST_PRECEDENCE - 3)
public SecurityWebFilterChain secondFactorScopeApiHttpSecurity(ServerHttpSecurity http) {
final ServerWebExchangeMatcher baseScopeEndpointsMatcher = new OrServerWebExchangeMatcher(
new PathPatternParserServerWebExchangeMatcher("/api/fhir"),
new PathPatternParserServerWebExchangeMatcher("/api/fhir/List**"),
new PathPatternParserServerWebExchangeMatcher("/api/fhir/Observation**")
);
http.securityMatcher(baseScopeEndpointsMatcher)
.authorizeExchange(exchanges -> exchanges.anyExchange().hasAuthority("SCOPE_2fa"))
.oauth2ResourceServer(ServerHttpSecurity.OAuth2ResourceServerSpec::jwt);
return http.build();
}
...
I want the user to see a HTTP 403 in case the payloads "user" property does not match the sub claim from the JWT.
What you want to achieve is an easy task on resource-servers and, in my opinion, resources access-control is the responsability of resource-server, not gateway, specially if access decision involves the resource itself.
I would just let the gateway be transparent to OAuth2: leave requests authorization header as well as responses status code unchanged.
I have samples in that series of tutorials which incrementally builds to advanced role based access control. It should take you less than an hour to follow the first 3:
1st demoes resource-server security conf with spring-boot-starter-oauth2-resource-server (what you've implemented on the gateway so far)
2nd shows how to replace JwtAuthenticationToken with an implementation of your choice exposing strongly typed private-claims. It also greatly reduce Java conf with one of the thin wrappers I created around spring-boot one.
3rd demoes security SpEL customization to write stuff like
#GetMapping("/on-behalf-of/{username}")
#PreAuthorize("is(#username) or isNice() or onBehalfOf(#username).can('greet')")
public String getGreetingFor(#PathVariable("username") String username) {
...
}
Of course, in your case, you would use a signature like myControllerMethod(#RequestBody MyDto dto, Authentication auth) and an expression like #dto.sub eq #auth.name, but you get the idea.
We have an API service which has multiple APIs exposed, and there are multiple personas who/which can access our service.
Users - Who needs to have an account in our system -> Needs to be
authenticated with our Identity Provider Service (Keycloak) with JWT
token.
Regulated System - Which needs to be authenticated with central
authority maintained by some party.
Internal service to service communication -> authentication with same
Keycloak.
Temporary JWT token issued by the same service before creating the user
account when the user digitally verified the mobile number.
I was trying to have AuthenticationWebFilter for each authentication type, and configure with Pathmatchers, though it was getting authenticated by the right authentication web filter, the request keeps flowing through the other authentication filter, and ends up resulting as unauthorized.
Snippet of configuration:
public class Configuration {
#Bean
#Order(1)
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity httpSecurity,
#Qualifier("userCreationFilter")
AuthenticationWebFilter userCreationFilter) {
final String[] WHITELISTED_URLS = {"/**.json",
"/users/verify",
"/users/permit",
"/sessions",
"/internal/xxxxx/**",
"/**.html",
"/**.js",
"/**.yaml",
"/**.css",
"/**.png"};
httpSecurity.authorizeExchange().pathMatchers(WHITELISTED_URLS).permitAll();
httpSecurity.addFilterBefore(userCreationFilter, SecurityWebFiltersOrder.AUTHENTICATION)
.authorizeExchange()
.pathMatchers("/users")
.authenticated();
httpSecurity.httpBasic().disable().formLogin().disable().csrf().disable().logout().disable();
return httpSecurity.build();
}
#Bean
#Order(2)
public SecurityWebFilterChain securityWebFilterChain2(ServerHttpSecurity httpSecurity,
#Qualifier("managerFilter")
AuthenticationWebFilter managerFilter) {
httpSecurity.addFilterBefore(managerFilter, SecurityWebFiltersOrder.AUTHENTICATION)
.authorizeExchange()
.pathMatchers("/xxxxx/**",
"/providers",
"/xxxxx/**/approve",
"/xxxx/**/xxxxx").authenticated();
return httpSecurity.build();
}
}
Right now there are no roles as such we have.
I tried keeping all configuration in single SecurityWebFilterChain Bean, and tried addWebFilterAt, but no luck.
What am I missing? Should I do it different way?