Spring-boot Oauth2 Authorization Server with JWT, Custom claims, and Oauth2 Client Login

Christian
7 min readDec 30, 2020

Recently I had a requirement to include Google login to our already running spring-boot authorization server, well there are a lot of resources on spring-boot right? This should be straight forward, Somebody must have done this. I spent quite a lot of time searching the internet but to my greatest surprise, I couldn’t find anything…. It was either an outh2 client or an authorization server but not both. At last, I came across a GitHub project that pointed me in the right direction https://github.com/naturalprogrammer/spring-lemon. In this article, I’ll be taking you through creating your own Authorization server, JWT with some custom claims as well as Google login. Let’s dive right in.

Obtain Client credential from google console

To obtain client credentials for Google OAuth2 authentication, head on over to the Google API Console — section “Credentials”.

Here we’ll create credentials of type “OAuth2 Client ID” for our web application. This results in Google setting up a client id and secret for us.

We also have to configure an authorized redirect URI in the Google Console, which is the path that users will be redirected to after they successfully login with Google.

By default, Spring Boot configures this redirect URI as /login/oauth2/code/{registrationId}. Therefore, for Google we’ll add the URI:

http://localhost:8080/login/oauth2/code/google

Create a spring boot project, you can call it anything but am calling it an Authorization server, here is my pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.3.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>authorization-server</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>authorization-server</name>
<description>Example Spring-boot authorization server with JWT, custom claims and Oauth2 client</description>

<properties>
<java.version>14</java.version>
<spring-cloud.version>Hoxton.SR7</spring-cloud.version>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
<version>2.3.3.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-jwt</artifactId>
<version>1.1.1.RELEASE</version>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
</dependencies>

<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

...

</project>

Edit your application properties file to include the clientId and secret for google

spring.datasource.url=jdbc:h2:file:/data/auth
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=password
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.security.oauth2.client.registration.google.client-id= <Client id>
spring.security.oauth2.client.registration.google.client-secret=<client secret>

All done, now let’s get into the coding part, I’ll proceed to create a minimal user entity for our use case

package com.example.authorizationserver.entities;

import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import javax.persistence.*;
import java.io.Serializable;
import java.util.Date;

@Entity
@Table(name = "app_user")
@Getter
@Setter
@NoArgsConstructor
public class User implements Serializable {

/**
*
*/
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String firstName;
@Column(nullable = false)
private String lastName;
@Column(nullable = false)
private String email;
@Column(nullable = false)
private String password;
@Temporal(TemporalType.TIMESTAMP)
private Date createdAt;
@Temporal(TemporalType.TIMESTAMP)
private Date dateUpdated;

public User(User user){
this.id = user.getId();
this.firstName = user.getFirstName();
this.lastName = user.getLastName();
this.email = user.getEmail();
this.password = user.getPassword();
}

@PrePersist
private void setCreatedAt() {
createdAt = new Date();
}

@PreUpdate
private void setUpdatedAt() {
dateUpdated = new Date();
}


}

Next, we would create a custom UserDetail, UserdetailService, OidcUserService, DefaultOAuth2UserService, and authorization server configuration classes.

...@Getter
@Setter
public class CustomUserDetails extends User implements UserDetails, OidcUser {

/**
*
*/
private static final long serialVersionUID = 1L;

private Map<String, Object> attributes;
private String name;
private Map<String, Object> claims;
private OidcUserInfo userInfo;
private OidcIdToken idToken;

public CustomUserDetails(User user) {

super(user);
}


@Override
public Map<String, Object> getAttributes() {
return null;
}

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {

Collection<SimpleGrantedAuthority> c = new ArrayList<>();
c.add(new SimpleGrantedAuthority("USER"));
return c;
}

@Override
public String getPassword() {
return super.getPassword();
}

@Override
public String getUsername() {
return super.getEmail();
}

....



}

Noticed that in our custom user details class above we also implemented the OidUser interface, this is because the Oauth2 client library uses the OpenID connect identity layer, implementing the OidUser interface also allows us to use one userDetail for both the Outh2 client and authorization server. I experienced some issues with clashing properties here, for instance, if you had an address property in your user object then you will probably need to rename that field to something else. below is my user details service class

CustomUserDetailService.java

...@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

private final UserRepository userRepository;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

User user = userRepository.findByEmail(username).orElseThrow(() -> new BadCredentialsException(
"Invalid username or password"));
return new CustomUserDetails(user);

}

}

CustomOAuth2UserService.java

...
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

private CustomUserDetailsService userDetailsService;

public CustomOAuth2UserService(CustomUserDetailsService futureDAOUserDetailsService){
this.userDetailsService = futureDAOUserDetailsService;
}


@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {

OAuth2User oath2User = super.loadUser(userRequest);
return buildPrincipal(oath2User);
}

/**
* Builds the security principal from the given userReqest.
* Registers the user if not already reqistered
*/
public CustomUserDetails buildPrincipal(OAuth2User oath2User) {

Map<String, Object> attributes = oath2User.getAttributes();
String email = getOAuth2Email(attributes);

CustomUserDetails user = (CustomUserDetails) userDetailsService.loadUserByUsername(email);



return user;
}

