04 Aug 2019

Custom JSON Web Token Claims in Spring Security OAuth2

Sometimes the standard claims provided by the framework are not enough, and we need to add some additional information to the JSON Web Tokens (JWT) for use on resource servers.
In this tutorial, we are going to look at how to add and use custom claims in JWT generated by Spring Security OAuth2.

1. Add custom claims

We will create an authorization server and configure it to add a custom claim to JWT.

1.1. Authorization Server

Let’s start by creating a configuration class that extends WebSecurityConfigurerAdapter in which we configure http security, set up in-memory authentication manager, and create some beans for further use:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .anyRequest()
                .authenticated();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
                .withUser("john")
                .password(passwordEncoder().encode("pass"))
                .roles("USER");
    }
}

Next, we create a configuration class for authorization server and configure the in-memory clients store with one initial client:

@Configuration
@EnableAuthorizationServer
public class AuthServerConfig extends AuthorizationServerConfigurerAdapter {

    private PasswordEncoder passwordEncoder;
    private AuthenticationManager authenticationManager;

    @Autowired
    public AuthServerConfig(PasswordEncoder passwordEncoder, AuthenticationManager authenticationManager) {
        this.passwordEncoder = passwordEncoder;
        this.authenticationManager = authenticationManager;
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
        endpoints.authenticationManager(authenticationManager);
    }

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                .withClient("client")
                .secret(passwordEncoder.encode("secret"))
                .authorizedGrantTypes("password")
                .scopes("read");
    }
}

We need to configure the access token converter and the token store in order to make the server to use JSON Web Tokens:

public class AuthServerConfig extends AuthorizationServerConfigurerAdapter {

    // ...

    @Bean
    public JwtAccessTokenConverter accessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setSigningKey("123456");
        return converter;
    }
    
    @Bean
    public TokenStore tokenStore() {
        return new JwtTokenStore(accessTokenConverter());
    }
    
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
        endpoints.authenticationManager(authenticationManager)
                .accessTokenConverter(accessTokenConverter())
                .tokenStore(tokenStore());
    }
    
    // ...
    
}

Now we can run and test our authorization server:

 
> curl client:secret@localhost:8080/oauth/token -d grant_type=password -d username=john -d password=pass
{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1NjQ1MTI2NDUsInVzZXJfbmFtZSI6ImpvaG4iLCJhdXRob3JpdGllcyI6WyJST0xFX1VTRVIiXSwianRpIjoiNWVhNTE5ZGItZDg2Ny00MGM0LTk4ODUtODhlNjQ5ZjNmNTRiIiwiY2xpZW50X2lkIjoiY2xpZW50Iiwic2NvcGUiOlsicmVhZCJdfQ.r6Zxv5vtr9mtc_NCEy9nuoCz-sWIoT_J2KzPDXmotlM","token_type":"bearer","expires_in":43199,"scope":"read","jti":"5ea519db-d867-40c4-9885-88e649f3f54b"}

The decrypted payload of this token looks like this:

{
  "exp": 1564512645,
  "user_name": "john",
  "authorities": [
    "ROLE_USER"
  ],
  "jti": "5ea519db-d867-40c4-9885-88e649f3f54b",
  "client_id": "client",
  "scope": [
    "read"
  ]
}

1.2. Token Enhancer

Now we can move on to adding custom claims to tokens. To do this, we need to create a class that implements TokenEnhancer. In our implementation, it will simply add user_id claim with a random integer:

public class CustomTokenEnhancer implements TokenEnhancer {

    @Override
    public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
        Map<String, Object> additionalInfo = new HashMap<>();
        additionalInfo.put("user_id", new Random().nextInt());
        ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo);
        return accessToken;
    }
}

To make it work, we need to add it to the configuration:

public class AuthServerConfig extends AuthorizationServerConfigurerAdapter {

    // ... 
    
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
        TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
        tokenEnhancerChain.setTokenEnhancers(List.of(new CustomTokenEnhancer(), accessTokenConverter()));
        endpoints.authenticationManager(authenticationManager)
                .tokenEnhancer(tokenEnhancerChain)
                .accessTokenConverter(accessTokenConverter())
                .tokenStore(tokenStore());
    }
    
    // ... 
    
}

We can restart the service and test it again:

 
> curl client:secret@localhost:8080/oauth/token -d grant_type=password -d username=john -d password=pass
{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMDk3NzQ0MjAwLCJ1c2VyX25hbWUiOiJqb2huIiwic2NvcGUiOlsicmVhZCJdLCJleHAiOjE1NjQ4OTQyNDMsImF1dGhvcml0aWVzIjpbIlJPTEVfVVNFUiJdLCJqdGkiOiI2YzhkOGY3Yi1jOGUxLTQ5NzMtYTM2ZS04NDExZWU0NjYyZTEiLCJjbGllbnRfaWQiOiJjbGllbnQifQ.cOhVIqxAvSRsHF3X-yKOCDF_Soo2yPSUfgNd6sXd6vg","token_type":"bearer","expires_in":43199,"scope":"read","user_id":1097744200,"jti":"6c8d8f7b-c8e1-4973-a36e-8411ee4662e1"}

Now we can observe the user id claim in the server response, as well as in the token payload:

{
  "user_id": 1097744200,
  "user_name": "john",
  "scope": [
    "read"
  ],
  "exp": 1564894243,
  "authorities": [
    "ROLE_USER"
  ],
  "jti": "6c8d8f7b-c8e1-4973-a36e-8411ee4662e1",
  "client_id": "client"
}

2. Use custom claims

Usually, in addition to adding claims to the token, we also need to use it on the resource server.

2.1. Resource server

We start by creating a configuration class for the resource server by extending ResourceServerConfigurerAdapter:

@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .anyRequest()
                .authenticated()
                .and()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    }
}

