Skip to main content

OAuth 2 Spring Authorization Server

We're going to start with the first component of our OAuth 2 system: the Authorization Server.

This backend is responsible for authenticating users. It provides the user with a login form. The user enters his email and password. If authentication is successful, a JWT Token is generated and returned to the Client (we'll get to what client later). If not, an error message is displayed.

Repository

https://github.com/Mehdi-HAFID/token-generator

Requirements

Nidam Spring Authorization Sever uses JDK 17, Spring Boot 3.2.0. It is a Spring Authorization Server.

As stated in the intro, Nidam will not teach you Spring Authorization Server, you're supposed to know how to work with it beforehand.

pom.xml

Here are the dependencies of this authorization server

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>3.2.0</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>nidam</groupId>
<artifactId>token-generator</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>token-generator</name>
<description>token-generator</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
<version>3.0.2</version>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk15on</artifactId>
<version>1.70</version>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

</project>

Configuration

This is the most crucial part of this auth server. We will explain only the changes specific to Nidam;

No changes are made to SecurityFilterChain Bean.

src/main/java/nidam/tokengenerator/config/SecurityConfigStaticKey.java
@Configuration
public class SecurityConfigStaticKey {

private Logger log = Logger.getLogger(SecurityConfigStaticKey.class.getName());

@Bean
@Order(1)
public SecurityFilterChain asFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);

http.getConfigurer(OAuth2AuthorizationServerConfigurer.class).oidc(Customizer.withDefaults());
http.exceptionHandling((e) ->
e.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login"))
);
return http.build();
}

@Bean
@Order(2)
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http.formLogin(Customizer.withDefaults());
http.authorizeHttpRequests(c -> c.anyRequest().authenticated());
return http.build();
}
}

It is important to note that we're using version 3.2.0. And I have encountered bugs in Spring. The following is a fix for a bug.

src/main/java/nidam/tokengenerator/config/SecurityConfigStaticKey.java
	@Bean
public DaoAuthenticationProvider authenticationProvider(UserDetailsService userDetailsService) {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService);
return authProvider;
}

For some reason Spring does not pick up our custom UserDetailsService -discussed later-. And it does print No AuthenticationProvider found. Check this StackOverflow question and this GitHub issue

success

Version 3.2.2 has fixed this bug, but as I'm writing the documentation I have not upgraded to it. Nidam v 1.0 is shipping with 3.2.0 so this fix will be included in the code. Future versions will try to upgrade to the latest versions of the dependencies.

OAuth 2 Client

We declare the credentials for our secret client: OAuth 2 Client ID with a secret key. The client ID is client, and the secret is secret but because we use password encoders, the same ones that we defined in the registration backend we must provide the hash. You achieve this using Spring CLI

PS C:\**\spring-3.2.1\bin> .\spring encodepassword secret
{bcrypt}$2a$10$.ld6BfZescPDfVVduvu.6O9.7FLMI64l4PfvnBZJQEBhTLFFbeKei
src/main/java/nidam/tokengenerator/config/SecurityConfigStaticKey.java
public RegisteredClientRepository registeredClientRepository() {
RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("client")
.clientSecret("{bcrypt}$2a$10$.ld6BfZescPDfVVduvu.6O9.7FLMI64l4PfvnBZJQEBhTLFFbeKei") //secret
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("http://localhost:7080/bff/login/oauth2/code/token-generator")
.scope(OidcScopes.OPENID)
.postLogoutRedirectUri("http://localhost:7080/react-ui")
.tokenSettings(TokenSettings.builder().accessTokenTimeToLive(Duration.ofHours(12)).build())
.build();
return new InMemoryRegisteredClientRepository(registeredClient);
}

The redirectUri and postLogoutRedirectUri values will be explained in later pages. We set the token to expire after 12 hours, default value is 5 minutes.

Token Signing

For the authorization server to sign the tokens and the resource server to validate them, we're going to need a pair of private and public keys. First, let's create them.

Generation of Private and Public Keys

This code repo already contains a keystore.jks, you can use that for development, but remember to generate yours before going to production. Open a terminal and execute this command line:

keytool -genkeypair -alias alias -keyalg RSA -keypass keypass -keystore keystore.jks -storepass storepass

This will produce keystore.jks file.

On Windows, open git bash and run:

keytool -list -rfc --keystore keystore.jks | openssl x509 -inform pem -pubkey

You will be asked fot the store pass, enter: storepass

info

It goes without saying that you must use your own passwords

The public key will be printed, save it somewhere:

-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlR7vCKfixqO524nx2qgj
Yi1bgtdRK2eDhVvthVEj2FxTP2thtRrQx+36KMq8RT+8xl1iOYg4uRtcRO2aqgfu
VhM/uWv1aTYFoxZ6HNnsVIB/Po4HRJTaq+SpIrQ9OirraFHNNU9Gh4qDlIduHRpW
/cROw1lg/hCAPfI6Cd7Al4TSCJyzmxjZ1tRJlihcJuJ9QvYOGuxOjpkQQPjZQWNV
5cBkTO0QhU7O71OTvJuVwDs/FgI1uOzmR/5cO0hI5IULQxXjC5Utcp/2wt8Vtqe/
BSzPMDh26+nVcsUyMzozu+gEiwatbIwXblm4BjOEAV5jRnpCSap4jYle+9xULxdE
YQIDAQAB
-----END PUBLIC KEY-----

The code expects the keystore.jks to be located in the src/main/resources/ folder. It, also expects these 3 values in application.properties:

src/main/resources/application.properties
password=keypass
privateKey=keystore.jks
alias=alias

JWKSource Declaration

The Keypair is loaded from the keystore.jks. The keypair is used by the authorization server to sign tokens.

src/main/java/nidam/tokengenerator/config/SecurityConfigStaticKey.java
@Value("${password}")
private String password;

@Value("${privateKey}")
private String privateKey;

@Value("${alias}")
private String alias;

@Bean
public JWKSource<SecurityContext> jwkSource() throws Exception {

KeyPair keyPair = JKSFileKeyPairLoader.loadKeyStore(privateKey, password, alias);
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();

RSAKey rsaKey = new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString())
.build();
JWKSet jwkSet = new JWKSet(rsaKey);
return new ImmutableJWKSet<>(jwkSet);
}

Loading the KeyPair from keystore.jks file

src/main/java/nidam/tokengenerator/config/JKSFileKeyPairLoader.java
public class JKSFileKeyPairLoader {

private static Logger log = Logger.getLogger(JKSFileKeyPairLoader.class.getName());

public static KeyPair loadKeyStore(String privateKey, String password, String alias) throws Exception {
final KeyStore keystore = KeyStore.getInstance("JKS");

keystore.load(new ClassPathResource(privateKey).getInputStream(), password.toCharArray());

final PrivateKey key = (PrivateKey) keystore.getKey(alias, password.toCharArray());

final Certificate cert = keystore.getCertificate(alias);
final PublicKey publicKey = cert.getPublicKey();
return new KeyPair(publicKey, key);

}
}

UserDetailsService Declaration

We provide our own UserDetailsService implementation, which checks if the email and password match a user in the database. Discussed later.

src/main/java/nidam/tokengenerator/config/SecurityConfigStaticKey.java
@Bean
public UserDetailsService userDetailsService(UserRepository userRepository) {
UserDetailsService userDetailsService = new JpaUserDetailsService(userRepository);
return userDetailsService;
}

AuthorizationServerSettings Declaration

src/main/java/nidam/tokengenerator/config/SecurityConfigStaticKey.java
@Bean
public AuthorizationServerSettings authorizationServerSettings() {
return AuthorizationServerSettings.builder()
.build();
}

Password Encoders

Password encoders are a copy paste from the registration backend. So we will not explain them again. Declared at src/main/java/nidam/tokengenerator/config/PasswordEncoderConfig.java. Check the registration explanation

Customizing the token

To add custom attributes to the token payload we use OAuth2TokenCustomizer. In this code, we add the authorities to the payload in an attribute called authorities.

