1. Why Persistent Client Credentials?
2. Database Design
3. Create New Spring Starter Project
4. Enable Spring Authorization Server
5. Code Data Access Layer
6. Implement Registered Client Repository
7. Test Get Access Token Endpoint
8. Customize Access Token Generation
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</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-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-devtools</artifactId> <scope>runtime</scope> <optional>true</optional> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-test</artifactId> <scope>test</scope> </dependency> </dependencies>Note that the Spring Test and Spring Security Test dependencies are added automatically by Spring Initializr.
package net.codejava.oauth2; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer; import org.springframework.security.web.SecurityFilterChain; @Configuration public class AuthorizationServerConfig { @Bean SecurityFilterChain authorizationServerFilterChain(HttpSecurity http) throws Exception { http.with(OAuth2AuthorizationServerConfigurer.authorizationServer(), Customizer.withDefaults()); return http.build(); } }This code snippet activates the authorization server with default security settings for the application: all requests must be authenticated, except for the /oauth2/token endpoint, which handles client requests for obtaining new access tokens.The server will not start until a RegisteredClientRepository is configured, which we’ll set up after implementing the data access layer.
spring.datasource.url=jdbc:mysql://localhost:3306/rest_api_tests spring.datasource.username=root spring.datasource.password=passwordRemember to update the database URL, username and password to match your MySQL server configuration. Also, add the following properties to enable Hibernate to generate database table from Java entity class (forward engineering) and to print SQL statements in the console for easier debugging and testing:
spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=trueNext, we need to code a Java entity class that maps to the clients table using JPA annotations. The code is as follows:
package net.codejava.oauth2; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; @Entity @Table(name = "clients") public class Client { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String clientName; private String clientId; private String clientSecret; private String scope; public static Client clientId(String cid) { Client newClient = new Client(); newClient.setClientId(cid); return newClient; } public Client name(String name) { this.clientName = name; return this; } public Client scope(String scope) { this.scope = scope; return this; } // getters and setters are not shown for brevity }And define the corresponding JPA repository as follows:
package net.codejava.oauth2; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface ClientRepository extends JpaRepository<Client, Long> { Optional<Client> findByClientId(String clientId); }Spring Data JPA will generate a proxy object that implements the findByClientId() method, which will be used by the server to authenticate clients.Use the following test class to perform unit tests:
package net.codejava.oauth2; import static org.assertj.core.api.Assertions.assertThat; import java.util.List; import java.util.Optional; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.annotation.Rollback; @DataJpaTest @AutoConfigureTestDatabase(replace = Replace.NONE) @Rollback(false) public class ClientRepositoryTests { @Autowired private ClientRepository repo; @Test public void testAddClients() { Client client1 = Client.clientId("client-1").name("John Doe").scope("read"); Client client2 = Client.clientId("client-2").name("Max One").scope("read"); Client client3 = Client.clientId("client-3").name("Devi Kumar").scope("write"); Client client4 = Client.clientId("client-4").name("Bob Kai").scope("write"); PasswordEncoder encoder = new BCryptPasswordEncoder(); client1.setClientSecret(encoder.encode("pass1")); client2.setClientSecret(encoder.encode("pass2")); client3.setClientSecret(encoder.encode("pass3")); client4.setClientSecret(encoder.encode("pass4")); repo.saveAll(List.of(client1, client2, client3, client4)); } @Test public void testFindByClientId() { String clientId = "client-3"; Optional<Client> result = repo.findByClientId(clientId); assertThat(result).isPresent(); } }Run the testAddClients() method to persist four client credentials into the database. We’ll use this dummy data later to test the Get Access Token API. As you can see, we use BCryptPasswordEncoder to encode the client secrets.Also, run the testFindByClientId() method to verify that a client can be retrieved by a given client ID.
Spring Boot REST APIs Ultimate Course
Hands-on REST API Development with Spring Boot: Design, Implement, Document, Secure, Test, Consume RESTful APIs
@Bean public RegisteredClientRepository registeredClientRepository(ClientRepository clientRepo) { return new RegisteredClientRepository() { @Override public void save(RegisteredClient registeredClient) { } @Override public RegisteredClient findById(String id) { return null; } @Override public RegisteredClient findByClientId(String clientId) { Optional<Client> findResult = clientRepo.findByClientId(clientId); if (findResult.isEmpty()) return null; Client client = findResult.get(); return RegisteredClient.withId(client.getId().toString()) .clientId(client.getClientId()) .clientSecret(client.getClientSecret()) .scope(client.getScope()) .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) .build(); } }; }This code requires some explanation:
@Bean PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }Spring Security will use this password encoder to verify the client secrets stored in the database, which are encoded using the BCrypt password encoder.With that, the setup for the Spring authorization server is complete. Now we can start the application to test it out.
curl -d "grant_type=client_credentials&client-id=…&client-secret=…" localhost:8080/oauth2/tokenFor example, try the following command:
curl -d "grant_type=client_credentials&client_id=client-1&client_secret=abc" localhost:8080/oauth2/token -vThis command sends client_id as client-1 and client_secret as abc. The server will return an HTTP 401 Unauthorized status because the client secret is invalid, along with the following JSON in the response body:
{"error":"invalid_client"}Now, let’s try making another request with valid a client id and secret, using a command like this:
curl -d "grant_type=client_credentials&client_id=client-1&client_secret=pass1" localhost:8080/oauth2/token -v | jqThe server will return an HTTP 200 OK status, along with a newly issued access token included in a JSON object in the response body, as shown below:
{ "kid": "77c7c6e3-ba27-4aa3-8389-d084cb9f4eeb", "alg": "RS256" }And the decoded payload:
{ "sub": "client-1", "aud": "client-1", "nbf": 1743742740, "iss": "http://localhost:8080", "exp": 1743743040, "iat": 1743742740, "jti": "e33c35f5-7624-4c0f-972a-ef68856e1d0e" }Read this article to learn more about the structure and meaning of the information contained in a JWT.To test the Get Access Token API using Postman, create a new request with the following details:
@Bean public OAuth2TokenCustomizer<JwtEncodingContext> tokenCustomizer() { return (context) -> { if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) { RegisteredClient client = context.getRegisteredClient(); Builder builder = context.getClaims(); builder.issuer("CodeJava"); builder.expiresAt(Instant.now().plus(10, ChronoUnit.MINUTES)); builder.claim("scope", client.getScopes()); } }; }This code modifies the issued access token by changing the issuer to “CodeJava” (default is a URL), increasing the expiration time to 10 minutes, and adding the “scope” claim.Test the Get Access Token API again and decode the new access token, you’ll see that the changes have been successfully applied:
{ "sub": "client-2", "aud": "client-2", "nbf": 1743746260, "scope": [ "read" ], "iss": "CodeJava", "exp": 1743746860, "iat": 1743746260, "jti": "54555db1-72a7-4eb0-a907-3b40502e1109" }Read this article to learn more about access token customization with Spring authorization server.