7

I'm trying to have my authorization server generate a JWT access token with some custom claims in it.

Here is what the Bearer token returned by the authorization server /auth/token endpoint looks like: 51aea31c-6b57-4c80-9d19-a72e15cb2bb7

I find this token a bit short to be a JWT token and to contain my custom claims...

And when using it in subsequent requests to the resource server, it complains with the error: Cannot convert access token to JSON

I'm using the following dependencies:

  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.1.1.RELEASE</version>
    <relativePath/>
  </parent>

  <dependency>
    <groupId>org.springframework.security.oauth.boot</groupId>
    <artifactId>spring-security-oauth2-autoconfigure</artifactId>
    <version>2.1.2.RELEASE</version>
  </dependency>

The authorization server is configured this way:

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
  endpoints
  .tokenServices(defaultTokenServices())
  .allowedTokenEndpointRequestMethods(HttpMethod.GET, HttpMethod.POST)
  .accessTokenConverter(jwtAccessTokenConverter())
  .userDetailsService(userDetailsService);

  endpoints
  .pathMapping("/oauth/token", RESTConstants.SLASH + DomainConstants.AUTH + RESTConstants.SLASH + DomainConstants.TOKEN);

  TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
  tokenEnhancerChain.setTokenEnhancers(Arrays.asList(tokenEnhancer(), jwtAccessTokenConverter()));
  endpoints
  .tokenStore(tokenStore())
  .tokenEnhancer(tokenEnhancerChain)
  .authenticationManager(authenticationManager);
}

@Bean
@Primary
public DefaultTokenServices defaultTokenServices() {
  DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
  defaultTokenServices.setTokenStore(tokenStore());
  defaultTokenServices.setSupportRefreshToken(true);
  return defaultTokenServices;
}

@Bean
public TokenStore tokenStore() {
  return new JwtTokenStore(jwtAccessTokenConverter());
}

@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
  JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
  jwtAccessTokenConverter.setKeyPair(new KeyStoreKeyFactory(new ClassPathResource(jwtProperties.getSslKeystoreFilename()), jwtProperties.getSslKeystorePassword().toCharArray()).getKeyPair(jwtProperties.getSslKeyPair()));
return jwtAccessTokenConverter;
}

@Bean
public TokenEnhancer tokenEnhancer() {
  return new CustomTokenEnhancer();
}

And it's using the class:

class CustomTokenEnhancer implements TokenEnhancer {

  @Autowired
  private TokenAuthenticationService tokenAuthenticationService;

  // Add user information to the token
  @Override
  public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
    User user = (User) authentication.getPrincipal();
    Map<String, Object> info = new LinkedHashMap<String, Object>(accessToken.getAdditionalInformation());
    info.put(CommonConstants.JWT_CLAIM_USER_EMAIL, user.getEmail().getEmailAddress());
    info.put(CommonConstants.JWT_CLAIM_USER_FULLNAME, user.getFirstname() + " " + user.getLastname());
    info.put("scopes", authentication.getAuthorities().stream().map(s -> s.toString()).collect(Collectors.toList()));
    info.put("organization", authentication.getName());
    DefaultOAuth2AccessToken customAccessToken = new DefaultOAuth2AccessToken(accessToken);
    customAccessToken.setAdditionalInformation(info);
    customAccessToken.setExpiration(tokenAuthenticationService.getExpirationDate());
    return customAccessToken;
  }

}

I also have the class:

@Configuration
class CustomOauth2RequestFactory extends DefaultOAuth2RequestFactory {

  @Autowired
  private TokenStore tokenStore;

  @Autowired
  private UserDetailsService userDetailsService;

  public CustomOauth2RequestFactory(ClientDetailsService clientDetailsService) {
    super(clientDetailsService);
  }

  @Override
  public TokenRequest createTokenRequest(Map<String, String> requestParameters, ClientDetails authenticatedClient) {
    if (requestParameters.get("grant_type").equals("refresh_token")) {
      OAuth2Authentication authentication = tokenStore
          .readAuthenticationForRefreshToken(tokenStore.readRefreshToken(requestParameters.get("refresh_token")));
      SecurityContextHolder.getContext()
          .setAuthentication(new UsernamePasswordAuthenticationToken(authentication.getName(), null,
              userDetailsService.loadUserByUsername(authentication.getName()).getAuthorities()));
    }
    return super.createTokenRequest(requestParameters, authenticatedClient);
  }

}

UPDATE: I also tried the alternative way of specifying the custom claim:

@Component
class CustomAccessTokenConverter extends JwtAccessTokenConverter {

    @Autowired
  private TokenAuthenticationService tokenAuthenticationService;

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