src/main/java/nidam/tokengenerator/config/SecurityConfigStaticKey.java
@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer() {
return context -> {
List<String> auths = new ArrayList<>();
for (GrantedAuthority auth : context.getPrincipal().getAuthorities()){
auths.add(auth.getAuthority());
}
JwtClaimsSet.Builder claims = context.getClaims();
claims.claim("authorities", auths);
};
}

This is what the payload of a token generated by Nidam looks like:

decoded using jwt.io
{
"sub": "mehdi@nidam.com",
"aud": "client",
"nbf": 1713792863,
"scope": [
"openid"
],
"iss": "http://localhost:4002/auth",
"exp": 1713836063,
"iat": 1713792863,
"jti": "eeff561a-db2a-42d5-bd79-2afabbb5f8c3",
"authorities": [
"manage-users",
"manage-projects"
]
}

Architecture

Entities

We carry the User and Authority entities from the registration backend with no change.

Repository

We carry the UserRepository from the registration backend with no change.

Loading Users for Authentication

Here we will present the implementation for UserDetails and UserDetailsService.

UserDetails

UserDetails as you know represents the user as understood by Spring Security. We create a class that implements UserDetails and takes a User entity as a constructor parameter. So the two responsibilities are separated.

src/main/java/nidam/tokengenerator/model/EntityUserDetails.java
public class EntityUserDetails implements UserDetails {

private final User user;

public EntityUserDetails(User user) {
this.user = user;
}

public User getUser() {
return user;
}

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return user.getAuthorities().stream()
.map(authority -> new SimpleGrantedAuthority(authority.getName()))
.collect(Collectors.toList());
}

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

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

@Override
public boolean isAccountNonExpired() {
return true;
}

@Override
public boolean isAccountNonLocked() {
return true;
}

@Override
public boolean isCredentialsNonExpired() {
return true;
}

@Override
public boolean isEnabled() {
return user.isEnabled();
}
}

UserDetailsService

Next is the UserDetailsService implementation. It loads the user entity from the database by email. If it exists, it returns our UserDetails implementation: EntityUserDetails of the retrieved entity. If not, a Spring UsernameNotFoundException is thrown.

src/main/java/nidam/tokengenerator/service/JpaUserDetailsService.java
@Service
public class JpaUserDetailsService implements UserDetailsService {

private Logger log = Logger.getLogger(JpaUserDetailsService.class.getName());

private final UserRepository userRepository;

public JpaUserDetailsService(UserRepository userRepository){
this.userRepository = userRepository;
}

@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
Optional<User> userOptional = userRepository.findUserByEmail(email);
if(userOptional.isPresent()){
EntityUserDetails entityUserDetails = new EntityUserDetails(userOptional.get());
return entityUserDetails;
}
throw new UsernameNotFoundException("User not found");
}
}

These two properties declared in the application.properties file will be explained later:

src/main/resources/application.properties
server.servlet.contextPath=/auth
server.forward-headers-strategy=framework

Auth Server

Now, we have a fully working Authorization Server that will authenticate users created using the registration frontend and backend.

Manually Getting The Token

To check that the authorization server is working as expected. We can follow these steps to get a token:

  1. Have the database running. And start the authorization server with mvn spring-boot:run.
  2. In your browser go to http://localhost:4002/auth/oauth2/authorize?response_type=code&client_id=client&scope=openid&redirect_uri=http://localhost:7080/bff/login/oauth2/code/token-generator
  3. Enter an email and a password combo you registered before.
  4. You will be redirected to http://localhost:7080/bff/login/oauth2/code/token-generator?code=Cwv0B-UKPykTJA_9QohDQFVYTXDNey8_FwZ9sSLgHV12ZeuWw6Ex7Ng4ahzvIUcLu-aKfm7-vxL5XYtz3htGL0kN8tkEbxsiQYIPlwEr_bsmhqnBKh2UdZo-m68Yvudq Notice the code parameter. that is the authorization code (this is where the name authorization code grant came from).
  5. Go to Postman and make a POST request to this URL. Remember to change the code with the one you've got in the previous step. http://localhost:4002/auth/oauth2/token?client_id=client&redirect_uri=http://localhost:7080/bff/login/oauth2/code/token-generator&grant_type=authorization_code&code=Cwv0B-UKPykTJA_9QohDQFVYTXDNey8_FwZ9sSLgHV12ZeuWw6Ex7Ng4ahzvIUcLu-aKfm7-vxL5XYtz3htGL0kN8tkEbxsiQYIPlwEr_bsmhqnBKh2UdZo-m68Yvudq And in the authorization tab of Postman, enter your client id and secret. In our case: client and secret.

