[Learn Microservices With Me] Securing Microservice Communication Using An Auth Server

John AmiscarayJohn Amiscaray
9 min read

Hi everyone! Welcome back to a new blog post. Today, we'll continue with our student microservice API and extend it with an Auth server to secure our microservice communication.

Prerequisites

To follow along with this guide, you should:

  1. Be able to follow along with the previous microservice tutorials in this blog (and ideally have read them)

  2. Understand the basics of microservices

  3. Understand the concept of configuration servers in a microservice architecture

  4. Know how JWT works, including its structure and the theory behind it

  5. Know about HTTP basic authentication

For this guide, we’ll be starting from the code we left off at the previous blog post. If you need a refresher on the code we’ll be working with, check it out on GitHub.

Background

If you remember back to my first post, we had implemented some communication between microservices to sync replicated data between services:

Course Service

@RestController
@RequestMapping("student")
@AllArgsConstructor
public class StudentController {

    private StudentService studentService;

    @PostMapping
    public ResponseEntity<Void> addStudent(@RequestBody SaveStudentRequest student) {
        studentService.saveStudent(student);

        return ResponseEntity.noContent().build();
    }

}

Single endpoint for the Student Service microservice to sync its student records with the Course Service

Student Service

@RestController
@RequestMapping("/student")
@AllArgsConstructor
public class StudentController {

    // Fields and other endpoints up here...

    @PostMapping("")
    public ResponseEntity<Void> saveStudent(@RequestBody StudentDTO studentDTO) throws URISyntaxException {
        var studentID = studentService.saveStudent(studentDTO);

        courseServiceClient.saveNewStudent(new CourseSaveStudentRequest(studentID))
                .then()
                .subscribe(); // Do nothing when the request goes through.

        return ResponseEntity.created(new URI("/student/" + studentID)).build();
    }

}

Student Service sends the request to the Course Service after saving a new student

One issue with this code is security; how can we enforce that only our Student Service can call this endpoint? To do this, we need to implement some form of authentication and authorization to verify that the client is our Student Service. This is where an authentication server will come into play.

Our Authentication Server

In our updated application, we'll make use of the functionality Spring provides to create an authentication server. Using the spring-boot-starter-oauth2-authorization-server dependency, we can set up a microservice for managing JWT-based authorization. This includes:

  1. configuring registered clients, their passwords (secrets), authorities, and more;

  2. generating JWTs with a private key;

  3. issuing JWTs; and

  4. providing public keys for microservices to verify issued JWTs.

Much of this functionality would be implemented for us by Spring, and all we'd need to do is write configurations. After it’s all set up, we can then configure our Course Service to require authentication for the /student endpoint. When our Student Service wants to send a request to this endpoint, it would first need to send a request to the Auth Service to obtain a JWT it will add to the authorization header.

Implementing the Auth Service

Now that you understand how our Auth Service will work, let's dive into the implementation! First, let's add a new Maven module to our application. The pom.xml for the new module should look like so:

<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>io.john.amiscaray</groupId>
        <artifactId>MicroservicesClassroomExample</artifactId>
        <version>1.0-SNAPSHOT</version>
    </parent>

    <artifactId>AuthService</artifactId>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</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-oauth2-authorization-server</artifactId>
        </dependency>
    </dependencies>

    <properties>
        <maven.compiler.source>21</maven.compiler.source>
        <maven.compiler.target>21</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

</project>

From there, we can add the basic properties we need for our microservice to register itself with Consul (our service registry) and connect to the config server:

spring.application.name=auth-service
server.port=8083
spring.cloud.consul.host=localhost
spring.cloud.consul.port=8500
spring.config.import=configserver:
spring.cloud.config.discovery.service-id=config-service
spring.cloud.config.discovery.enabled=true

and add our main method:

package io.john.amiscaray;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class AuthServerApplication {

    public static void main(String[] args) {
        SpringApplication.run(AuthServerApplication.class, args);
    }

}

With that, we need to configure this microservice as an authentication server. We'll first create the required configuration class and set up the public/private key pair we discussed above:

@Configuration
public class AuthorizationServerConfig {
    @Bean
    public JWKSource<SecurityContext> jwkSource() {
        RSAKey rsaKey = generateRsa(); // generate a key pair
        JWKSet jwkSet = new JWKSet(rsaKey);
        return (jwkSelector, context) -> jwkSelector.select(jwkSet);
    }

    private RSAKey generateRsa() {
        KeyPair keyPair = KeyGeneratorUtils.generateRsaKey();
        return new RSAKey.Builder((RSAPublicKey) keyPair.getPublic())
                .privateKey((RSAPrivateKey) keyPair.getPrivate())
                .keyID(UUID.randomUUID().toString())
                .build();
    }
}
package io.john.amiscaray.util;