  @Override
  public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
    User user = (User) authentication.getPrincipal();
    Map<String, Object> info = new LinkedHashMap<String, Object>(accessToken.getAdditionalInformation());
    info.put(CommonConstants.JWT_CLAIM_USER_EMAIL, user.getEmail().getEmailAddress());
    info.put(CommonConstants.JWT_CLAIM_USER_FULLNAME, user.getFirstname() + " " + user.getLastname());
    info.put("scopes", authentication.getAuthorities().stream().map(s -> s.toString()).collect(Collectors.toList()));
    info.put("organization", authentication.getName());
    DefaultOAuth2AccessToken customAccessToken = new DefaultOAuth2AccessToken(accessToken);
    customAccessToken.setAdditionalInformation(info);
    customAccessToken.setExpiration(tokenAuthenticationService.getExpirationDate());
    return super.enhance(customAccessToken, authentication);
  }

}

with it being called like:

endpoints
.tokenStore(tokenStore())
.tokenEnhancer(jwtAccessTokenConverter())
.accessTokenConverter(jwtAccessTokenConverter())

but it changed nothing and the error remained identical.

Running with the debugger, none of these two enhancer overrides are called.

Stephane
  • 11,836
  • 25
  • 112
  • 175

2 Answers2

7

To build Spring Boot server with OAuth2, JWT and extra claims we should:

1) Add dependency to the project:

<dependency>
    <groupId>org.springframework.security.oauth.boot</groupId>
    <artifactId>spring-security-oauth2-autoconfigure</artifactId>
    <version>2.1.2.RELEASE</version>
</dependency>

2) Add Web security configuration (to publish AuthenticationManager bean - it will be used in the next step), for example:

@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

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

    @Override
    @Autowired
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(username -> AuthUser.with()
                .username(username)
                .password("{noop}" + username)
                .email(username + "@mail.com")
                .authority(AuthUser.Role.values()[ThreadLocalRandom.current().nextInt(2)])
                .build()
        );
    }

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

Here is implemented a simple UserDetailsService for testing purpose. It works with the following simple 'User' object and Role enum which implements GrantedAuthority interface. AuthUser has only one additional property email which will be added to the JWT token as a claim.

@Value
@EqualsAndHashCode(callSuper = false)
public class AuthUser extends User {

    private String email;

    @Builder(builderMethodName = "with")
    public AuthUser(final String username, final String password, @Singular final Collection<? extends GrantedAuthority> authorities, final String email) {
        super(username, password, authorities);
        this.email = email;
    }

    public enum Role implements GrantedAuthority {
        USER, ADMIN;

        @Override
        public String getAuthority() {
            return this.name();
        }
    }
}

3) Configure Authorization server and enable Resource server:

@Configuration
@EnableAuthorizationServer
@EnableResourceServer
public class AuthServerConfig extends AuthorizationServerConfigurerAdapter {

    public static final String TOKEN_KEY = "abracadabra";

    private final AuthenticationManager authenticationManager;

    public AuthServerConfig(final AuthenticationManager authenticationManager) {
        this.authenticationManager = authenticationManager;
    }

    @Override
    public void configure(ClientDetailsServiceConfigurer clientDetailsService) throws Exception {
        clientDetailsService.inMemory()
                .withClient("client")
                .secret("{noop}")
                .scopes("*")
                .authorizedGrantTypes("password", "refresh_token")
                .accessTokenValiditySeconds(60 * 2) // 2 min
                .refreshTokenValiditySeconds(60 * 60); // 60 min
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
        TokenEnhancerChain chain = new TokenEnhancerChain();
        chain.setTokenEnhancers(List.of(tokenEnhancer(), tokenConverter()));
        endpoints
                .tokenStore(tokenStore())
                .reuseRefreshTokens(false)
                .tokenEnhancer(chain)
                .authenticationManager(authenticationManager);
    }

    @Bean
    public TokenStore tokenStore() {
        return new JwtTokenStore(tokenConverter());
    }

    @Bean
    public JwtAccessTokenConverter tokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setSigningKey(TOKEN_KEY);
        converter.setAccessTokenConverter(authExtractor());
        return converter;
    }

    private TokenEnhancer tokenEnhancer() {
        return (accessToken, authentication) -> {
                if (authentication != null && authentication.getPrincipal() instanceof AuthUser) {
                    AuthUser authUser = (AuthUser) authentication.getPrincipal();
                    Map<String, Object> additionalInfo = new HashMap<>();
                    additionalInfo.put("user_email", authUser.getEmail());
                    ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo);
                }
                return accessToken;
            };
    }

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

A simple ClientDetailsService is implemented here. It contains only one client, which has 'client' name, blank password and granted types "password" and "refresh_token". It gives us a possibility to create a new access token and refresh it. (To work with many types of clients or in other scenarios you have to implement more complex, and maybe persistent, variants of ClientDetailsService.)

