Search code examples
spring-securityoauth-2.0jwttokenroles

Spring Security PreAuthorize ROLE does not pick up JWT claim


I have implemented a simple OAuth2 test app (Authorization Server, Resource Server, Client), based on a Baeldung example (Sample code is on GitHub)

Everything works fine, but I want to add an additional security contraint:

  • So far the ResourceServer verifies the Client’s SCOPE (by JWT inspection).
  • I want the ResourceServer to also check the ROLE of the user who authorized the client.

What I did:

  • By default the ROLE information is not as claim in the JWT, so I added it (in the Authorization Server). It now adds new claim auth (unsure if auth is the right keyword):
  @Bean
  OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer() {
    return context -> {
      if (context.getTokenType() == OAuth2TokenType.ACCESS_TOKEN) {
        Authentication principal = context.getPrincipal();
        String enumeratedRoles = principal.getAuthorities().iterator().next().getAuthority();
        context.getClaims().claim("auth", enumeratedRoles);
      }
    };
  }
  • I debugged the ResourceServer's portected method (the one invoked by client). Both, JWT and authorized.principal carry the additional claim, so far so good:
    • JWT bayload, deserialized:
{
   "sub":"AssortmentExtender",
   "aud":"assortment-client",
   "nbf":1689578763,
   "auth":"ROLE_ADMIN",
   "scope":[
      "assortment.write"
   ],
   "iss":"http://auth-server:9000",
   "exp":1689579063,
   "iat":1689578763
}
  • Protected method, debug info:
  @PreAuthorize("hasRole('ADMIN')")
  @PutMapping("/bookstore/isbns/{isbn}")
  public void addBookToAssortment(@RequestBody BookDetailsImpl bookDetails, final @AuthenticationPrincipal
  Jwt jwt, Authentication authentication) {
// Whatever protected logic, here I debugged...
}

