javaspringrestjwt

How to get access to the REST endpoints based on roles that are coming from payload claim JWT


I am trying to authenticate myself based on roles that I get in my claims. Basically I want to be able to connect to my application only if the specific roles are part of the claim

Dependencies :

    <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-web</artifactId>
          <version>3.1.4</version>
          <scope>compile</scope>
        </dependency>
        <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-security</artifactId>
          <version>3.1.4</version>
          <scope>compile</scope>
        </dependency>
        <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
          <version>3.1.4</version>
          <scope>compile</scope>
        </dependency>

This is my security class :

    @Configuration
    @EnableWebSecurity(debug = true)
    @AllArgsConstructor
    public class SecurityOAuth2Config {
    
      private static final Logger LOGGER = LogManager.getLogger(SecurityOAuth2Config.class);
    
      @Bean
      public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    
        http
          .cors(AbstractHttpConfigurer::disable)
          .headers(header -> header.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable))
          .csrf(AbstractHttpConfigurer::disable)
          .sessionManagement(sess -> sess.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
          .authorizeHttpRequests(req -> req
            .requestMatchers(antMatcher("/actuator/health")).permitAll()
            .requestMatchers(antMatcher("/actuator/info")).permitAll()
            .requestMatchers(antMatcher("/h2-console/**")).permitAll()
            // Invoked internally inside the kubernetes cluster
            .requestMatchers(antMatcher("/internal/api/**")).permitAll()
            .requestMatchers(antMatcher("/api/**"))
            .hasAnyRole("User1", "Admin3","TestDataUploader")
            .anyRequest().permitAll()
          )
          .oauth2ResourceServer((oauth2) -> oauth2.jwt(jwtConfigurer ->
            jwtConfigurer.jwtAuthenticationConverter(jwtAuthenticationConverter())));
    
    
        return http.getOrBuild();
      }
    
      @Bean
      public JwtAuthenticationConverter jwtAuthenticationConverter() {
        var grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
        grantedAuthoritiesConverter.setAuthoritiesClaimName("role");
        grantedAuthoritiesConverter.setAuthorityPrefix("");
        var jwtAuthenticationConverter = new JwtAuthenticationConverter();
        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
        return jwtAuthenticationConverter;
      }
    
      @Bean
      public GrantedAuthoritiesMapper userAuthoritiesMapper() {
        return (authorities) -> {
          Set<GrantedAuthority> mappedAuthorities = new HashSet<>();
          LOGGER.info("AUTHORITIES " +  authorities);
          authorities.forEach(authority -> {
            if (authority instanceof OidcUserAuthority oidcAuth) {
              oidcAuth.getUserInfo().getClaimAsStringList("role").forEach(
                role -> mappedAuthorities.add(new SimpleGrantedAuthority("" + role)));
            }
          });
          mappedAuthorities.addAll(authorities);
    
          return mappedAuthorities;
        };
      }
    }

I all the time get unauthorized

Bearer token example

{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1699881658,
  "iss": "https://id.test.io/",
  "upn": "Jon",
  "role": [
    "Admin",
    "Approve"
  ]
}

Controller endpoint example

    @RestController
    @RequestMapping("/api")
    @AllArgsConstructor
    public class ApplicationListController {
      private ServiceApp serviceApp;

     @GetMapping("/app")
     public Collection<Response> getApplications() {
       return serviceApp.loadResponse();
    }
   }

Application yaml file

  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://id.test.io/
          jwk-set-uri: https://id.test.io/.well-known/jwks.json

