1

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.

m5c
  • 45
  • 7

2 Answers2

1

Not entirely an answer but hopefully enough to get you on the way a bit.

On the topic of the JWT contents:

We also fill an authorities field ourselves in the claims section of the JWT.

On the topic of configuring the authentication converter:

This code isn't deprecated. The API only changed a little bit.

http.oauth2ResourceServer(resourceServer -> {
     resourceServer.jwt(jwt -> {
         jwt.jwtAuthenticationConverter(converter -> {
            // TODO: implement me
            return null; 
         });
     });
});

So you can actually still implement that in a non-deprecated way.

I don't know how to exactly implement that part though, since we don't use those methods and just do that ourselves instead. We do it by providing a bean of the type AuthenticationProvider. If you don't need anything fancy, I imagine your way will also work and might even be preferred (our code is from before the modern rewrite of the oauth2 implementation in Spring)

Sebastiaan van den Broek
  • 5,818
  • 7
  • 40
  • 73
  • So I would assume the "converter" I implement here in essence accesses the non-standard JWT claims and its output then serves as extension of the regular authorities? Is this understanding correct? The implementation something as described here: https://stackoverflow.com/q/72226464/13805480 – m5c Jul 17 '23 at 09:52
  • @m5c I imagine that would work, but I don't have a lot of experience with those classes. Our authentication mechanism allows for 3 types of authentication, of which oauth2 is just one, so it's a lot more custom code in our case. – Sebastiaan van den Broek Jul 17 '23 at 12:25
  • 1
    The way exposed here is a valid answer and should be accepted. In the `TODO`, you just merge the various claims you want as authorities source, with the processing you want for each (add "SCOPE_" prefix to entries in scope claim for instance or change case). You could also use ["my" starter](https://github.com/ch4mpy/spring-addons) which auto-configures an authorities converter based on application properties. – ch4mp Jul 17 '23 at 18:06
0

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.

m5c
  • 45
  • 7