public String getOAuth2Email(Map<String, Object> attributes) {

return (String) attributes.get(StandardClaimNames.EMAIL);
}


}

CustomOidcUserService

...public class CustomOidcUserService extends OidcUserService {

private static final Log log = LogFactory.getLog(CustomOidcUserService.class);

private CustomOAuth2UserService oauth2UserService;

public CustomOidcUserService(CustomOAuth2UserService oauth2UserService) {

this.oauth2UserService = oauth2UserService;
log.debug("Created");
}

@Override
public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException {

OidcUser oidcUser = super.loadUser(userRequest);
CustomUserDetails principal = oauth2UserService.buildPrincipal(oidcUser);

principal.setClaims(oidcUser.getClaims());
principal.setIdToken(oidcUser.getIdToken());
principal.setUserInfo(oidcUser.getUserInfo());

return principal;
}
}

In other to add custom claims to our JWT we will need to create two classes a token enhancer and an access token converter, this is achieved by extending the default classes spring provides.

public class CustomAccessTokenConverter extends DefaultAccessTokenConverter {

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

In the tokenEnhancer class below we have extracted some properties from the principal and added them as additional info to the access token.

public class CustomTokenEnhancer implements TokenEnhancer {

@Override
public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {

User user = (CustomUserDetails) authentication.getPrincipal();
final Map<String, Object> additionalInfo = new HashMap<>();

additionalInfo.put("userId", user.getId());
additionalInfo.put("userEmail", user.getEmail());
additionalInfo.put("userFullName", user.toString());

((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo);

return accessToken;
}


}

Finally, we need to create our Authorization server configuration

...@Configuration
@EnableAuthorizationServer
@RequiredArgsConstructor
public class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter{



private final AuthenticationManager authenticationManager;
private final PasswordEncoder passwordEncoder;


@Override
public void configure (ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory ()
.withClient ("user-client")
.authorizedGrantTypes ("password", "authorization_code", "refresh_token", "implicit")
.authorities ("ROLE_CLIENT", "ROLE_TRUSTED_CLIENT", "USER")
.scopes ("read", "write")
.autoApprove (true)
.secret (passwordEncoder. encode ("password")).redirectUris("https://test.com/fake");
}

@Override
public void configure(AuthorizationServerSecurityConfigurer security) {
security.tokenKeyAccess("permitAll()")
.checkTokenAccess("isAuthenticated()");
}



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


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


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


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


}

I have created an in-memory client for test purpose and set our custom token converter as the Jwt token converter, I have also added the custom token enhancer class to the list of enhancers of the Authorization server endpoint. In a production environment please make sure to keep your signing key securely.

I also created a configuration class for the web security configurations.

...@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

private final CustomUserDetailsService customUserDetailsService;

@Override
protected void configure(HttpSecurity http) throws Exception {

CustomOAuth2UserService customOAuth2UserService = new CustomOAuth2UserService(customUserDetailsService);
http.authorizeRequests().antMatchers("/login","/oauth/token", "/oauth/authorize/**").permitAll().and().authorizeRequests()
.anyRequest().authenticated().and()
.formLogin().and().httpBasic().disable()
.anonymous().disable().oauth2Login().userInfoEndpoint()
.userService(customOAuth2UserService).oidcUserService(new CustomOidcUserService(customOAuth2UserService));
}

@Bean
public FilterRegistrationBean<CorsFilter> corFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();

CorsConfiguration configAutenticacao = new CorsConfiguration();
configAutenticacao.setAllowCredentials(true);
configAutenticacao.addAllowedOrigin("*");
configAutenticacao.addAllowedHeader("*");
configAutenticacao.addAllowedMethod("*");
configAutenticacao.setMaxAge(3600L);
source.registerCorsConfiguration("/**", configAutenticacao); // Global for all paths

FilterRegistrationBean<CorsFilter> bean = new FilterRegistrationBean<CorsFilter>(new CorsFilter(source));
bean.setOrder(Ordered.HIGHEST_PRECEDENCE);
return bean;
}

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


@Bean
public DaoAuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(customUserDetailsService);
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}


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

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();

}




}

If you run the project now, this is what you will see on the login page.

Great it works, now you can log in using both the username and password and google. We could also use the oauth2 login API (oauth/token) to get an access token to access your APIs. Let’s try it out but before that we need to register a user, I’ll create a user using the command line runner interface for this example, see class below.

...@Component
@RequiredArgsConstructor
public class CommandLineAppStartupRunner implements CommandLineRunner {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;

@Override
public void run(String...args) {
User admin = new User();
admin.setFirstName("John");
admin.setLastName("Doe");
admin.setEmail("okemedjbabs@gmail.com");
admin.setPassword(passwordEncoder.encode("password"));

userRepository.save(admin);
}
}

Now you can proceed to login with username and password, google or API. See the screenshot below for the OAuth API login

You can see that our custom claims/additional info are also returned along with the access token. Great, As always you can get this example and the complete code from Github https://github.com/okemechris/Authorization-server-example

--

--

Christian

Senior Software Engineer | Web3 | Lightning Network | Go, Java, Python