Note: We create it in the same project, and by default it will use the already created beans of the access token converter and the token store. If we want to have this server separate, we will need to to declare these beans again.

Next, we will create a controller that will return an authenticated principal:

@RestController
@RequestMapping("/user")
public class UserController {

    @GetMapping("/me")
    public ResponseEntity<UserDto> me() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        String principal = (String) authentication.getPrincipal();
        return ResponseEntity.ok(new UserDto(principal));
    }

    private class UserDto {
        private String username;

        UserDto(String username) {
            this.username = username;
        }

        public String getUsername() {
            return username;
        }
    }
}

We can test our controller by sending a request with a token received from the authorization server:

> curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMDk3NzQ0MjAwLCJ1c2VyX25hbWUiOiJqb2huIiwic2NvcGUiOlsicmVhZCJdLCJleHAiOjE1NjQ4OTQyNDMsImF1dGhvcml0aWVzIjpbIlJPTEVfVVNFUiJdLCJqdGkiOiI2YzhkOGY3Yi1jOGUxLTQ5NzMtYTM2ZS04NDExZWU0NjYyZTEiLCJjbGllbnRfaWQiOiJjbGllbnQifQ.cOhVIqxAvSRsHF3X-yKOCDF_Soo2yPSUfgNd6sXd6vg" localhost:8080/user/me
{"username":"john"}

2.2. Token Store

The first way to access custom claims is to read them from the token value that is stored in the authentication object using the readAccessToken(String) method from the TokenStore. Let’s create a private method in our controller to get custom claims from authentication, and add the user_id value obtained from them to the response:

@RestController
@RequestMapping("/user")
public class UserController {

    private TokenStore tokenStore;

    @Autowired
    public UserController(TokenStore tokenStore) {
        this.tokenStore = tokenStore;
    }

    @GetMapping("/me")
    public ResponseEntity<UserDto> me() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        String principal = (String) authentication.getPrincipal();
        Map<String, Object> additionalInfo = getAdditionalInfo(authentication);
        int userId = (int) additionalInfo.get("user_id");
        return ResponseEntity.ok(new UserDto(principal, userId));
    }

    private Map<String, Object> getAdditionalInfo(Authentication authentication) {
        OAuth2AuthenticationDetails details = (OAuth2AuthenticationDetails) authentication.getDetails();
        OAuth2AccessToken accessToken = tokenStore.readAccessToken(details.getTokenValue());
        return accessToken.getAdditionalInformation();
    }

    private class UserDto {
        private String username;
        private int userId;

        UserDto(String username, int userId) {
            this.username = username;
            this.userId = userId;
        }

        public String getUsername() {
            return username;
        }

        public int getUserId() {
            return userId;
        }
    }
}

If we repeat the request to the endpoint, in addition to the username, we also get the user ID obtained from the custom claims of the token:

> curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMDk3NzQ0MjAwLCJ1c2VyX25hbWUiOiJqb2huIiwic2NvcGUiOlsicmVhZCJdLCJleHAiOjE1NjQ4OTQyNDMsImF1dGhvcml0aWVzIjpbIlJPTEVfVVNFUiJdLCJqdGkiOiI2YzhkOGY3Yi1jOGUxLTQ5NzMtYTM2ZS04NDExZWU0NjYyZTEiLCJjbGllbnRfaWQiOiJjbGllbnQifQ.cOhVIqxAvSRsHF3X-yKOCDF_Soo2yPSUfgNd6sXd6vg" localhost:8080/user/me
{"username":"john","userId":1097744200}

2.3. Authentication

In order not to use TokenStore#readAccessToken(String) each time, we can read custom claims directly from the authentication details, but first we need to make sure that they are added there. To do this, we extend the DefaultAccessTokenConverter class and make it so that in addition to the standard authentication extraction, it also adds claims into the authentication details:

public class CustomAccessTokenConverter extends DefaultAccessTokenConverter {

    @Override
    public OAuth2Authentication extractAuthentication(Map<String, ?> claims) {
        OAuth2Authentication authentication
                = super.extractAuthentication(claims);
        authentication.setDetails(claims);
        return authentication;
    }
}

And make our JwtAccessTokenConverter use it:

@Bean
public JwtAccessTokenConverter accessTokenConverter() {
    JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
    converter.setAccessTokenConverter(new CustomAccessTokenConverter());
    converter.setSigningKey("123456");
    return converter;
}

Now we can change the private method in our controller to read claims from authentication details:

private Map<String, Object> getAdditionalInfo(Authentication authentication) {
    OAuth2AuthenticationDetails details = (OAuth2AuthenticationDetails) authentication.getDetails();
    return (Map<String, Object>) details.getDecodedDetails();
}

We can make sure that everything works and the response has not changed by repeating the request to the endpoint:

> curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMDk3NzQ0MjAwLCJ1c2VyX25hbWUiOiJqb2huIiwic2NvcGUiOlsicmVhZCJdLCJleHAiOjE1NjQ4OTQyNDMsImF1dGhvcml0aWVzIjpbIlJPTEVfVVNFUiJdLCJqdGkiOiI2YzhkOGY3Yi1jOGUxLTQ5NzMtYTM2ZS04NDExZWU0NjYyZTEiLCJjbGllbnRfaWQiOiJjbGllbnQifQ.cOhVIqxAvSRsHF3X-yKOCDF_Soo2yPSUfgNd6sXd6vg" localhost:8080/user/me
{"username":"john","userId":1097744200}

3. Conclusion

You can now add custom claims to your JSON Web Tokens, but name them carefully to avoid conflict with reserved claims defined by the JWT specification or other custom claims. It can be difficult to deal with two claims with the same name, which contain different information.

Full source code can be found on GitHub.