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;
}