The result is:

{
"access_token": "eyJraWQiOiJjYWI1ZTU2My0zM2RiLTQ1YTUtOWZkMy05Yzc2MmU2OTI5NWEiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJtZWhkaUBuaWRhbS5jb20iLCJhdWQiOiJjbGllbnQiLCJuYmYiOjE3MTM3OTI4NjMsInNjb3BlIjpbIm9wZW5pZCJdLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjQwMDIvYXV0aCIsImV4cCI6MTcxMzgzNjA2MywiaWF0IjoxNzEzNzkyODYzLCJqdGkiOiJlZWZmNTYxYS1kYjJhLTQyZDUtYmQ3OS0yYWZhYmJiNWY4YzMiLCJhdXRob3JpdGllcyI6WyJtYW5hZ2UtdXNlcnMiLCJtYW5hZ2UtcHJvamVjdHMiXX0.GjWcxHvA2fVBCruE0HmzIyEswCkv9OQtMPQJUE7iYFP5rABSj6spM8uXqe8nRgy7SghlH3TBrAPziTa1rYkyKhLazWoYXVR3klwfnwvT9rk9Bdnjk3baJmwb7sqCRHRVpOIyaF3aFo0Fj4N3Y-TTaVjl9A_Xrm75BPoAYrgRxLXNa_ngp839x1nprwLMfTTyib7AWW1QqVa5k1856mOLS0GOa3st0QaEgVcQqQWCXPnDiW_iZeg2BAav0SI67oj7672bd4quOmR8f7ztsFvJ7Kxotr7uKZ5_1nVdlJ-Ee6qGFjPb_aNYHz3v-Mj5Yo5zZEXy3SNKmamsnzWki3bo7Q",
"scope": "openid",
"id_token": "eyJraWQiOiJjYWI1ZTU2My0zM2RiLTQ1YTUtOWZkMy05Yzc2MmU2OTI5NWEiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJtZWhkaUBuaWRhbS5jb20iLCJhdWQiOiJjbGllbnQiLCJhenAiOiJjbGllbnQiLCJhdXRoX3RpbWUiOjE3MTM3OTI1NzAsImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6NDAwMi9hdXRoIiwiZXhwIjoxNzEzNzk0NjYzLCJpYXQiOjE3MTM3OTI4NjMsImp0aSI6ImY2YjIyZWViLTNhMWYtNGUwMC05MjMyLTdhOTU2ZDdiMGM4MyIsImF1dGhvcml0aWVzIjpbIm1hbmFnZS11c2VycyIsIm1hbmFnZS1wcm9qZWN0cyJdLCJzaWQiOiJfSjM5MWdpbjRRbkNMaEg5STFha0k4TTJtaWhYV3VwbDlncjc5ZFJ0YXU4In0.onadAGa0R62JmZzGt7V_rhSLQC_R8MLM5i8teeQMAwIy6iBgNjvhaxerhv_-l9MHTlTDpubFagqzRfuGjHZ88Mlm05kYR7fvmhZuuD0PtSyMj5nGl2poXBk8lBbV9kvGy2-zVqkFCw8U1-94zRFz7CogTbDxZXfyG10lt5iBwr3kioTagKPaWZ7I4o0OEQO5XOzVkmp_qaJV47qx7d8YleefW5O9KhsSz8BmH4xzzDKoXfgWfkby4iTH4_JXEB8spsMqpSri66MAXp-LHgKTN0EoePJmgdKFDWGQNb3VH3LQyRrJXvMYZ-Blr_atg7bWHtfet_V1SKT_KJ4sJ9LSYA",
"token_type": "Bearer",
"expires_in": 43200
}
info

This is not needed for Nidam. It is simply to check that your authorization server is working as expected.