Extract roles from access token issued by Keycloak using Spring Security

Introduction

The problem: When parsing an access tokens issued by Keycloak using Spring Security the roles don’t get extracted from the token.

This post shows how to implement a custom converter for the token and combine it with the default JWT converter.

This post assumes that you know Spring Boot and are familiar with Keycloak and its concepts like realms, clients and roles.

The code can be found on GitHub. You can open the repo directly in Gitpod to try it out. Using Gitpod it will automatically set up a workspace including Keycloak.

If you don’t care about the background just look at these two classes. They are where the magic happens.

  1. KeycloakJwtRolesConverter
  2. WebSecurityConfiguration

Background

I recently needed to add authentication to a REST API which is implemented using Spring Boot. Keycloak is used to authenticate the users and issue access tokens.

In Keycloak roles can be assigned to a user on different levels: realm level and resource level.

A resource, also called client in Keycloak, represents an individual service or application. Meaning a user might have access to multiple resources. (I’m using resource and client interchangable. In the Keycloak UI it is called client but inside of the access token it is called resource.)

What I needed was to check if a user is assigned to a specific role in a specifc client. The default JWT converter does not extract this information since it is not where the converter expects it. There are two solutions to this problem:

  1. Configure a mapper in Keycloak that puts the roles inside the access token where the converter expects it.
  2. Implement a custom converter that extracts the roles from where they are by default.

Since I wanted to touch the Keycloak default setup as little as possible I opted for the second option.

Setup Keycloak

To setup Keycloak you can either open the repo directly in Gitpod or setup your own instance. No matter which option you choose you manually have to create the required users. (The instructions how to do this are at the end of this chapter.)

If you want to code along on your own machine download the docker-compose.yml and realm-export.json. Place both files into the same folder on your computer and execute docker compose up -d.

This will create a Keycloak Docker container into which a pre-configured realm and client are imported.

The realm is called “backendW and in it you’ll find a client called “rest-api". Additionally the roles user and admin have been created inside of that client.

The only thing left to do manually is to create two users:

  1. A user called “user” to which you assign only the “user” role from the rest-api client.
  2. A user called “admin” to which you assign the roles “user” and “admin” from the rest-api client.

Create Spring Boot Project

With Keycloak all set up it is time to create the Spring Boot project which will expose the REST API.

I’m using the latest stable version of Spring Boot (currently 3.0.2), Java 17 and Gradle.

Using this URL you’ll find a pre-configured project which you can download.

We specifically care about the Spring Security and OAuth2 Resource Server dependencies. The latter is the one allowing us to configure Keycloak as the server against which the access tokens will be verified.

After downloading the project import it into your IDE.

Setup the Endpoints

Create a new class io.betweendata.RestApi.endpoint.ApiController and add three endpoints to it. What the endpoints do is not important so we just have them return some text to verify which endpoint was called.

@RestController
public class ApiController {

  /**
   * Endpoint which should be accessible by anyone even without an access token. (see
   * {@link io.betweendata.RestApi.config.WebSecurityConfiguration#filterChain(HttpSecurity)} for the configuration.)
   */
  @RequestMapping(
          method = RequestMethod.GET,
          value = "/public",
          produces = {"text/plain"}
  )
  public ResponseEntity<String> publicEndpoint() {

    return new ResponseEntity<>("Public Endpoint", HttpStatus.CREATED);
  }

  /**
   * Endpoint which should be accessible only by users with the role "user"
   * {@link io.betweendata.RestApi.config.WebSecurityConfiguration#filterChain(HttpSecurity)} for the configuration.)
   */
  @RequestMapping(
          method = RequestMethod.GET,
          value = "/user",
          produces = {"text/plain"}
  )
  public ResponseEntity<String> userEndpoint() {

    return new ResponseEntity<>("User Endpoint", HttpStatus.CREATED);
  }

  /**
   * Endpoint which should be accessible only by users with the role "admin"
   * {@link io.betweendata.RestApi.config.WebSecurityConfiguration#filterChain(HttpSecurity)} for the configuration.)
   */
  @RequestMapping(
          method = RequestMethod.GET,
          value = "/admin",
          produces = {"text/plain"}
  )
  public ResponseEntity<String> adminEndpoint() {
    return new ResponseEntity<>("Admin Endpoint", HttpStatus.CREATED);
  }

}