import java.security.KeyPair;
import java.security.KeyPairGenerator;

public final class KeyGeneratorUtils {

    private KeyGeneratorUtils() {}

    public static KeyPair generateRsaKey() {
        try {
            KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
            keyPairGenerator.initialize(2048);
            return keyPairGenerator.generateKeyPair();
        } catch (Exception ex) {
            throw new IllegalStateException(ex);
        }
    }
}

This code generates an RSA public/private key pair and uses it to create a JWKSource bean. Spring will use this bean to generate JWTs using the private key and share the public key using an endpoint of this Auth Service.

Next, we need to create another bean for some configuration for our Auth Server:

@Bean
public AuthorizationServerSettings authorizationServerSettings(@Value("${auth.issuer-uri}") String issuerUri) {
    return AuthorizationServerSettings.builder()
            .issuer(issuerUri)
            .build();
}

along with some new configuration:

# Other properties go here...
# Add the property below to our application.properties; this is the value of the issuerUri argument for our bean
auth.issuer-uri=http://localhost:8083

This sets the issuer field for our JWTs to the URI of our Auth Server. Without this configuration, you'd run into 401 unauthorized errors due to an invalid issuer.

Lastly, we need a couple more beans for a repository of registered clients and a password encoder to encode their passwords. Let's start with the easier of the two, the password encoder:

@Bean
public PasswordEncoder secretEncoder() {
    return new BCryptPasswordEncoder();
}

Then, we can define a RegisteredClientRepository bean where we can register our Student Service as a client:

@Bean
public RegisteredClientRepository registeredClientRepository(
        PasswordEncoder passwordEncoder,
        @Value("${client.student-service.client-secret}") String rawSecret
) {
    var encodedSecret = passwordEncoder.encode(rawSecret);

    var registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
            .clientId("student-service")
            .clientSecret(encodedSecret)
            .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
            .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
            .scope("write:student")
            .build();

    return new InMemoryRegisteredClientRepository(registeredClient);
}

add auth-service.properties in our config repository (for our config server) with the client secret:

# auth-service/auth-service.properties from the configuration git repository
client.student-service.client-secret=${CLIENT_SECRET}

and update our config server configuration with a search path for the location of the auth-service.properties files

# Config Server application.properties
# Other properties above...
# Updated Config Server search path:
spring.cloud.config.server.git.search-paths[2]=auth-service

Above, in our registeredClientRepository method, we register our Student Service as a client with a random UUID, password/secret (added in our configuration repository as an environment variable), and a write:student permission/scope. Additionally, we configured it so that the Student Service can authenticate with this service using HTTP basic authentication to retrieve the JWT as client credentials.

With that configuration, our Student Service would have to send a POST request to this Auth Service to a generated /oauth2/token endpoint to request the JWT token. In the body, the request will have information that the Student service wants client credentials (a JWT) and to request the write:student permission for the /student endpoint. To authenticate the request, the Student Service would add an Authorization header (following the HTTP basic authentication scheme) with the above client ID and client secret as the username and password, respectively.

For your reference, the final configuration class should look like so:

package io.john.amiscaray.config;

import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import io.john.amiscaray.util.KeyGeneratorUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
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.settings.AuthorizationServerSettings;

import java.security.KeyPair;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.util.UUID;

@Configuration
public class AuthorizationServerConfig {

    @Bean
    public RegisteredClientRepository registeredClientRepository(
            PasswordEncoder passwordEncoder,
            @Value("${client.student-service.client-secret}") String rawSecret
    ) {
        var encodedSecret = passwordEncoder.encode(rawSecret);

        var registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
                .clientId("student-service")
                .clientSecret(encodedSecret)
                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
                .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
                .scope("write:student")
                .build();

        return new InMemoryRegisteredClientRepository(registeredClient);
    }

    @Bean
    public JWKSource<SecurityContext> jwkSource() {
        RSAKey rsaKey = generateRsa(); // generate a key pair
        JWKSet jwkSet = new JWKSet(rsaKey);
        return (jwkSelector, context) -> jwkSelector.select(jwkSet);
    }

    private RSAKey generateRsa() {
        KeyPair keyPair = KeyGeneratorUtils.generateRsaKey();
        return new RSAKey.Builder((RSAPublicKey) keyPair.getPublic())
                .privateKey((RSAPrivateKey) keyPair.getPrivate())
                .keyID(UUID.randomUUID().toString())
                .build();
    }

    @Bean
    public AuthorizationServerSettings authorizationServerSettings(@Value("${auth.issuer-uri}") String issuerUri) {
        return AuthorizationServerSettings.builder()
                .issuer(issuerUri)
                .build();
    }

    @Bean
    public PasswordEncoder secretEncoder() {
        return new BCryptPasswordEncoder();
    }

}

