Question

Spring Security: mapping OAuth2 claims with roles to secure Resource Server endpoints

I'm setting up a Resource Server with Spring Boot and to secure the endpoints I'm using OAuth2 provided by Spring Security. So I'm using the Spring Boot 2.1.8.RELEASE which for instance uses Spring Security 5.1.6.RELEASE.

As Authorization Server I'm using Keycloak. All processes between authentication, issuing access tokens and validation of the tokens in the Resource Server are working correctly. Here is an example of an issued and decoded token (with some parts are cut):

{
  "jti": "5df54cac-8b06-4d36-b642-186bbd647fbf",
  "exp": 1570048999,
  "aud": [
    "myservice",
    "account"
  ],
  "azp": "myservice",
  "realm_access": {
    "roles": [
      "offline_access",
      "uma_authorization"
    ]
  },
  "resource_access": {
    "myservice": {
      "roles": [
        "ROLE_user",
        "ROLE_admin"
      ]
    },
    "account": {
      "roles": [
        "manage-account",
        "manage-account-links",
        "view-profile"
      ]
    }
  },
  "scope": "openid email offline_access microprofile-jwt profile address phone",
}

How can I configure Spring Security to use the information in the access token to provide conditional authorization for different endpoints?

Ultimately I want to write a controller like this:

@RestController
public class Controller {

    @Secured("ROLE_user")
    @GetMapping("userinfo")
    public String userinfo() {
        return "not too sensitive action";
    }

    @Secured("ROLE_admin")
    @GetMapping("administration")
    public String administration() {
        return "TOOOO sensitive action";
    }
}
 46  39698  46
1 Jan 1970

Solution

 66

After messing around a bit more, I was able to find a solution implementing a custom jwtAuthenticationConverter, which is able to append resource-specific roles to the authorities collection.

    http.oauth2ResourceServer()
                .jwt()
                .jwtAuthenticationConverter(new JwtAuthenticationConverter()
                {
                    @Override
                    protected Collection<GrantedAuthority> extractAuthorities(final Jwt jwt)
                    {
                        Collection<GrantedAuthority> authorities = super.extractAuthorities(jwt);
                        Map<String, Object> resourceAccess = jwt.getClaim("resource_access");
                        Map<String, Object> resource = null;
                        Collection<String> resourceRoles = null;
                        if (resourceAccess != null &&
                            (resource = (Map<String, Object>) resourceAccess.get("my-resource-id")) !=
                            null && (resourceRoles = (Collection<String>) resource.get("roles")) != null)
                            authorities.addAll(resourceRoles.stream()
                                                            .map(x -> new SimpleGrantedAuthority("ROLE_" + x))
                                                            .collect(Collectors.toSet()));
                        return authorities;
                    }
                });

Where my-resource-id is both the resource identifier as it appears in the resource_access claim and the value associated to the API in the ResourceServerSecurityConfigurer.

Notice that extractAuthorities is actually deprecated, so a more future-proof solution should be implementing a full-fledged converter

    import org.springframework.core.convert.converter.Converter;
    import org.springframework.security.authentication.AbstractAuthenticationToken;
    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.JwtAuthenticationToken;
    import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;

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

    public class CustomJwtAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken>
    {
        private static Collection<? extends GrantedAuthority> extractResourceRoles(final Jwt jwt, final String resourceId)
        {
            Map<String, Object> resourceAccess = jwt.getClaim("resource_access");
            Map<String, Object> resource;
            Collection<String> resourceRoles;
            if (resourceAccess != null && (resource = (Map<String, Object>) resourceAccess.get(resourceId)) != null &&
                (resourceRoles = (Collection<String>) resource.get("roles")) != null)
                return resourceRoles.stream()
                                    .map(x -> new SimpleGrantedAuthority("ROLE_" + x))
                                    .collect(Collectors.toSet());
            return Collections.emptySet();
        }

        private final JwtGrantedAuthoritiesConverter defaultGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();

        private final String resourceId;

        public CustomJwtAuthenticationConverter(String resourceId)
        {
            this.resourceId = resourceId;
        }

        @Override
        public AbstractAuthenticationToken convert(final Jwt source)
        {
            Collection<GrantedAuthority> authorities = Stream.concat(defaultGrantedAuthoritiesConverter.convert(source)
                                                                                                       .stream(),
                                                                     extractResourceRoles(source, resourceId).stream())
                                                             .collect(Collectors.toSet());
            return new JwtAuthenticationToken(source, authorities);
        }
    }

I have tested both solutions using Spring Boot 2.1.9.RELEASE, Spring Security 5.2.0.RELEASE and an official Keycloak 7.0.0 Docker image.

Generally speaking, I suppose that whatever the actual Authorization Server (i.e. IdentityServer4, Keycloak...) this seems to be the proper place to convert claims into Spring Security grants.

2019-10-04

Solution

 25

Here is another solution

    private JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
        jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName("roles");
        jwtGrantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
        JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter);
        return jwtAuthenticationConverter;
    }

    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                .authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .oauth2ResourceServer().jwt()
                .jwtAuthenticationConverter(jwtAuthenticationConverter());
    }
2020-09-13