SpringBoot OAuth2 with Keycloak not returning mapped Roles as Authorities
Asked Answered
Q

3

10

I am creating a simple SpringBoot application and trying to integrate with OAuth 2.0 provider Keycloak. I have created a realm, client, roles (Member, PremiumMember) at realm level and finally created users and assigned roles (Member, PremiumMember).

If I use SpringBoot Adapter provided by Keycloak https://www.keycloak.org/docs/latest/securing_apps/index.html#_spring_boot_adapter then when I successfully login and check the Authorities of the loggedin user I am able to see the assigned roles such as Member, PremiumMember.

Collection<? extends GrantedAuthority> authorities = 
 SecurityContextHolder.getContext().getAuthentication().getAuthorities();

But if I use generic SpringBoot Auth2 Client Config I am able to login but when I check the Authorities it always show only ROLE_USER, SCOPE_email,SCOPE_openid,SCOPE_profile and didn't include the roles I mapped (Member, PremiumMember).

My SpringBoot OAuth2 config:

pom.xml

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

application.properties

spring.security.oauth2.client.provider.spring-boot-thymeleaf-client.issuer-uri=http://localhost:8181/auth/realms/myrealm

spring.security.oauth2.client.registration.spring-boot-thymeleaf-client.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.spring-boot-thymeleaf-client.client-id=spring-boot-app
spring.security.oauth2.client.registration.spring-boot-thymeleaf-client.client-secret=XXXXXXXXXXXXXX
spring.security.oauth2.client.registration.spring-boot-thymeleaf-client.scope=openid,profile,roles
spring.security.oauth2.client.registration.spring-boot-thymeleaf-client.redirect-uri=http://localhost:8080/login/oauth2/code/spring-boot-app

I am using SpringBoot 2.5.5 and Keycloak 15.0.2.

Using this generic OAuth2.0 config approach (without using Keycloak SpringBootAdapter) is there a way to get the assigned roles?

Quiteria answered 26/9, 2021 at 0:38 Comment(0)
A
35

By default, Spring Security generates a list of GrantedAuthority using the values in the scope or scp claim and the SCOPE_ prefix.

Keycloak keeps the realm roles in a nested claim realm_access.roles. You have two options to extract the roles and map them to a list of GrantedAuthority.

OAuth2 Client

If your application is configured as an OAuth2 Client, then you can extract the roles from either the ID Token or the UserInfo endpoint. Keycloak includes the roles only in the Access Token, so you need to change the configuration to include them also in either the ID Token or the UserInfo endpoint (which is what I use in the following example). You can do so from the Keycloak Admin Console, going to Client Scopes > roles > Mappers > realm roles

Realm roles configuration

Then, in your Spring Security configuration, define a GrantedAuthoritiesMapper which extracts the roles from the UserInfo endpoint and maps them to GrantedAuthoritys. Here, I'll include how the specific bean should look like. A full example is available on my GitHub: https://github.com/ThomasVitale/spring-security-examples/tree/main/oauth2/login-user-authorities

@Bean
public GrantedAuthoritiesMapper userAuthoritiesMapperForKeycloak() {
        return authorities -> {
            Set<GrantedAuthority> mappedAuthorities = new HashSet<>();
            var authority = authorities.iterator().next();
            boolean isOidc = authority instanceof OidcUserAuthority;

            if (isOidc) {
                var oidcUserAuthority = (OidcUserAuthority) authority;
                var userInfo = oidcUserAuthority.getUserInfo();

                if (userInfo.hasClaim("realm_access")) {
                    var realmAccess = userInfo.getClaimAsMap("realm_access");
                    var roles = (Collection<String>) realmAccess.get("roles");
                    mappedAuthorities.addAll(generateAuthoritiesFromClaim(roles));
                }
            } else {
                var oauth2UserAuthority = (OAuth2UserAuthority) authority;
                Map<String, Object> userAttributes = oauth2UserAuthority.getAttributes();

                if (userAttributes.containsKey("realm_access")) {
                    var realmAccess =  (Map<String,Object>) userAttributes.get("realm_access");
                    var roles =  (Collection<String>) realmAccess.get("roles");
                    mappedAuthorities.addAll(generateAuthoritiesFromClaim(roles));
                }
            }

            return mappedAuthorities;
        };
    }

Collection<GrantedAuthority> generateAuthoritiesFromClaim(Collection<String> roles) {
        return roles.stream()
                .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
                .collect(Collectors.toList());
}

OAuth2 Resource Server