Solution

  • You should remove remove your GrantedAuthoritiesMapper bean, it duplicates what you already configured on the JwtAuthenticationConverter.

    When using .hasAnyRole("User1", "Admin3","TestDataUploader"), you expect the authentication to contain one of ROLE_User1, ROLE_Admin3 or ROLE_TestDataUploader authorities, but you map the "role": [ "Admin", "Approve" ] claim without a prefix => authorities are Admin and Approve => no match because of missing ROLE_ prefix and because expected role is Admin3 (not Admin as contained in your claims). Three options:

    Solution 1: with only "official" Boot starters

    spring-boot-starter-oauth2-resource-server is enough, you don't need to explicitly depend on spring-boot-starter-security

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

    Update your Java conf as follow (implementation of the second bullet point above):

    @Configuration
    @EnableWebSecurity(debug = true)
    @AllArgsConstructor
    public class SecurityOAuth2Config {
        
          private static final Logger LOGGER = LogManager.getLogger(SecurityOAuth2Config.class);
        
        @Bean
        public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthenticationConverter jwtAuthenticationConverter) throws Exception {
            http
              .cors(AbstractHttpConfigurer::disable)
              .headers(header -> header.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable))
              .csrf(AbstractHttpConfigurer::disable)
              .sessionManagement(sess -> sess.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
              .authorizeHttpRequests(req -> req
                .requestMatchers(antMatcher("/actuator/health")).permitAll()
                .requestMatchers(antMatcher("/actuator/info")).permitAll()
                .requestMatchers(antMatcher("/h2-console/**")).permitAll()
                // Invoked internally inside the kubernetes cluster
                .requestMatchers(antMatcher("/internal/api/**")).permitAll()
                .requestMatchers(antMatcher("/api/**"))
                .hasAnyRole("Admin", "User1", "TestDataUploader")
                .anyRequest().permitAll()
              )
              .oauth2ResourceServer((oauth2) -> oauth2.jwt(jwtConfigurer ->
                jwtConfigurer.jwtAuthenticationConverter(jwtAuthenticationConverter)));
    
            return http.getOrBuild();
        }
        
        @Bean
        public JwtAuthenticationConverter jwtAuthenticationConverter() {
            var grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
            grantedAuthoritiesConverter.setAuthoritiesClaimName("role");
            grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
            var jwtAuthenticationConverter = new JwtAuthenticationConverter();
            jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
            return jwtAuthenticationConverter;
        }
    }
    

    Keep the same properties (optionally remove jwk-set-uri which is most probably redundant in your case):

    spring:
      security:
        oauth2:
          resourceserver:
            jwt:
              issuer-uri: https://id.test.io/
              # jwk-set-uri is not needed when 
              # - the resource server can reach .well-known/openid-configuration using issuer-uri
              # - the iss claim in access tokens is exactly the value of issuer-uri (including case and trailing slash if any)
              # jwk-set-uri: https://id.test.io/.well-known/jwks.json
    

    Solution 2: using my starter & method security

    This would probably make your life easier.

    One more dependency:

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
    </dependency>
    
    <dependency>
        <groupId>com.c4-soft.springaddons</groupId>
        <artifactId>spring-addons-starter-oidc</artifactId>
        <version>7.1.13</version>
    </dependency>
    

    Replace spring.security.oauth2.resourceserver.* with the following:

    com:
      c4-soft:
        springaddons:
          oidc:
            ops:
            - iss: https://id.test.io/
              username-claim: upn
              authorities:
              - path: $.roles
                prefix: ROLE_
            resourceserver:
              permit-all:
              - /actuator/health
              - /actuator/info
              - /h2-console/**
              - /internal/api/**
              cors:
    

    Clear your Java security conf and add @EnableMethodSecurity

    @Configuration
    @EnableWebSecurity(debug = true)
    @EnableMethodSecurity
    public class SecurityOAuth2Config {
    }
    

    Yes, that simple, the stateless resource server filter-chain is created for you, based on the application properties above.

    Your controller becomes (mind the @PreAuthorize("hasAnyRole('Admin', 'User1', 'TestDataUploader')")):

    @RestController
    @RequestMapping("/api")
    @AllArgsConstructor
    public class ApplicationListController {
        private ServiceApp serviceApp;
    
        @GetMapping("/app")
        @PreAuthorize("hasAnyRole('Admin', 'User1', 'TestDataUploader')")
        public Collection<Response> getApplications() {
            return serviceApp.loadResponse();
        }
    }