Authorization endpoints are configured with TokenEnhancerChain which contains tokenEnhancer and tokenConverter. It's important to add them in this sequence. The first one enhances an access token with additional claims (user email in our case). The second one creates a JWT token. The endpoints set with a simple JwtTokenStore, our TokenEnhancerChain and authenticationManager.

Note to JwtTokenStore - if you decide to implement a persistent variant of the store you can find more info here.

The last thing here is authExtractor which gives us a possibility to extract claims from JWT tokens of incoming requests.

Then all things are set up we can request our server to get an access token:

curl -i \
--user client: \
-H "Content-Type: application/x-www-form-urlencoded" \
-X POST \
-d "grant_type=password&username=user&password=user&scope=*" \
http://localhost:8080/oauth/token

Rsponse:

{
    "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2VtYWlsIjoidXNlckBtYWlsLmNvbSIsInVzZXJfbmFtZSI6InVzZXIiLCJzY29wZSI6WyIqIl0sImV4cCI6MTU0Nzc2NDIzOCwiYXV0aG9yaXRpZXMiOlsiQURNSU4iXSwianRpIjoiYzk1YzkzYTAtMThmOC00OGZjLWEzZGUtNWVmY2Y1YWIxMGE5IiwiY2xpZW50X2lkIjoiY2xpZW50In0.RWSGMC0w8tNafT28i2GLTnPnIiXfAlCdydEsNNZK-Lw",
    "token_type": "bearer",
    "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2VtYWlsIjoidXNlckBtYWlsLmNvbSIsInVzZXJfbmFtZSI6InVzZXIiLCJzY29wZSI6WyIqIl0sImF0aSI6ImM5NWM5M2EwLTE4ZjgtNDhmYy1hM2RlLTVlZmNmNWFiMTBhOSIsImV4cCI6MTU0Nzc2NzcxOCwiYXV0aG9yaXRpZXMiOlsiQURNSU4iXSwianRpIjoiZDRhNGU2ZjUtNDY2Mi00NGZkLWI0ZDgtZWE5OWRkMDJkYWI2IiwiY2xpZW50X2lkIjoiY2xpZW50In0.m7XvxwuPiTnPaQXAptLfi3CxN3imfQCVKyjmMCIPAVM",
    "expires_in": 119,
    "scope": "*",
    "user_email": "user@mail.com",
    "jti": "c95c93a0-18f8-48fc-a3de-5efcf5ab10a9"
}

If we decode this access token on https://jwt.io/ we can see that it contain the user_email claim:

{
  "user_email": "user@mail.com",
  "user_name": "user",
  "scope": [
    "*"
  ],
  "exp": 1547764238,
  "authorities": [
    "ADMIN"
  ],
  "jti": "c95c93a0-18f8-48fc-a3de-5efcf5ab10a9",
  "client_id": "client"
}

To extract such a claim (and other data) from a JWT token of incoming requests we can use the following approach:

@RestController
public class DemoController {

    @GetMapping("/demo")
    public Map demo(OAuth2Authentication auth) {

        var details = (OAuth2AuthenticationDetails) auth.getDetails();
        //noinspection unchecked
        var decodedDetails = (Map<String, Object>) details.getDecodedDetails();

        return Map.of(
                "name", decodedDetails.get("user_name"),
                "email", decodedDetails.get("user_email"),
                "roles", decodedDetails.get("authorities")
        );
    }
}

My working demo: sb-jwt-oauth-demo

Related info:

Cepr0
  • 28,144
  • 8
  • 75
  • 101
  • Nice well rounded tutorial. Other than that, did you see anything in my configuration that would explain my issue ? – Stephane Jan 20 '19 at 10:57
  • 1
    @Stephane Oauth is a rather complicated topic. Because of this, it is not easy to find the reason why someone else's code does not work. Therefore, I've provided a minimal working example so that you can compare it with yours and find the cause of the problem (or just take this example as a basis). The main points that need to be paid attention to are `tokenConverter` (it creates the JWT token), `tokenEnhancer` (it adds claims to the token) and the `TokenEnhancerChain` that combines them. – Cepr0 Jan 20 '19 at 12:05
  • Yes I tried to see the difference between our codes or anything in yours that I had not implemented, but for now, I'm still not seeing any solution. – Stephane Jan 20 '19 at 17:11
  • @Stephane I think you should take my code as a basis and then extend it step by step with your code. Thus you can determine the problem place. – Cepr0 Jan 20 '19 at 17:36
  • @Cepr0 - this sample adds to the JWT payload AND the json. That doesn't seem right to me. It should be in the payload only? – SledgeHammer Oct 28 '19 at 03:49
  • @SledgeHammer It's an interesting question and requires separate research. – Cepr0 Oct 28 '19 at 18:34
  • @Cepr0 -- found the answer here: https://stackoverflow.com/questions/46406905/spring-oauth-2-jwt-inlcuding-additional-info-just-in-access-token – SledgeHammer Oct 28 '19 at 22:55
