JWT Role Claims Population in Spring Security

I’ve been experiencing a problem with JWT role claims when integrating Auth0 with my Spring Boot application using Spring Security. The roles from my JWT are not being populated correctly, which results in authorization issues.

I’ve ensured that the claim name matches exactly with the URL in the JWT, i.e., https://project-pulse.secured.com/roles. I also have a custom JwtAuthenticationConverter in place that should set the roles. My expectation is that the JWT role of TENANT_ADMIN should translate to a Spring Security authority of ROLE_TENANT_ADMIN which then should give proper access.

In addition, I’m using the JwtDecoders provided by Spring to validate the issuer and audience, which seems to be working fine. The issue primarily is with the roles not being recognized or converted to Spring Security authorities.

JWT Payload:

{
  "https://project-pulse.secured.com/roles": ["TENANT_ADMIN"],
  "iss": "https://[REDACTED].us.auth0.com/",
  "sub": "google-oauth2|[REDACTED]",
  "aud": [
    "https://project-pulse.com/",
    "https://[REDACTED].us.auth0.com/userinfo"
  ],
  "iat": [REDACTED],
  "exp": [REDACTED],
  "azp": "[REDACTED]",
  "scope": "openid profile email",
  "org_id": "[REDACTED]"
}

SecurityConfig:

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
@Slf4j
public class SecurityConfig
{
    private final CorsConfigurationSource corsConfigurationSource;
    private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler;
    private final ClientRegistrationRepository clientRegistrationRepository;

    @Value("${okta.oauth2.audience}")
    private String audience;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws
            Exception {
        http.csrf(AbstractHttpConfigurer::disable)
                .cors(customizer -> customizer.configurationSource(corsConfigurationSource))
                .sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

                .authorizeHttpRequests(authorizer -> authorizer.requestMatchers("/student/**")
                        .hasRole("TENANT_ADMIN")
                        .requestMatchers("/secured")
                        .authenticated()
                        .requestMatchers("/oauth2/authorize-client", "/error/**")
                        .permitAll()
                        .anyRequest()
                        .authenticated())

                .oauth2ResourceServer(oauth2ResourceServer ->
                                          {
                                          oauth2ResourceServer.jwt(jwt -> jwt.jwtAuthenticationConverter(
                                                  customJwtAuthenticationConverter()));
                                          oauth2ResourceServer.bearerTokenResolver(cookieBearerTokenResolver());
                                          })

                .oauth2Login(oauth2Login -> oauth2Login.authorizationEndpoint(authorizationEndpoint -> authorizationEndpoint.authorizationRequestResolver(
                                new CustomOAuth2AuthorizationRequestResolver(clientRegistrationRepository)))
                        .successHandler(oAuth2LoginSuccessHandler)
                        .failureUrl("/login?error"));

        return http.build();
    }

    @Bean
    public BearerTokenResolver cookieBearerTokenResolver() {
        return request ->
            {
            Cookie[] cookies = request.getCookies();
            if (cookies != null) {
                for (Cookie cookie : cookies) {
                    if (cookie.getName()
                            .equals("access_token"))
                    {
                        return cookie.getValue();
                    }
                }
            }
            return null;
            };
    }

    @Bean
    public JwtAuthenticationConverter customJwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
        jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName(audience + "roles");
        jwtGrantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
        JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter);
        return jwtAuthenticationConverter;
    }


}

my JwtConfig

@Configuration
@Slf4j
public class JwtConfig
{

    @Value("${okta.oauth2.issuer}")
    private String issuerUrl;

    @Bean
    public JwtDecoder jwtDecoder() {
        String jwkSetUri = issuerUrl + ".well-known/jwks.json";
        NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri)
                .build();

        OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(issuerUrl);
        OAuth2TokenValidator<Jwt> withAudience =
                new DelegatingOAuth2TokenValidator<>(withIssuer, new AudienceValidator());
        jwtDecoder.setJwtValidator(withAudience);

        return jwtDecoder;
    }


    static class AudienceValidator implements OAuth2TokenValidator<Jwt>
    {
        private final OAuth2Error error = new BearerTokenError(BearerTokenErrorCodes.INVALID_TOKEN,
                                                               HttpStatus.UNAUTHORIZED,
                                                               "The required audience is missing",
                                                               null
        );

        public OAuth2TokenValidatorResult validate(Jwt jwt) {
            if (jwt.getAudience()
                    .contains("https://project-pulse.com/"))
            {
                return OAuth2TokenValidatorResult.success();
            }
            return OAuth2TokenValidatorResult.failure(error);
        }
    }

}

Below are relevant logs for when an authenticated user tried to access a protected resource, user have needed role that is assigned in the auth0 dashboard.

DEBUG o.s.s.o.s.r.a.JwtAuthenticationProvider.authenticate -
                Authenticated token
DEBUG o.s.s.o.s.r.w.a.BearerTokenAuthenticationFilter.doFilterInternal -
                Set SecurityContextHolder to JwtAuthenticationToken [Principal=org.springframework.security.oauth2.jwt.Jwt@c26d0db, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=null], Granted Authorities=[SCOPE_openid, SCOPE_profile, SCOPE_email]]
Did not set SecurityContextHolder since already authenticated JwtAuthenticationToken [Principal=org.springframework.security.oauth2.jwt.Jwt@c26d0db, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=null], Granted Authorities=[SCOPE_openid, SCOPE_profile, SCOPE_email]]
Mapped to com.projectpulse.projectpulsebe.features.user.controllers.HomeController#getCurrentUser(OidcUser)
Checking authorization on SecurityContextHolderAwareRequestWrapper[ org.springframework.security.web.header.HeaderWriterFilter$HeaderWriterRequest@7d37484b] using AuthorityAuthorizationManager[authorities=[ROLE_TENANT_ADMIN]]
Sending JwtAuthenticationToken [Principal=org.springframework.security.oauth2.jwt.Jwt@c26d0db, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=null], Granted Authorities=[SCOPE_openid, SCOPE_profile, SCOPE_email]] to access denied handler since access is denied

i dont know how relevant is this but here’s the action that im using to attach the roles to the app’s metadata :

exports.onExecutePostLogin = async (event, api) => {
  const namespace = 'https://project-pulse.secured.com';
  if (event.authorization) {
    api.idToken.setCustomClaim(`${namespace}/roles`, event.authorization.roles);
    api.accessToken.setCustomClaim(`${namespace}/roles`, event.authorization.roles);
  }
}

From the trace logs, it’s evident that only the default SCOPE_ prefixed authorities are being populated, but not the TENANT_ADMIN role from the JWT.

This is my first time working with this particular integration, and there’s a possibility that I might be overlooking something or making a newbie mistake. If anyone has encountered a similar problem or has insights on how to debug and fix this, I’d greatly appreciate your assistance!

If you use the Okta Spring Boot starter, you can eliminate a lot of your code and have audience validation and roles translated to groups automatically.

okta.oauth2.issuer=https://<your-auth0-domain>/
okta.oauth2.client-id=<client-id>
okta.oauth2.client-secret=<client-secret>
okta.oauth2.audience=https://project-pulse.com/
okta.oauth2.groupsClaim=https://project-pulse.secured.com/roles

You can try this out in a brand new Spring Boot app using our RBAC in Spring Boot lab.

2 Likes