Mapping Roles from Claims

The Problem

By default Spring only maps the scope claim (to SCOPE_* authorities). Keycloak puts roles somewhere else, in nested claims:

{
  "iss": "http://localhost:8180/realms/shopease",
  "aud": ["shopease-api", "account"],
  "sub": "9b1d2c3e-41f5-4a6b-8c7d-0e1f2a3b4c5d",
  "preferred_username": "asha",
  "scope": "openid email orders.read orders.write",
  "realm_access": { "roles": ["customer", "offline_access"] },
  "resource_access": { "shopease-api": { "roles": ["seller"] } }
}

The Mapping Logic in Plain Java

Example

This is exactly what our converter will do: scopes become SCOPE_x, realm and client roles become ROLE_X:

import java.util.*;

public class Main {
    static List<String> authorities(Map<String, Object> claims, String clientId) {
        List<String> result = new ArrayList<>();
        if (claims.get("scope") instanceof String scope) {
            for (String s : scope.split(" ")) result.add("SCOPE_" + s);
        }
        if (claims.get("realm_access") instanceof Map<?, ?> realm
                && realm.get("roles") instanceof List<?> roles) {
            for (Object r : roles) result.add("ROLE_" + r.toString().toUpperCase());
        }
        if (claims.get("resource_access") instanceof Map<?, ?> access
                && access.get(clientId) instanceof Map<?, ?> client
                && client.get("roles") instanceof List<?> clientRoles) {
            for (Object r : clientRoles) result.add("ROLE_" + r.toString().toUpperCase());
        }
        return result;
    }

    public static void main(String[] args) {
        Map<String, Object> asha = Map.of(
            "scope", "openid orders.read orders.write",
            "realm_access", Map.of("roles", List.of("customer", "offline_access")),
            "resource_access", Map.of("shopease-api", Map.of("roles", List.of("seller"))));
        Map<String, Object> batchJob = Map.of("scope", "inventory.sync");

        System.out.println("asha      -> " + authorities(asha, "shopease-api"));
        System.out.println("batch job -> " + authorities(batchJob, "shopease-api"));
        System.out.println("can see seller dashboard? "
            + authorities(asha, "shopease-api").contains("ROLE_SELLER"));
    }
}

Output

asha      -> [SCOPE_openid, SCOPE_orders.read, SCOPE_orders.write, ROLE_CUSTOMER, ROLE_OFFLINE_ACCESS, ROLE_SELLER]
batch job -> [SCOPE_inventory.sync]
can see seller dashboard? true

The Spring Converter

@Configuration
public class JwtAuthConfig {

    @Bean
    JwtAuthenticationConverter jwtAuthenticationConverter(
            @Value("${shopease.oauth.client-id:shopease-api}") String clientId) {

        JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter();   // scope -> SCOPE_*

        JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
        converter.setPrincipalClaimName("preferred_username");    // authentication.getName() = "asha"
        converter.setJwtGrantedAuthoritiesConverter(jwt -> {
            Collection<GrantedAuthority> authorities = new ArrayList<>(scopes.convert(jwt));

            Map<String, Object> realm = jwt.getClaimAsMap("realm_access");
            if (realm != null && realm.get("roles") instanceof Collection<?> roles) {
                roles.forEach(r -> authorities.add(new SimpleGrantedAuthority("ROLE_" + r.toString().toUpperCase())));
            }
            Map<String, Object> access = jwt.getClaimAsMap("resource_access");
            if (access != null && access.get(clientId) instanceof Map<?, ?> client
                    && client.get("roles") instanceof Collection<?> clientRoles) {
                clientRoles.forEach(r -> authorities.add(new SimpleGrantedAuthority("ROLE_" + r.toString().toUpperCase())));
            }
            return authorities;
        });
        return converter;
    }
}

Now URL rules like hasRole("SELLER") and method rules like @PreAuthorize("hasRole('ADMIN')") work with Keycloak tokens exactly as they did with your own JWTs.

Tip: Auth0 and Okta usually put roles or permissions in a top-level claim (Auth0 RBAC: permissions; custom: https://shopease.in/roles). For a flat claim you do not need custom code: scopes.setAuthoritiesClaimName("permissions"); scopes.setAuthorityPrefix("");.

Practice

Exercise: Auth0 sends "permissions": ["read:orders", "write:orders"]. Configure a converter so hasAuthority("read:orders") works.

Show answer
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
    JwtGrantedAuthoritiesConverter permissions = new JwtGrantedAuthoritiesConverter();
    permissions.setAuthoritiesClaimName("permissions");
    permissions.setAuthorityPrefix("");               // keep "read:orders" as-is

    JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
    converter.setJwtGrantedAuthoritiesConverter(permissions);
    return converter;
}