Service Foundry
Young Gyu Kim <credemol@gmail.com>

Spring Cloud Gateway & Spring Authorization Server with Roles

Introduction

This article is part of a series on Spring Cloud Gateway. The other articles in the series are:

  1. Part 1: Spring Cloud Gateway with Virtual Threads

  2. Part 2: Spring Cloud Gateway with Spring Authorization Server

  3. Part 3: Spring Cloud Gateway with Spring Authorization Server using Database

  4. Part 4: Spring Cloud Gateway & Spring Authorization Server with Roles

This is the fourth article in the series, which focuses on using Spring Authorization Server and Resource Server with Roles.

In this article, we are going to implement the following:

  1. Add roles to the JWT token generated by the Spring Authorization Server

  2. Use these roles as authorities in the Resource Server to secure the endpoints.

References

This document is based on the following references:

Add roles to the JWT token

In the previous article, we added the authorities to the JWT token. In this article, we are going to add roles to the JWT token.

JWT Payload

{
  "sub": "nsa2admin",
  "aud": "nsa2",
  "nbf": 1727731003,
  "scope": [
    "openid",
    "profile"
  ],
  "iss": "http://nsa2-auth-server:9000",
  "exp": 1727731303,
  "iat": 1727731003,
  "jti": "5e6b21b0-7744-41f9-b345-1fef26487b52"
}

In the JWT payload, there are scopes, and they can be used as authorities in the form of 'SCOPE_{scope}'.

For debugging purposes, I added the following endpoint to the Resource server to display the access token, authorities, and scope.

SecureController.java - accessToken methods
    @GetMapping("/access_token")
    public Map<String, String> accessToken(JwtAuthenticationToken jwtToken) {
        Map<String, Object> tokenAttributes = jwtToken.getTokenAttributes();
        log.info("principal class: {}", jwtToken.getPrincipal().getClass());

        var authorities = jwtToken.getAuthorities();
        log.info("authorities: {}", authorities);
        return Map.of(
                "principal", jwtToken.getName(),
                "access_token", jwtToken.getToken().getTokenValue(),
                "authorities", authorities.toString(),
                "scope",tokenAttributes.containsKey("scope") ?
                        tokenAttributes.get("scope").toString() : ""
        );
    }

When you access the /access_token endpoint, you will see the following output.

jaeger 1
Figure 1. Jaeger - Logs

The default authorities are SCOPE_openid and SCOPE_profile when scope is openid profile. And all JWT access tokens have the same authorities. However, that is not what we need. We wanted to configure the authorities to be ROLE_NSA2_USER and ROLE_NSA2_ADMIN based on users' roles to secure the endpoints in the Resource server.

To customizer the JWT token, we need to implement the OAuth2TokenCustomizer interface and override the customize method.

AuthorizationServerConfig.java - jwtTokenCustomizer method
    @Bean
    public OAuth2TokenCustomizer<JwtEncodingContext> jwtTokenCustomizer() {
        return (context) -> {
            if(OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) {
                log.debug("Adding roles to access token");
                log.debug("authorities: {}", context.getPrincipal().getAuthorities());

                context.getClaims().claims((claims) -> {
                    Set<String> roles = AuthorityUtils.authorityListToSet(
                                    context.getPrincipal().getAuthorities())
                            .stream()
                            .map((authority) -> authority.replaceFirst("^ROLE_", ""))
                            .collect(Collectors
                                    .collectingAndThen(Collectors.toSet(),
                                            Collections::unmodifiableSet));

                    log.debug("roles: {}", roles);
                    claims.put("roles", roles);
                });
            }
        };
    }

In the jwtTokenCustomizer method, we are adding roles to the JWT token. We are getting the authorities from the context.getPrincipal().getAuthorities() and converting them to roles by removing the ROLE_ prefix. And then we are adding the roles to the JWT token.

When you decode the JWT token, you will see the roles in the payload.

{
  "sub": "nsa2admin",
  "aud": "nsa2",
  "nbf": 1727740527,
  "scope": [
    "openid",
    "profile"
  ],
  "roles": [
    "NSA2_USER",
    "NSA2_ADMIN"
  ],
  "iss": "http://nsa2-auth-server:9000",
  "exp": 1727740827,
  "iat": 1727740527,
  "jti": "d42057ad-333e-4bb9-9f82-e26b0cba15f2"
}

Implement JwtAuthenticationConverter in the Resource server

In the Resource server, we need to implement the JwtAuthenticationConverter to convert the roles to authorities.