If you start the app now and try to access any of the endpoints you would see a login dialog. This is the default behavior of Spring Security to avoid accidentally exposing any endpoints.

To change this create the class io.betweendata.RestApi.config.WebSecurityConfiguration and in it define that all endpoints as public without requiring any authentication. (We’ll secure it later again.)

@Configuration  
@EnableWebSecurity  
public class WebSecurityConfiguration {  
  
  @Bean  
  SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {  
    httpSecurity.authorizeHttpRequests().requestMatchers("/**").permitAll();  
  
    return httpSecurity.build();  
  }  
}

You can use curl to test that the endpoints are all reachable:
curl -i http://localhost:8080/public

Extract Roles from Access Token

The following is an example of an access token issued by Keycloak for the user “admin”. (The one created earlier to which we assigned the roles “user” and “admin”.)

{
  "exp": 1676996589,
  "iat": 1676996289,
  "jti": "30737091-fd28-4278-b87b-3e450ee85504",
  "iss": "http://localhost:8180/realms/backend",
  "aud": "account",
  "sub": "b9ec5e66-f4e0-440a-9b1b-56e9213300d0",
  "typ": "Bearer",
  "azp": "rest-api",
  "session_state": "48845725-6cbd-4c76-b9e7-7ce1f729171f",
  "acr": "1",
  "realm_access": {
    "roles": [
      "default-roles-backend",
      "offline_access",
      "uma_authorization"
    ]
  },
  "resource_access": {
    "rest-api": {
      "roles": [
        "admin",
        "user"
      ]
    },
    "account": {
      "roles": [
        "manage-account",
        "manage-account-links",
        "view-profile"
      ]
    }
  },
  "scope": "openid email profile",
  "sid": "48845725-6cbd-4c76-b9e7-7ce1f729171f",
  "email_verified": true,
  "preferred_username": "admin",
  "given_name": "",
  "family_name": ""
}

In it we can see that the user has a total of 8 roles assigned.

  1. default-roles-backend, offline_access and uma_authorization on the realm level.
  2. admin and user for the client rest-api.
  3. manage-account, manage-account-links and view-profile for the client account.

The first (realm) and last (account client) are assigned by default from Keycloak.

As mentioned earlier due to its format the roles are not automatically extracted by Spring. Only the scopes are.

Therefore we have to implement our own converter to extract the roles.

The following class io.betweendata.RestApi.security.oauth2.KeycloakJwtRolesConverter does exactly that. It extracts the roles claims from the realm and all resources and returns it as an authority.

In the returned authorities the realm roles are prefixed with ROLE_realm_ while the resource roles are prefixed with ROLE_[NAME_OF_THE_RESOURCE]_.

So the role user can be found in the authority ROLE_rest-api_user once the token has been converted.

public class KeycloakJwtRolesConverter implements Converter<Jwt, Collection<GrantedAuthority>> {
  /**
   * Prefix used for realm level roles.
   */
  public static final String PREFIX_REALM_ROLE = "ROLE_realm_";
  /**
   * Prefix used in combination with the resource (client) name for resource level roles.
   */
  public static final String PREFIX_RESOURCE_ROLE = "ROLE_";

  /**
   * Name of the claim containing the realm level roles
   */
  private static final String CLAIM_REALM_ACCESS = "realm_access";
  /**
   * Name of the claim containing the resources (clients) the user has access to.
   */
  private static final String CLAIM_RESOURCE_ACCESS = "resource_access";
  /**
   * Name of the claim containing roles. (Applicable to realm and resource level.)
   */
  private static final String CLAIM_ROLES = "roles";