Note above that with the CLIENT_SECRET environment variable, the property would be retrieved from the machine that the Auth Service is running on, not from the config server.

Updating the Course Service

Now that our Auth Service is complete, we need to make some changes to our Course Service to secure the /student endpoint. To begin, we need to add the following dependencies:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
 <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

As you'd expect, we now need Spring Security to secure our /student endpoint. However, what's more interesting here is the spring-boot-starter-oauth2-resource-server dependency. This is required for us to use the Auth Service.

Next, we can configure Spring Security for our service to require authentication for our /student endpoint:

package io.john.amiscaray.cfg;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.web.server.SecurityWebFilterChain;

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
        http.authorizeExchange(ex -> ex
                        .pathMatchers("/student").authenticated()
                        .anyExchange().permitAll()
                ).oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));

        return http.build();
    }

}

This configuration will require clients calling the /student endpoint to be authenticated using a JWT in their Authorization header while permitting all other requests. Afterward, we need to update our application.properties to configure the URI of our Auth Server:

# Other properties up here...
spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:8083

Here, we need to use the actual hostname and port of our Auth Server since the JWT decoder functionality can't resolve the hostname and port by service name, as we've done in the previous blog post.

Finally, we can update our /student endpoint to require the write:student permission that our Student Service is allowed to have:

@PreAuthorize("hasAuthority('SCOPE_write:student')") // Require the write:student permission
@PostMapping
public ResponseEntity<Void> addStudent(@RequestBody SaveStudentRequest student) {
    studentService.saveStudent(student);

    return ResponseEntity.noContent().build();
}

Updating Our Student Service

Lastly, we need to update our Student Service to retrieve a JWT and apply it as an Authorization header for the /student request. First, we need to create a new AuthServiceClient class to call the Auth Service:

package io.john.amiscaray.http;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;

import java.util.Map;

@Service
public class AuthServiceClient {

    private WebClient.Builder webClientBuilder;
    @Value("${auth.client-secret}")
    private String clientSecret;

    public AuthServiceClient(WebClient.Builder webClientBuilder) {
        this.webClientBuilder = webClientBuilder;
    }

    public Mono<String> getAuthToken() {
        return webClientBuilder.build().post()
                .uri("http://auth-service/oauth2/token")
                .headers(headers -> headers.setBasicAuth("student-service", clientSecret))
                .body(BodyInserters.fromFormData("grant_type", "client_credentials").with("scope", "write:student"))
                .retrieve()
                .bodyToMono(Map.class)
                .map(response -> (String) response.get("access_token"));
    }

}

In the getAuthToken method, we send the post request to the /oauth2/token. Here the http://auth-service URI gets resolved as the host and port of the Auth Service using service discovery. Then, in the headers, we have an HTTP basic authorization header with our service name as the username and client secret as the password. In the body, we have form data specifying that we want to get client credentials (the JWT) with the write:student scope. Finally, we retrieve the body as a Mono (async container) and map it to a Mono<String> containing the JWT from the response body. For the auth.client-secret property, we add it to the git repository that our Config Server fetches from:

auth.client-secret=${CLIENT_SECRET}

Again, like last time, the CLIENT_SECRET environment variable gets fetched from the machine that the Student Service is running on.

Lastly, using this new method, we can update the CourseServiceClient class to add a JWT to the header of the /student request:

package io.john.amiscaray.http;

import io.john.amiscaray.dto.CourseSaveStudentRequest;
import lombok.AllArgsConstructor;
import org.springframework.http.HttpStatusCode;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.*;
import reactor.core.publisher.Mono;

import java.util.function.Consumer;

@Service
@AllArgsConstructor
public class CourseServiceClient {

    private WebClient.Builder webClientBuilder;
    private AuthServiceClient authServiceClient;

    public Mono<Void> saveNewStudent(CourseSaveStudentRequest saveStudentRequest) {
        return authServiceClient.getAuthToken()
                .flatMap(authToken -> webClientBuilder
                        .build()
                        .post()
                        .uri("http://course-service/student")
                        .headers(headers -> headers.setBearerAuth(authToken))
                        .bodyValue(saveStudentRequest)
                        .retrieve()
                        .bodyToMono(Void.class));
    }
}

First, we are calling the AuthServiceClient#getAuthToken method to retrieve the Mono containing the JWT. Then we call the Mono#flatMap method to retrieve this JWT and call the /student endpoint with the JWT in the authorization header, returning a new Mono as the result of the API call.

Conclusion

With that, we have successfully implemented authentication and authorization in our microservices application to secure our inter-service communication! I hope you found this interesting and can use this to develop new skills or awesome projects in the future. For future reference, check out the final code here on GitHub.

0
Subscribe to my newsletter

Read articles from John Amiscaray directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

John Amiscaray
John Amiscaray