41

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";
    }
}
rigon
  • 1,310
  • 4
  • 15
  • 37
  • I have got the same issue, I am trying messing with a customized JwtDecoder and CustomAccessTokenConverter, but still no luck. The key should be placing the resource_access roles in a place where Spring Security can pick them up, but so far I had no luck, even following multiple tutorials/samples around the internet. – BladeWise Oct 04 '19 at 09:38
  • Some insights can be found in this video: https://www.youtube.com/watch?v=xrxWc7TG0zA&list=PLVApX3evDwJ1d0lKKHssPQvzv2Ao3e__Q This playlist contains actually good material to have a better understanding how to build SpringBoot applications. – rigon Oct 04 '19 at 15:40

6 Answers6

63

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.

BladeWise
  • 983
  • 9
  • 9
21

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());
    }
hillel_guy
  • 646
  • 6
  • 17
11

The difficulty you are experiencing is partly due to your roles being positioned in the JWT under resource_server->client_id. This then requires a custom token converter to extract them.

You can configure keycloak to use a client mapper that will present the roles under a top-level claim name such as "roles". This makes the Spring Security configuration simpler as you only need JwtGrantedAuthoritiesConverter with the authoritiesClaimName set as shown in the approach taken by @hillel_guy.

The keycloak client mapper would be configured like this:

enter image description here

jtsnr
  • 1,180
  • 11
  • 18
8

As already mentioned by @hillel_guy's answer, using an AbstractHttpConfigurer should be the way to go. This worked seamlessly for me with spring-boot 2.3.4 and spring-security 5.3.4. See the spring-security API documentation for reference: OAuth2ResourceServerConfigurer

UPDATE

Full example, as asked in the comments:

import org.springframework.context.annotation.Configuration;
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.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    private static final String JWT_ROLE_NAME = "roles";
    private static final String ROLE_PREFIX = "ROLES_";

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests().anyRequest().authenticated()
                .and().csrf().disable()
                .cors()
                .and().oauth2ResourceServer().jwt()
                .jwtAuthenticationConverter(jwtAuthenticationConverter());
    }

    private JwtAuthenticationConverter jwtAuthenticationConverter() {
        // create a custom JWT converter to map the roles from the token as granted authorities
        JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
        jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName(JWT_ROLE_NAME); // default is: scope, scp
        jwtGrantedAuthoritiesConverter.setAuthorityPrefix(ROLE_PREFIX ); // default is: SCOPE_

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

In my case, I wanted to map roles from the JWT instead of scope.
Hope this helps.

bepo
  • 81
  • 1
  • 4
1

2022 update

I maintain a set of tutorials and samples to configure resource-servers security for:

  • both servlet and reactive applications
  • decoding JWTs and introspecting access-tokens
  • default or custom Authentication implementations
  • any OIDC authorization-server(s), including Keycloak of course (most samples support multiple realms / identity-providers)

The repo also contains a set of libs published on maven-central to:

  • mock OAuth2 identities during unit and integration tests (with authorities and any OpenID claim, including private ones)
  • configure resource-servers from properties file (including source claims for roles, roles prefix and case processing, CORS configuration, session-management, public routes and more)

Sample for a servlet with JWT decoder

@EnableMethodSecurity(prePostEnabled = true)
@Configuration
public class SecurityConfig {}
com.c4-soft.springaddons.security.issuers[0].location=https://localhost:8443/realms/master
com.c4-soft.springaddons.security.issuers[0].authorities.claims=realm_access.roles,resource_access.spring-addons-public.roles,resource_access.spring-addons-confidential.roles
com.c4-soft.springaddons.security.cors[0].path=/sample/**
<dependency>
    <groupId>com.c4-soft.springaddons</groupId>
    <artifactId>spring-addons-webmvc-jwt-resource-server</artifactId>
    <version>6.0.3</version>
</dependency>

No, nothing more requried.

Unit-tests with mocked authentication

Secured @Component without http request (@Service, @Repository, etc.)

@Import({ SecurityConfig.class, SecretRepo.class })
@AutoConfigureAddonsSecurity
class SecretRepoTest {

    // auto-wire tested component
    @Autowired
    SecretRepo secretRepo;

    @Test
    void whenNotAuthenticatedThenThrows() {
        // call tested components methods directly (do not use MockMvc nor WebTestClient)
        assertThrows(Exception.class, () -> secretRepo.findSecretByUsername("ch4mpy"));
    }

    @Test
    @WithMockJwtAuth(claims = @OpenIdClaims(preferredUsername = "Tonton Pirate"))
    void whenAuthenticatedAsSomeoneElseThenThrows() {
        assertThrows(Exception.class, () -> secretRepo.findSecretByUsername("ch4mpy"));
    }

    @Test
    @WithMockJwtAuth(claims = @OpenIdClaims(preferredUsername = "ch4mpy"))
    void whenAuthenticatedWithSameUsernameThenReturns() {
        assertEquals("Don't ever tell it", secretRepo.findSecretByUsername("ch4mpy"));
    }

}

Secured @Controller (sample for @WebMvcTest but works for @WebfluxTest too)

@WebMvcTest(GreetingController.class) // Use WebFluxTest or WebMvcTest
@AutoConfigureAddonsWebSecurity // If your web-security depends on it, setup spring-addons security
@Import({ SecurityConfig.class }) // Import your web-security configuration
class GreetingControllerAnnotatedTest {

    // Mock controller injected dependencies
    @MockBean
    private MessageService messageService;

    @Autowired
    MockMvcSupport api;

    @BeforeEach
    public void setUp() {
        when(messageService.greet(any())).thenAnswer(invocation -> {
            final JwtAuthenticationToken auth = invocation.getArgument(0, JwtAuthenticationToken.class);
            return String.format("Hello %s! You are granted with %s.", auth.getName(), auth.getAuthorities());
        });
        when(messageService.getSecret()).thenReturn("Secret message");
    }

    @Test
    void greetWitoutAuthentication() throws Exception {
        api.get("/greet").andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockAuthentication(authType = JwtAuthenticationToken.class, principalType = Jwt.class, authorities = "ROLE_AUTHORIZED_PERSONNEL")
    void greetWithDefaultMockAuthentication() throws Exception {
        api.get("/greet").andExpect(content().string("Hello user! You are granted with [ROLE_AUTHORIZED_PERSONNEL]."));
    }
}

Advanced use-cases

The most advanced tutorial demoes how to define a custom Authentication implementation to parse (and expose to java code) any private claim into things that are security related but not roles (in the sample it's grant delegation between users).

It also shows how to extend spring-security SpEL to build a DSL like:

@GetMapping("greet/on-behalf-of/{username}")
@PreAuthorize("is(#username) or isNice() or onBehalfOf(#username).can('greet')")
public String getGreetingFor(@PathVariable("username") String username) {
    return ...;
}
ch4mp
  • 6,622
  • 6
  • 29
  • 49
0

If you are using Azure AD Oath there is a much easier way now:

        http 
        .cors()
        .and()
        .authorizeRequests()
        .anyRequest()
        .authenticated() 
        .and()
        .oauth2ResourceServer()
        .jwt()
        .jwtAuthenticationConverter(new AADJwtBearerTokenAuthenticationConverter("roles", "ROLE_")); 

The ADDJwtBearerTokenAuthenticationConverter allows you to add your claim name as the first argument and what you want your role prefixed with as the second argument.

My import so you can find the library:

import com.azure.spring.aad.webapi.AADJwtBearerTokenAuthenticationConverter;
  • Your answer is subjective because there are no sources that would back up your words. These types of answers are considered bad for SO and tend to be downvoted or even removed. Please refer to [How do I write a good answer?](https://stackoverflow.com/help/how-to-answer) *Section: Provide context for links*. – improbable Mar 18 '21 at 17:27