Debugger fields, for method's authentication parameter correctly shows the ROLE_ADMIN claim:

  name = "AssortmentExtender"
    ...
  principal = {Jwt@7362}
    headers = {Collections$UnmodifiableMap@7402\ size = 2
    claims = {Collections$UnmodifiableMap@7403} size = 8
      "sub" -> "AssortmentExtender"
      "aud" -> {ArrayList@7428} size = 1
      "nbf" -> ‹Instant@74301 #2023-07-17T07:15:33Z"
      "auth" -> "ROLE_ADMIN"
      "scope" -> {ArrayList@7434} size = 1
      "iss" -> "htto://auth-server:9000"
      "exp" -> {Instant@7406) "2023-07-17T07:20:33Z"
      "iat" -> (Instant@7405) "2023-07-17T07:15:33Z"
    tokenValue = "eyJrawQiOil3YTzNTNmOCOxYTAXLTQwNmQtYjczOCthN
    issuedAt = (Instant@7405) "2023-07-17T07:15:33Z"
    expiresAt = {Instant@74061 2023-07-17T07:20:33Z"
  credentials = {Jwt@7362}

What does not work:

  • The ResourceServer's @PreAuthorize rejects the Client request.
  • Logger says:
  Failed to authorize ReflectiveMethodInvocation: [...] with authorization manager
org.springframework.security.config.annotation.method.configuration.DeferringObservationAuthorizationManager@4b97e0b0 and decision ExpressionAuthorizationDecision [granted=false,
expressionAttribute=hasRole('ADMIN')]

Summary:

  • The claim is there, JWT and principal object correctly show the ADMIN_ROLE claim.
  • @PreAuthorize rejects the request anyway.

Question:

  • Why does PreAuthorize not pick up the 'auth' claim?
  • Am I just using the wrong keyword? Is @PreAuthorize checking something else than the principal claims? 
(I've already tried various variants, including role, roles, as discussed here)

EDIT:

  • From what I've found so far, the problem is that @PreAuthorize does not acutally access the claims, but the authorities extracted from the JWT structure. By default it only extracts scopes, and prefixes each entry with SCOPE_.
  • My guess is that I somehow need to register a mechanism that does now the same for my jwt ROLES, and this seems to be possible with a custom JwtAuthenticationConverter. However, I am lost as of how to achieve this, notably since the jwt().jwtAuthenticationConverter(...) addendum for the SecurityConfig appears to be deprecated now.

EDIT2

  private Converter getJwtAuthenticationConverter() {

    // create a custom JWT converter to map the "roles" from the token as granted authorities
    JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter =
        new JwtGrantedAuthoritiesConverter();
    jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName("roles"); // claim as JSON entry in JWT
    jwtGrantedAuthoritiesConverter.setAuthorityPrefix(
        "ROLE_"); // prefix to be used in authority object

    // Return a new Converter object that reflects the above JWTclaim-To-Authorities rules.
    JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
    jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter);
    return jwtAuthenticationConverter;
  }
  • However unfortunately this seems to overwrite all existing authorities from the scope claims.
  • Based on a github discussion, I tried to write a converter that combines the existing scope rules with the custom roles claim, but unfortunatley this throws a cast exception at runtime:
// Throws classcast exception at runtime: 
// java.util.LinkedHashSet cannot be cast to class org.springframework.security.authentication.AbstractAuthenticationToken
  private Converter getDualJwtAuthenticationConverter() {

    JwtGrantedAuthoritiesConverter scope = new JwtGrantedAuthoritiesConverter();
    scope.setAuthorityPrefix("SCOPE_");
    scope.setAuthoritiesClaimName("scope");
    JwtGrantedAuthoritiesConverter roles = new JwtGrantedAuthoritiesConverter();
    roles.setAuthorityPrefix("ROLE_");
    roles.setAuthoritiesClaimName("roles");
    return new DelegatingJwtGrantedAuthoritiesConverter(scope, roles);
  }

As far as I can tell, the problem is that the Security configuration jwt.jwtAuthenticationConverter(whateverConverter()); wants a converter of type <Jwt, AbstractAuthToken>, while the DelegatingJwtGrantedAuthoritiesConverter is of type <Jwt, Collection<GrantedAuthority>>.

So ultimately the question is how to write a converter that combines multiple JWT claims into a fused list of authorities.


Solution

  • I figured out how to fuse multiple JWT claims to a list of authorities.

    • The trick was indeed to write a custom converter, as recommended by @Sebastiaan.
    • The solution is very close to this proposal (same goal, fusing of multiple claims), although I changed the proposal to match my JWT structure.
    • I also added JavaDoc comments for the implementation, to explain what actually happens in the converter code.

    In essence, only two things were needed:

    1. Create you own converter in a new class:
    import java.util.ArrayList;
    import java.util.Collection;
    import java.util.stream.Collectors;
    import java.util.Collections;
    import java.util.stream.Stream;
    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;
    
    /**
     * This is a custom converter to extract all entries of two Jwt claims "scope" and "role". All
     * findings are prefixed with the respective "SCOPE_" and "ROLE_" prefixes, and then wrapped up as a
     * list of authorities, which can be processed by Springs SPeL in @PreAuthorize annotations. This
     * implementation is based on: https://stackoverflow.com/a/58234971/13805480
     */
    public class FusedClaimConverter implements Converter<Jwt, AbstractAuthenticationToken> {
    
      // The fused converter internally fuses the outputs of two converters.
      // One component is the default converter (extracting scp/scope claim information).
      // So we create one instance of this off-the-shelf convert for later use.
      private final JwtGrantedAuthoritiesConverter defaultGrantedAuthoritiesConverter =
          new JwtGrantedAuthoritiesConverter();
    
      /**
       * This method provides the second component of our custom converter. It is a manual
       * implementation that searches the jwt for a custom "role" claim. If found, all entries are
       * prefixed with "ROLE_" and returned as list of authorities.
       *
       * @param jwt as the json web token to analyze for "role" claim entries.
       * @return collection of granted authorities extracted from the jwt.
       */
      private static Collection<? extends GrantedAuthority> extractResourceRoles(final Jwt jwt) {
        ArrayList<String> resourceAccess = jwt.getClaim(
            "role"); // <- specify here whatever additional jwt claim you wish to convert to authority
        if (resourceAccess != null) {
          // Convert every entry in value list of "role" claim to an Authority
          return resourceAccess.stream().map(x -> new SimpleGrantedAuthority("ROLE_" + x))
              .collect(Collectors.toSet());
        }
        // Fallback: return empty list in case the jwt has no "role" claim.
        return Collections.emptySet();
      }
    
      /**
       * This is the main converter method to override. In essence here we provide a custom
       * implementation that concatenates the authority lists generated from two respective conterters.
       * One is the off-the-shelf default converter that operates on the "scp"/"scope" claim. The other
       * is the converter for our custom claim.
       *
       * @param source as the json web token to inspect for claims
       * @return list of authorities extracted from token, wrapped up in AbstractAuthenticationToken
       * object.
       */
      @Override
      public AbstractAuthenticationToken convert(final Jwt source) {
        Collection<GrantedAuthority> authorities =
            Stream.concat(defaultGrantedAuthoritiesConverter.convert(source).stream(),
                extractResourceRoles(source).stream()).collect(Collectors.toSet());
        return new JwtAuthenticationToken(source, authorities);
      }
    }
    
    1. Register your custom converter in the ResourceServer's SecurityFilterChain:
    @Configuration
    @EnableWebSecurity
    @EnableMethodSecurity
    public class ResourceServerConfig {
    
    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    
        http
            .authorizeHttpRequests((authorize) -> authorize
                // Whatever custom rules...
                [...]
            .oauth2ResourceServer(oauth2 -> {
              oauth2.jwt(jwt -> {
                jwt.jwtAuthenticationConverter(new FusedClaimConverter()); // <-- Here the custom converter is registered.
              });
            });
    
        return http.build();
      }
    

    EDIT: For completeness, I've uploaded a well documented runnable application with this configuration on GitHub.