Last updated on September 5th, 2023
In the previous post, I introduced two OAuth2.1 grants: client credentials and authorization code. In this part, I will walk you through the implementation of each grant using the Spring Authorization framework. Starting from Spring Security 5, the Spring team no longer supports building an authorization server from spring-security-starter but has moved to the Spring Authorization Server framework. At the time of developing this application, there were limited references available. That is why I will share my experience through this post. So, let’s delve into the details and explore these implementations.
Getting Started
Let’s start a simple Authorization Server. In this example, I’ll choose the default Spring configuration to set up the Client Credentials Grant type.
Add below dependencies:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-authorization-server</artifactId>
<version>1.1.2</version>
</dependency>
</dependencies>
Our first step is client registration. As usual, we can configure it via YAML/properties fields or bean configuration.
The default one is an in-memory client as below configuration:
spring:
security.oauth2.authorizationserver.client:
client-1:
registration:
client-id: clientA
client-secret: "$2a$10$jdJGhzsiIqYFpjJiYWMl/eKDOd8vdyQis2aynmFN0dgJ53XvpzzwC"
client-authentication-methods: client_secret_basic
authorization-grant-types: client_credentials
scopes: read
Alternatively, you can config a bean as InMemoryRegisteredClientRepository
for clients as below:
package com.example.authserver;
import java.util.UUID;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.oidc.OidcScopes;
import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class AuthServerConfiguration {
@Bean
@Order(1)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http)
throws Exception {
// minimal default configuration for an OAuth2 authorization server
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class).oidc(Customizer.withDefaults());
return http.formLogin(Customizer.withDefaults()).build();
}
@Bean
@Order(2)
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http)
throws Exception {
http.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated())
.formLogin(Customizer.withDefaults());
return http.build();
}
@Bean
public RegisteredClientRepository registeredClientRepository() {
RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("clientA")
.clientSecret("secret")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.scope(OidcScopes.OPENID)
.scope("read")
.build();
return new InMemoryRegisteredClientRepository(registeredClient);
}
}
Please note that we need to config authorizationServerSecurityFilterChain if we want to customize the behavior.
Spring Authorization Server framework essentially provides the necessary features and default set-up for implementing OAuth2. Start the application and call the following command to get tokens:
http -f POST :8080/oauth2/token grant_type=client_credentials scope=read -a clientA:secret
Its result:
{
"access_token": "eyJraWQiOiJkZjExYjcwYy1kYjlkLTRiYWEtYWU2NS0wOWY5OTAzZGYxOTYiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJjbGllbnQiLCJhdWQiOiJjbGllbnQiLCJuYmYiOjE2OTMyMDMyMjEsInNjb3BlIjpbInVzZXIucmVhZCJdLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAiLCJleHAiOjE2OTMyMDM1MjEsImlhdCI6MTY5MzIwMzIyMX0.UuzrF1s6deQCmQjz-FHNUROjm8sFL-86iR4h75VsllBu8wR8A9mxhqavtj9ZjvzIeQq84T0zn8VH5AaY46YhGMixThQ1gsvlt1zswnd3bl2Zls2a-I4mANJBHRg95gcKOX8l9TW7IfwT_cY4doDqHHg7pHGGdi73LKxsGrqpN2Xe5ANRPAqbBFWU4i4t_IrZS07ljULs2ucOHThkoYnVla8t1DZAf09qTUREHG9vndqfN-QX0PGg29BCLcAt0I9eQHWVVZe05BnvGG4EdiAY27Nc0J4mNr2LjcPK0MsgKW2QWSPiVVW-TD_yaWxUckjHKSOWYdZ61hBxoAUexowBDA",
"expires_in": 299,
"scope": "user.read",
"token_type": "Bearer"
}
Everything is now configured for a simple setup. For more detailed settings, you can refer to the Spring documentation.
While the essentials are covered in the basic setup mentioned above, real-world scenarios often require customization to meet specific requirements. In the next part, I will explain some ways to customize the Spring Authorization Server according to the needs of my project.
OAuth Client Registration
In the previous setup, we chose InMemoryRegisteredClientRepository
a simple way to demonstrate. Let’s explore a more common approach which is the JdbcRegiteredClientRepository
implementation.
Then we can find the below docs.
/**
* A JDBC implementation of a {@link RegisteredClientRepository} that uses a
* {@link JdbcOperations} for {@link RegisteredClient} persistence.
*
* <p>
* <b>NOTE:</b> This {@code RegisteredClientRepository} depends on the table definition described in
* "classpath:org/springframework/security/oauth2/server/authorization/client/oauth2-registered-client-schema.sql" and
* therefore MUST be defined in the database schema.
*
* @author Rafal Lewczuk
* @author Joe Grandja
* @author Ovidiu Popa
* @since 0.1.2
* @see RegisteredClientRepository
* @see RegisteredClient
* @see JdbcOperations
* @see RowMapper
*/
public class JdbcRegisteredClientRepository implements RegisteredClientRepository {
// the implmentation
}
This implies that when implementing a Client Repository using JDBC, it’s necessary to define tables with schemas that mimic the pre-defined schema of the framework, as below:
-- refer to oauth2-registered-client-schema.sql
CREATE TABLE oauth2_registered_client (
id varchar(100) NOT NULL,
client_id varchar(100) NOT NULL,
client_id_issued_at timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
client_secret varchar(200) DEFAULT NULL,
client_secret_expires_at timestamp DEFAULT NULL,
client_name varchar(200) NOT NULL,
client_authentication_methods varchar(1000) NOT NULL,
authorization_grant_types varchar(1000) NOT NULL,
redirect_uris varchar(1000) DEFAULT NULL,
post_logout_redirect_uris varchar(1000) DEFAULT NULL,
scopes varchar(1000) NOT NULL,
client_settings varchar(2000) NOT NULL,
token_settings varchar(2000) NOT NULL,
PRIMARY KEY (id)
);
-- refer to oauth2-authorization-schema.sql
CREATE TABLE oauth2_authorization (
id varchar(100) NOT NULL,
registered_client_id varchar(100) NOT NULL,
principal_name varchar(200) NOT NULL,
authorization_grant_type varchar(100) NOT NULL,
authorized_scopes varchar(1000) DEFAULT NULL,
attributes blob DEFAULT NULL,
state varchar(500) DEFAULT NULL,
authorization_code_value blob DEFAULT NULL,
authorization_code_issued_at timestamp DEFAULT NULL,
authorization_code_expires_at timestamp DEFAULT NULL,
authorization_code_metadata blob DEFAULT NULL,
access_token_value blob DEFAULT NULL,
access_token_issued_at timestamp DEFAULT NULL,
access_token_expires_at timestamp DEFAULT NULL,
access_token_metadata blob DEFAULT NULL,
access_token_type varchar(100) DEFAULT NULL,
access_token_scopes varchar(1000) DEFAULT NULL,
oidc_id_token_value blob DEFAULT NULL,
oidc_id_token_issued_at timestamp DEFAULT NULL,
oidc_id_token_expires_at timestamp DEFAULT NULL,
oidc_id_token_metadata blob DEFAULT NULL,
refresh_token_value blob DEFAULT NULL,
refresh_token_issued_at timestamp DEFAULT NULL,
refresh_token_expires_at timestamp DEFAULT NULL,
refresh_token_metadata blob DEFAULT NULL,
user_code_value blob DEFAULT NULL,
user_code_issued_at timestamp DEFAULT NULL,
user_code_expires_at timestamp DEFAULT NULL,
user_code_metadata blob DEFAULT NULL,
device_code_value blob DEFAULT NULL,
device_code_issued_at timestamp DEFAULT NULL,
device_code_expires_at timestamp DEFAULT NULL,
device_code_metadata blob DEFAULT NULL,
PRIMARY KEY (id)
);
-- refer to oauth2-authorization-consent-schema.sql
oauth2-authorization-consent-schema.sql
CREATE TABLE oauth2_authorization_consent (
registered_client_id varchar(100) NOT NULL,
principal_name varchar(200) NOT NULL,
authorities varchar(1000) NOT NULL,
PRIMARY KEY (registered_client_id, principal_name)
);-- refer to oauth2-registered-client-schema.sql
CREATE TABLE oauth2_registered_client (
id varchar(100) NOT NULL,
client_id varchar(100) NOT NULL,
client_id_issued_at timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
client_secret varchar(200) DEFAULT NULL,
client_secret_expires_at timestamp DEFAULT NULL,
client_name varchar(200) NOT NULL,
client_authentication_methods varchar(1000) NOT NULL,
authorization_grant_types varchar(1000) NOT NULL,
redirect_uris varchar(1000) DEFAULT NULL,
post_logout_redirect_uris varchar(1000) DEFAULT NULL,
scopes varchar(1000) NOT NULL,
client_settings varchar(2000) NOT NULL,
token_settings varchar(2000) NOT NULL,
PRIMARY KEY (id)
);
-- refer to oauth2-authorization-schema.sql
CREATE TABLE oauth2_authorization (
id varchar(100) NOT NULL,
registered_client_id varchar(100) NOT NULL,
principal_name varchar(200) NOT NULL,
authorization_grant_type varchar(100) NOT NULL,
authorized_scopes varchar(1000) DEFAULT NULL,
attributes blob DEFAULT NULL,
state varchar(500) DEFAULT NULL,
authorization_code_value blob DEFAULT NULL,
authorization_code_issued_at timestamp DEFAULT NULL,
authorization_code_expires_at timestamp DEFAULT NULL,
authorization_code_metadata blob DEFAULT NULL,
access_token_value blob DEFAULT NULL,
access_token_issued_at timestamp DEFAULT NULL,
access_token_expires_at timestamp DEFAULT NULL,
access_token_metadata blob DEFAULT NULL,
access_token_type varchar(100) DEFAULT NULL,
access_token_scopes varchar(1000) DEFAULT NULL,
oidc_id_token_value blob DEFAULT NULL,
oidc_id_token_issued_at timestamp DEFAULT NULL,
oidc_id_token_expires_at timestamp DEFAULT NULL,
oidc_id_token_metadata blob DEFAULT NULL,
refresh_token_value blob DEFAULT NULL,
refresh_token_issued_at timestamp DEFAULT NULL,
refresh_token_expires_at timestamp DEFAULT NULL,
refresh_token_metadata blob DEFAULT NULL,
user_code_value blob DEFAULT NULL,
user_code_issued_at timestamp DEFAULT NULL,
user_code_expires_at timestamp DEFAULT NULL,
user_code_metadata blob DEFAULT NULL,
device_code_value blob DEFAULT NULL,
device_code_issued_at timestamp DEFAULT NULL,
device_code_expires_at timestamp DEFAULT NULL,
device_code_metadata blob DEFAULT NULL,
PRIMARY KEY (id)
);
-- refer to oauth2-authorization-consent-schema.sql
oauth2-authorization-consent-schema.sql
CREATE TABLE oauth2_authorization_consent (
registered_client_id varchar(100) NOT NULL,
principal_name varchar(200) NOT NULL,
authorities varchar(1000) NOT NULL,
PRIMARY KEY (registered_client_id, principal_name)
);
Let’s add clients’ data to our DB.
INSERT INTO public.oauth2_registered_client (id,client_id,client_id_issued_at,client_secret,client_secret_expires_at,client_name,client_authentication_methods,authorization_grant_types,redirect_uris,scopes,client_settings,token_settings) VALUES
('add17e76-548f-4b59-a575-93875eeba478','clientA','2023-02-07 21:04:31.106429','$2a$12$lfzKdGF/vCM8Fyin6J/6nu1AiHBwINQ8fIbm6n5yZ61mngEquOwwG',NULL,'add17e76-548f-4b59-a575-93875eeba478','client_secret_basic,client_secret_post','refresh_token,client_credentials,authorization_code','http://127.0.0.1:9000/authorized','read,write','{"@class":"java.util.Collections$UnmodifiableMap","settings.client.require-proof-key":false,"settings.client.require-authorization-consent":true}','{"@class":"java.util.Collections$UnmodifiableMap","settings.token.reuse-refresh-tokens":true,"settings.token.id-token-signature-algorithm":["org.springframework.security.oauth2.jose.jws.SignatureAlgorithm","RS256"],"settings.token.authorization-code-time-to-live":["java.time.Duration",300.000000000],"settings.token.access-token-time-to-live":["java.time.Duration",300.000000000],"settings.token.access-token-format":{"@class":"org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat","value":"self-contained"},"settings.token.refresh-token-time-to-live":["java.time.Duration",3600.000000000]}');
Then, let’s add the following dependencies and add the above schema to a liquibase changelog.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.6.0</version>
</dependency>
<dependency>
<dependency>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-core</artifactId>
</dependency>
Register JDBC Client:
@Bean
public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {
return new JdbcRegisteredClientRepository(jdbcTemplate);
}
@Bean
public OAuth2AuthorizationService authorizationService(
JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {
return new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository);
}
Oauth User Registration
The authorization_code grant requires the user’s roles. Therefore, a user authentication mechanism must be configured in addition to the default OAuth2 security configuration.
Add the in-memory user manager or DB repository as following setup:
For InMemoryUserDetailsManager
@Bean
UserDetailsManager inMemoryUserDetailsManager() {
UserDetails one = User.withDefaultPasswordEncoder().roles("admin").username("hoai").password("pw").build();
return new InMemoryUserDetailsManager(one);
}
For user DB:
- Add the user and role schema in the liquibase changelog file
- Define a
CustomUserDetailsService
that implements your custom user retrieving logic. Below is one example:
package com.example.demo.authservice.service;
import com.example.demo.authservice.entity.Role;
import com.example.demo.authservice.entity.UserEntity;
import com.example.demo.authservice.model.JwtUser;
import com.example.demo.authservice.repository.UserRepository;
import java.util.Optional;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
private static final Logger LOGGER = LoggerFactory.getLogger(CustomUserDetailsService.class);
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<UserEntity> optional = userRepository.findByUsername(username);
if (optional.isEmpty()) {
throw new UsernameNotFoundException("No user found for " + username);
}
LOGGER.info("Found user for {}", username);
JwtUser jwtUser = new JwtUser();
jwtUser.setUsername(username);
jwtUser.setRoles(
optional.get().getAuthorities().stream().map(Role::getName).collect(Collectors.toSet()));
jwtUser.setAuthorities(optional.get().getAuthorities());
jwtUser.setEmail(optional.get().getEmail());
jwtUser.setFirstName(optional.get().getFirstName());
jwtUser.setLastName(optional.get().getLastName());
jwtUser.setPassword(optional.get().getPassword());
return jwtUser;
}
}
Getting and customizing JWT
Get the JWT
We’ve prepared to initiate the process of Authorization Code grant type. The first step involves acquiring an authorization code, which will subsequently be exchanged for an access token. Let’s begin by obtaining the authorization code.
http://127.0.0.1:9000/oauth2/authorize?response_type=code&client_id=clientA&redirect_uri=http://127.0.0.1:9000/authorized&scope=openid
The framework behind the scene would retrieve to check the client_id
and redirectUri
from our database. Then it would redirect you to a user login form:
After a successful authentication, the system will redirect you to the predefined redirectUri
, and within the link, the authorization code will be injected:
http://127.0.0.1:9000/authorized?code=9nqRokwOw2YjE_Y5k2X0fpUhWdzFEZsqoK6U1ZCUgGLLorHddEmDUpmv72CPfu6ntmkqEz4NZPQYuA4W1yd1mjE0CgfEFweaOfPGEMyHAjJD-zeZ1L_l4AzttFirFNrI
Everything is now in place for obtaining the access token. Please follow the following steps:
Its result:
{
"access_token": "eyJraWQiOiJlMDRkOWJhOS1jNzc1LTRkYmItYWMxOC0zMzBlNmNiNGViMTciLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJob2FpIiwiYXVkIjoiY2xpZW50QSIsIm5iZiI6MTY5MzIxMTE3OCwic2NvcGUiOlsib3BlbmlkIl0sImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6OTAwMCIsImV4cCI6MTY5MzIxMTQ3OCwiaWF0IjoxNjkzMjExMTc4fQ.Pjf_q-A_dhL35ZO3_okuqJm86fi3nRezTOvXuEmPdFibdWLOOpO6rnfJRTr1jC75IXeVBmQg1sHW0ZROmXe-GXXEto9yYFJ3wnUiqQSlo1mC12kTtF6KeKGsEC5joFIwG4TepEoB8T61mi4349hWDvUZDQnzPZZ875BXNppDlbtOtz7VkOQR7NBrOEsajpL028lbPsbFPVMSNTDN8bsZJ2i-6Y0RkDTNW6hSrg1nSPcijD-UKYzKTxqygrlqFtxfIDDPV3OripuVR22C9IHgyf6UozWeHKaHqazR5heAPrsprudeF0pMOxI9ER6fb2EMY-A9i2Su70hC7kOdDZGsVw",
"refresh_token": "YxmnvmFB8i5NdF4E-jHXOFgkK4ZBwrCvPsfYCgJhGNBF25v0Pzjs1fRJVuwxZgrvu_4_XnrbznOxT7JaP5qu8EVIOh_ovw9i72-_7_GuqTOYvBwV5vRsa2NBQewUJ8R6",
"scope": "openid",
"id_token": "eyJraWQiOiJlMDRkOWJhOS1jNzc1LTRkYmItYWMxOC0zMzBlNmNiNGViMTciLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjkwMDAiLCJzdWIiOiJob2FpIiwiYXVkIjoiY2xpZW50QSIsImV4cCI6MTY5MzIxMjk3OCwiaWF0IjoxNjkzMjExMTc4LCJhenAiOiJjbGllbnRBIn0.redCL7G2g5GTF3NqpoUw6z731X4W1ysZHAfWeYEMyx-VOerFftJFQ0BYCauaZ4-XwKa21KKHqXYHjkvUccgzysBhI4mzgTxCjWlOfFDUbTUEiU7PBztZxKEHjm7fcdRlC1mfjn2_3yosh_-Z_0d7bHqDVNyLYAohUk_awDtzzzxBTnEQI0mDTzwtEWdVwGbMTlAwUlV2aCSWV1zqqrrj_pRkoiabh8LF730F8FTVsli3sc54-Yyyp004-Fb9KOywQOt3Mh2JMaPLKZcpJXlM8z_aFTbJ8zK5HhWnymUU8Fe18JldB37ewnJYUH7Y-3k_WR47YGyr666xDweX7QHMgw",
"token_type": "Bearer",
"expires_in": 299
}
Customize the JWT payload
Suppose you’re interested in customizing the JWT payload. In that case, you can configure it as demonstrated in the following example:
@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> tokenCustomizer(
CustomUserDetailsService userInfoService) {
return context -> {
if (ID_TOKEN.equals(context.getTokenType().getValue())
|| ACCESS_TOKEN.equals(context.getTokenType())) {
JwtUser userInfo = (JwtUser) userInfoService.loadUserByUsername(context.getPrincipal().getName());
Map<String, Object> info = new HashMap<>();
info.put("roles", userInfo.getRoles());
context.getClaims().claims(claims -> claims.putAll(info));
context.getJwsHeader().type("jwt");
}
};
Introspective token and you can see the payload data:
{
"sub": "hoai",
"aud": "clientA",
"nbf": 1693212305,
"scope": [
"openid"
],
"roles": [
"Administrator"
],
"iss": "http://127.0.0.1:9000",
"exp": 1693212605,
"iat": 1693212305
}
Looking good! However, we need to verify whether we can use this token. Let’s build a simple resource server and try it out.
This time I’ve relied on the jwt set endpoint for asymmetric keys implementation.
- On the Auth server side:
@Bean
public AuthorizationServerSettings authorizationServerSettings() {
return AuthorizationServerSettings.builder()
.jwkSetEndpoint("/oauth2/jwks")
.build();
}
- On the resource server side:
spring:
security:
oauth2:
resourceserver.jwt.jwk-set-uri: http://localhost:9000/oauth2/jwks
Now, it’s time to run the resource server. The process should be good now ^^
The source code can be found on GitHub repo.
Reference
- Book: Spring Security in Action.
- Getting started for the Spring authorization server.
- Spring authorization server samples.