5

If you shared a sample project, it would be easier to spot the exact fix for you. In lieu of that, did you set a breakpoint at .tokenEnhancer(tokenEnhancerChain) and did it trigger?

I've created a super simple sample project, that shows how the tokenEnhancer is being invoked

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

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

    @Bean //by exposing this bean, password grant becomes enabled
    public AuthenticationManager authenticationManager() throws Exception {
        return super.authenticationManager();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService());
    }

    @Bean
    public UserDetailsService userDetailsService() {
        return new InMemoryUserDetailsManager(
            builder()
                .username("user")
                .password("{bcrypt}$2a$10$C8c78G3SRJpy268vInPUFu.3lcNHG9SaNAPdSaIOy.1TJIio0cmTK") //123
                .roles("USER")
                .build(),
            builder()
                .username("admin")
                .password("{bcrypt}$2a$10$XvWhl0acx2D2hvpOPd/rPuPA48nQGxOFom1NqhxNN9ST1p9lla3bG") //password
                .roles("ADMIN")
                .build()
        );
    }

    @EnableAuthorizationServer
    public static class Oauth2SecurityConfig extends AuthorizationServerConfigurerAdapter {
        private final PasswordEncoder passwordEncoder;
        private final AuthenticationManager authenticationManager;

        public Oauth2SecurityConfig(PasswordEncoder passwordEncoder,
                                    AuthenticationManager authenticationManager) {
            this.passwordEncoder = passwordEncoder;
            this.authenticationManager = authenticationManager;
        }

        @Bean
        public TokenEnhancer tokenEnhancer() {
            return new CustomTokenEnhancer();
        }

        @Override
        public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
            endpoints
                .tokenEnhancer(tokenEnhancer())
                .authenticationManager(authenticationManager)
            ;

        }

        @Override
        public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
            InMemoryClientDetailsService clientDetails = new InMemoryClientDetailsService();
            BaseClientDetails client = new BaseClientDetails(
                "testclient",
                null,
                "testscope,USER,ADMIN",
                "password",
                null
            );
            client.setClientSecret(passwordEncoder.encode("secret"));
            clientDetails.setClientDetailsStore(
                Collections.singletonMap(
                    client.getClientId(),
                    client
                )
            );
            clients.withClientDetails(clientDetails);
        }

    }

}

In this sample, there is also a unit test

@Test
@DisplayName("perform a password grant")
void passwordGrant() throws Exception {
    mvc.perform(
        post("/oauth/token")
            .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE)
            .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
            .param("username", "admin")
            .param("password", "password")
            .param("grant_type", "password")
            .param("response_type", "token")
            .param("client_id", "testclient")
            .header("Authorization", "Basic "+ Base64.encodeBase64String("testclient:secret".getBytes()))
    )
        .andExpect(status().isOk())
        .andExpect(content().string(containsString("\"full_name\":\"Joe Schmoe\"")))
        .andExpect(content().string(containsString("\"email\":\"Joe@Schmoe.Com\"")))
    ;
}

Feel free to check out the sample project and see if it works for you.,

Filip Hanik VMware
  • 1,484
  • 6
  • 8
  • Yes the debugger does stop at the line `.tokenEnhancer(tokenEnhancerChain)` but it doesn't stop at my two different `enhancer` methods. – Stephane Jan 14 '19 at 22:05
  • I have the full project at `https://github.com/stephaneeybert/user-rest` `https://github.com/stephaneeybert/user-data` `https://github.com/stephaneeybert/toolbox` I'll try your sample project tomorrow. – Stephane Jan 14 '19 at 22:07
  • I've checked out the first project, and a `mvn clean package` fails. Missing dependencies. Almost like it expects to be dependent on itself `The POM for com.thalasoft:user-data:jar:0.0.1-SNAPSHOT is missing, no dependency information available` My recommendation is to make it easier for someone to use your code. Take the sample, you will clone it, import it, and all projects will show up runnable in your IDE – Filip Hanik VMware Jan 15 '19 at 02:18
  • Did you first build the `toolbox` and then the `user-data` projects ? – Stephane Jan 15 '19 at 04:00
  • hi Stephane, I did not. Is there a reason you don't just have one repo, a parent POM with three modules that all build together? I may still get to it, but I have to down prioritize reviewing your code because of the complexity of getting set up. – Filip Hanik VMware Jan 15 '19 at 18:01
  • All right, no worries, I totally understand. The project is split up so as to be able to reuse the persistence layer some day if needed. To run it, one just needs to clone and build the 3 projects – Stephane Jan 15 '19 at 19:00
  • that goal can be achieved with one repo. take a look at Spring Security repo. published sub modules as separate artifacts. achieves what you are trying to do – Filip Hanik VMware Jan 15 '19 at 19:01