Nsa2JwtAuthenticationConverter.java
@Slf4j
public class Nsa2JwtGrantedAuthoritiesConverter implements Converter<Jwt, Collection<GrantedAuthority>> {

    @Override
    public <U> Converter<Jwt, U> andThen(Converter<? super Collection<GrantedAuthority>, ? extends U> after) {
        return Converter.super.andThen(after);
    }
    @Override
    public Collection<GrantedAuthority> convert(Jwt source) {
        var roles = source.getClaimAsStringList("roles");
        log.info("roles: {}", roles);

        // If roles are not present in the JWT token, then use the scopes as roles
        if(roles == null) {
            return source.getClaimAsStringList("scope")
                    .stream()
                    .map(scope -> "SCOPE_" + scope)
                    .map(SimpleGrantedAuthority::new)
                    .collect(Collectors.toList());
        }

        // If roles are present in the JWT token, then use the roles as roles
        return roles.stream()
                .map(role -> "ROLE_" + role)
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    }

}

In the Nsa2JwtGrantedAuthoritiesConverter class, we are converting the roles to authorities. If roles are not present in the JWT token, then we are using the scopes as authorities. If roles are present in the JWT token, then we are using the roles as authorities.

The Nsa2JwtGrantedAuthoritiesConverter class is used in the JwtAuthenticationConverter in the SecurityConfig class.

SecurityConfig.java - nsa2AuthenticationConverter method
    @Bean
    public JwtAuthenticationConverter nsa2AuthenticationConverter() {
        var converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(new Nsa2JwtGrantedAuthoritiesConverter());
        return converter;
    }
SecurityConfig.java - securityFilterChain method
    private static final String[] ALLOWED_URIS = {
            "/actuator/health",
            "/actuator/health/liveness",
            "/actuator/health/readiness"
    };

    // @formatter:off
    @Bean
    @Order(Ordered.HIGHEST_PRECEDENCE)
    public SecurityFilterChain securityFilterChain(
            HttpSecurity http,
            JwtAuthenticationConverter nsa2AuthenticationConverter) throws Exception {

        http
            .authorizeHttpRequests(authorize ->
                authorize
                    .requestMatchers(ALLOWED_URIS).permitAll()
                    .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2ResourceServer ->
                oauth2ResourceServer.jwt(jwt -> {
                    jwt.jwtAuthenticationConverter(nsa2AuthenticationConverter);
                })
            );

        return http.build();
    }
    // @formatter:on

In the securityFilterChain method, we are configuring the JwtAuthenticationConverter to convert the roles to authorities.

Here is the output when you access the /access_token endpoint with nsa2admin user.

jaeger 2
Figure 2. Jaeger - Logs

Now the authorities are ROLE_NSA2_USER and ROLE_NSA2_ADMIN based on the roles in the JWT token.

Here is the output when you access the /access_token endpoint with nsa2user user.

jaeger 3
Figure 3. Jaeger - access token

Now the authorities are ROLE_NSA2_USER based on the roles in the JWT token.

Secure the endpoints in the Resource server

SecureController.java - secure methods
    @PreAuthorize("hasAnyRole('NSA2_USER', 'NSA2_ADMIN')")
    @GetMapping("/hello")
    public Map<String, String> hello(Principal principal, JwtAuthenticationToken jwtToken) {
        log.info("principal: {}", principal);
        log.info("name: {}", jwtToken.getName());
        log.info("principal class: {}", principal.getClass());
        log.info("jwtToken class: {}", jwtToken.getClass());
        log.info("authorities: {}", jwtToken.getAuthorities());
        return Map.of("message", "ResourceServer - Hello, " + principal.getName());
    }


    @PreAuthorize("hasRole('NSA2_ADMIN')")
    @GetMapping("/admin/hello")
    public Map<String, String> adminHello(Principal principal) {
        return Map.of("message", "ResourceServer - Admin Hello, " + principal.getName());
    }

The nsa2admin user can access both /hello and /admin/hello endpoints. The nsa2user user can access only the /hello endpoint. When nsa2user tries to access the /admin/hello endpoint, it will get a 403 Forbidden error.

Here is the output when you access the /hello endpoint with nsa2user user.

jaeger ok
Figure 4. Jaeger OK

Here is the output when you access the /admin/hello endpoint with nsa2user user.

jaeger forbidden
Figure 5. Jaeger Forbidden

Conclusion

In this article, we added roles to the JWT token generated by the Spring Authorization Server and used these roles as authorities in the Spring Cloud Gateway to secure the endpoints.