  /**
   * Extracts the realm and resource level roles from a JWT token distinguishing between them using prefixes.
   */
  @Override
  public Collection<GrantedAuthority> convert(Jwt jwt) {
    // Collection that will hold the extracted roles
    Collection<GrantedAuthority> grantedAuthorities = new ArrayList<>();

    // Realm roles
    // Get the part of the access token that holds the roles assigned on realm level
    Map<String, Collection<String>> realmAccess = jwt.getClaim(CLAIM_REALM_ACCESS);

    // Verify that the claim exists and is not empty
    if (realmAccess != null && !realmAccess.isEmpty()) {
      // From the realm_access claim get the roles
      Collection<String> roles = realmAccess.get(CLAIM_ROLES);
      // Check if any roles are present
      if (roles != null && !roles.isEmpty()) {
        // Iterate of the roles and add them to the granted authorities
        Collection<GrantedAuthority> realmRoles = roles.stream()
                // Prefix all realm roles with "ROLE_realm_"
                .map(role -> new SimpleGrantedAuthority(PREFIX_REALM_ROLE + role))
                .collect(Collectors.toList());
        grantedAuthorities.addAll(realmRoles);
      }
    }

    // Resource (client) roles
    // A user might have access to multiple resources all containing their own roles. Therefore, it is a map of
    // resource each possibly containing a "roles" property.
    Map<String, Map<String, Collection<String>>> resourceAccess = jwt.getClaim(CLAIM_RESOURCE_ACCESS);

    // Check if resources are assigned
    if (resourceAccess != null && !resourceAccess.isEmpty()) {
      // Iterate of all the resources
      resourceAccess.forEach((resource, resourceClaims) -> {
        // Iterate of the "roles" claim inside the resource claims
        resourceClaims.get(CLAIM_ROLES).forEach(
                // Add the role to the granted authority prefixed with ROLE_ and the name of the resource
                role -> grantedAuthorities.add(new SimpleGrantedAuthority(PREFIX_RESOURCE_ROLE + resource + "_" + role))
        );
      });
    }

    return grantedAuthorities;
  }
}

You can find tests covering 100% of the implementation here.

Define Access Rules

That leaves us with two things to do:

  • Configure Spring to use our Keycloak converter
  • Define which roles are required to access the endpoints

To do this the implementation of the filterChain(...) method inside of WebSecurityConfiguration has to be changed to look like this:

@Bean
SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {

  DelegatingJwtGrantedAuthoritiesConverter authoritiesConverter =
          // Using the delegating converter multiple converters can be combined
          new DelegatingJwtGrantedAuthoritiesConverter(
                  // First add the default converter
                  new JwtGrantedAuthoritiesConverter(),
                  // Second add our custom Keycloak specific converter
                  new KeycloakJwtRolesConverter());

  // Set up http security to use the JWT converter defined above
  httpSecurity.oauth2ResourceServer().jwt().jwtAuthenticationConverter(
          jwt -> new JwtAuthenticationToken(jwt, authoritiesConverter.convert(jwt)));

  httpSecurity.authorizeHttpRequests(authorize -> authorize
          // Only users with the role "user" can access the endpoint /user.
          .requestMatchers("/user").hasAuthority(KeycloakJwtRolesConverter.PREFIX_RESOURCE_ROLE + "rest-api_user")
          // Only users with the role "admin" can access the endpoint /admin.
          .requestMatchers("/admin").hasAuthority(KeycloakJwtRolesConverter.PREFIX_RESOURCE_ROLE + "rest-api_admin")
          // All users, even once without an access token, can access the endpoint /public.
          .requestMatchers("/public").permitAll()
  );

  return httpSecurity.build();
}

That’s it. Once you restart the app you are no longer able to access the /user and /admin endpoints without an access token belonging to a user with the required roles.

Use curl -i http://localhost:8080/public to verify that for this endpoint no authentication is required.

Use the following command to get an access token for the user admin.

curl \
  -d "client_id=rest-api" \
  -d "username=admin" \
  -d "password=admin" \
  -d "grant_type=password" \
  "http://localhost:8180/realms/backend/protocol/openid-connect/token"

From the response copy the access token and use it in the following command:

curl -i http://localhost:8080/admin \
   -H "Authorization: Bearer REPLACE_WITH_ACCESS_TOKEN"

Replace the username and password with “user” and “user” to test that this user cannot access the /admin endpoint.

By default an access token from Keycloak is valid for 5min. So make sure to run the second command in that timeframe or get a new access token.

If you are not comfortable with curl you can use a tool like Insomnia to test the endpoints. In it you can configure the authentication for an endpoint directly.

The request to test that the “user” cannot access the /admin endpoint would look like this: Using “Fetch Token” will then automatically authenticate the user with Keycloak and add the access token to the request.

Conclusion

If you find any errors or have questions don’t hesitate to open an issue.