If your application is configured as an OAuth2 Resource Server, then you can extract the roles from the Access Token. In your Spring Security configuration, define a JwtAuthenticationConverter bean which extracts the roles from the Access Token and maps them to GrantedAuthoritys. Here, I'll include how the specific bean should look like. A full example is available on my GitHub: https://github.com/ThomasVitale/spring-security-examples/tree/main/oauth2/resource-server-jwt-authorities

public JwtAuthenticationConverter jwtAuthenticationConverterForKeycloak() {
    Converter<Jwt, Collection<GrantedAuthority>> jwtGrantedAuthoritiesConverter = jwt -> {
        Map<String, Collection<String>> realmAccess = jwt.getClaim("realm_access");
        Collection<String> roles = realmAccess.get("roles");
        return roles.stream()
            .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
            .collect(Collectors.toList());
    };

    var jwtAuthenticationConverter = new JwtAuthenticationConverter();
    jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter);

    return jwtAuthenticationConverter;
}
Alsatian answered 26/9, 2021 at 9:47 Comment(2)
My application is a OAuth Client and following your approach worked well for me. Thanks a lot.Quiteria
If the "Oauth client" is used, the ID token will be used to check for roles. That's why when configure keycloak to map realm roles, make sure to uncheck "Add to ID token" and check it again. On my keycloak 19, even the "Add to ID token" is check by default, the roles has not mapped.Pairoar
M
2

I use this configuration:

import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.core.convert.converter.Converter;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.BeanIds;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;

import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // We can safely disable CSRF protection on the REST API because we do not rely on cookies (https://security.stackexchange.com/questions/166724/should-i-use-csrf-protection-on-rest-api-endpoints)
        http.csrf(httpSecurityCsrfConfigurer -> httpSecurityCsrfConfigurer.ignoringAntMatchers("/api/**"));
        http.cors();
        http.authorizeRequests(registry -> {
            registry.mvcMatchers("/api-docs/**", "/architecture-docs/**").permitAll();
            registry.mvcMatchers("/api/integrationtest/**").permitAll();
            registry.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll();
            registry.mvcMatchers("/actuator/info", "/actuator/health").permitAll();
            registry.anyRequest().authenticated();
        });
        http.oauth2ResourceServer()
            .jwt()
            .jwtAuthenticationConverter(jwtAuthenticationConverter());
    }

    @Bean(name = BeanIds.AUTHENTICATION_MANAGER)
    public AuthenticationManager authenticationManagerBean() throws Exception {
        // Although this seems like useless code,
        // it is required to prevent Spring Boot creating a default password
        return super.authenticationManagerBean();
    }

    @Bean
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(jwtToAuthorityConverter());
        return converter;
    }

    @Bean
    public Converter<Jwt, Collection<GrantedAuthority>> jwtToAuthorityConverter() {
        return new Converter<Jwt, Collection<GrantedAuthority>>() {
            @Override
            public List<GrantedAuthority> convert(Jwt jwt) {
                Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
                if (realmAccess != null) {
                    @SuppressWarnings("unchecked")
                    List<String> roles = (List<String>) realmAccess.get("roles");
                    if (roles != null) {
                        return roles.stream()
                                    .map(rn -> new SimpleGrantedAuthority("ROLE_" + rn))
                                    .collect(Collectors.toList());
                    }
                }

                return Collections.emptyList();
            }
        };
    }
}

With these dependencies:

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
        </dependency>

And this property:

spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:8181/auth/realms/myrealm

Extra tip: Use https://github.com/ch4mpy/spring-addons for testing. You can also take a look there at a configuration sample (which is different from what I do, but should work fine as well, see https://github.com/ch4mpy/spring-addons/issues/27 for more info about those differences): https://github.com/ch4mpy/starter/tree/master/api/webmvc/common-security-webmvc/src/main/java/com/c4_soft/commons/security

Maniple answered 26/9, 2021 at 5:32 Comment(1)
My application is a OAuth2 Client not a Resource Server. Spring-addons project looks very interesting, will take a look at it. Thanks @WimDeblauwe for sharing your thoughts.Quiteria
C
1

You have another option if you do not want to customize Spring that much. According to the Spring documentation, you can change authority claim name and prefix.

    @Bean
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
        grantedAuthoritiesConverter.setAuthoritiesClaimName("roles");
        grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");

        JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
        return jwtAuthenticationConverter;
    }

With that, Spring expects the roles as a top level claim. To do so, you have to add client specific mapper on the Keycloak client, so that the realm roles will be sent additional on top level.

enter image description here

In my case, I added the mapper as a client specific one. But you should be able to set this as a default for a realm.

Continuant answered 18/10, 2023 at 6:8 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.