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
<?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.
@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.
@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
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
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
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
:
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.
@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
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.
@Bean
public UserDetailsService userDetailsService(UserRepository userRepository) {
UserDetailsService userDetailsService = new JpaUserDetailsService(userRepository);
return userDetailsService;
}
AuthorizationServerSettings Declaration
@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
.
@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:
{
"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.
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.
@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");
}
}
BFF related properties
These two properties declared in the application.properties file will be explained later:
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:
- Have the database running. And start the authorization server with
mvn spring-boot:run
. - 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
- Enter an email and a password combo you registered before.
- 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). - 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
}
This is not needed for Nidam. It is simply to check that your authorization server is working as expected.