In this demo, you’ll see how to build a secure Java microservices architecture with Spring Boot and Spring Cloud. You’ll use Spring Data REST to expose a JPA repository as a RESTful API. You’ll also use Spring Security and its OAuth support to add authentication and authorization. Finally, you’ll use Spring Cloud Gateway to route requests to your microservices.
Features:
💡 Service Discovery with Netflix Eureka
🚦 Routing with Spring Cloud Gateway MVC
🔐 Security with OAuth 2.0 and OpenID Connect
🌟 Refresh Tokens for better security
🔑 Okta Spring Boot Starter and Keycloak
Prerequisites:
- Build Java Microservices with Spring Boot & Spring Cloud
- Add Service Discovery with Netflix Eureka
- Build a Java Microservice with Spring Data REST
- Add Routing with Spring Cloud Gateway
- Secure Spring Boot Microservices with OAuth 2.0 and OIDC
- Fetch an Access Token as a JWT
- Spring Boot Microservices and Refresh Tokens
- The Okta Spring Boot starter and Keycloak
- Have fun with Spring Boot and Spring Cloud!
Fast Track: Clone the repo and follow the instructions in spring-boot-gateway-mvc/README.md
to configure everything.
-
Create a directory to hold all your projects:
take spring-boot-microservices
NoteIf take
doesn’t work, usemkdir spring-boot-microservices && cd spring-boot-microservices
-
Create three projects using start.spring.io's REST API and HTTPie:
-
discovery-service: a Netflix Eureka server used for service discovery.
-
car-service: a simple Car Service that uses Spring Data REST to serve up a REST API of cars.
-
api-gateway: an API gateway with a
/cool-cars
endpoint that talks to the car service and filters out cars that aren’t cool (in my opinion, of course).https start.spring.io/starter.tgz bootVersion==3.2.0 \ artifactId==discovery-service name==discovery-service \ dependencies==cloud-eureka-server baseDir==discovery-service | tar -xzvf - https start.spring.io/starter.tgz bootVersion==3.2.0 \ artifactId==car-service name==car-service baseDir==car-service \ dependencies==actuator,cloud-eureka,data-jpa,data-rest,postgresql,web,validation,devtools,docker-compose | tar -xzvf - https start.spring.io/starter.tgz bootVersion==3.2.0 \ artifactId==api-gateway name==api-gateway baseDir==api-gateway \ dependencies==cloud-eureka,cloud-gateway,cloud-resilience4j | tar -xzvf -
-
-
Open the
spring-boot-microservices
directory in IntelliJ IDEA:idea .
-
In the
discovery-service
project, configure theapplication.properties
file to use port8761
and turn off registration with Eureka.server.port=8761 eureka.client.register-with-eureka=false eureka.client.fetch-registry=false
-
Add the
@EnableEurekaServer
annotation to theEurekaServiceApplication
class.discovery-service/src/main/java/com/example/discoveryservice/EurekaServiceApplication.javaimport org.springframework.cloud.netflix.eureka.server.EnableEurekaServer; @EnableEurekaServer @SpringBootApplication public class EurekaServiceApplication { ... }
-
Start the discovery service application:
./gradlew bootRun
-
In the
car-service
project, configure theapplication.properties
file to use port8090
, to have an application name, and to create the database automatically.server.port=8090 spring.application.name=car-service spring.jpa.hibernate.ddl-auto=update
-
Create a
Car
entity in thedata
package withid
andname
properties.package com.example.carservice.data; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; import jakarta.validation.constraints.NotNull; import java.util.Objects; @Entity public class Car { public Car() { } public Car(String name) { this.name = name; } @Id @GeneratedValue private Long id; @NotNull private String name; // generate getters and setters with your IDE // create equals(), hashCode(), and toString() with your IDE }
-
Create a
CarRepository
interface in the same package:package com.example.carservice.data; import org.springframework.data.jpa.repository.JpaRepository; public interface CarRepository extends JpaRepository<Car, Long> { }
-
Modify
CarServiceApplication
to enable service discovery and to create a default set of cars when the application loads.car-service/src/main/java/com/example/carservice/CarServiceApplication.javapackage com.example.carservice; import com.example.carservice.data.Car; import com.example.carservice.data.CarRepository; import org.springframework.boot.ApplicationRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cloud.client.discovery.EnableDiscoveryClient; import org.springframework.context.annotation.Bean; import java.util.stream.Stream; @EnableDiscoveryClient @SpringBootApplication public class CarServiceApplication { public static void main(String[] args) { SpringApplication.run(CarServiceApplication.class, args); } @Bean ApplicationRunner init(CarRepository repository) { repository.deleteAll(); return args -> { Stream.of("Ferrari", "Jaguar", "Porsche", "Lamborghini", "Bugatti", "AMC Gremlin", "Triumph Stag", "Ford Pinto", "Yugo GV").forEach(name -> { repository.save(new Car(name)); }); repository.findAll().forEach(System.out::println); }; } }
-
Create a
CarController
class in theweb
package to expose a/cars
endpoint.package com.example.carservice.web; import com.example.carservice.data.Car; import com.example.carservice.data.CarRepository; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; import java.util.List; @RestController class CarController { private final CarRepository repository; public CarController(CarRepository repository) { this.repository = repository; } @GetMapping("/cars") public List<Car> getCars() { return repository.findAll(); } }
-
There’s a
compose.yaml
file in the root directory to start a PostgreSQL instance.services: postgres: image: 'postgres:latest' environment: - 'POSTGRES_DB=mydatabase' - 'POSTGRES_PASSWORD=secret' - 'POSTGRES_USER=myuser' ports: - '5432'
-
Start the car service application:
./gradlew bootRun
-
Confirm you can access the
/cars
endpoint with HTTPie:http :8090/cars
-
In the
api-gateway
project, configure theapplication.properties
file to have an application name.spring.application.name=api-gateway
-
Update
ApiGatewayApplication.java
to enable service discovery and add an OpenFeign client to talk to the car service.api-gateway/src/main/java/com/example/apigateway/ApiGatewayApplication.javapackage com.example.apigateway; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cloud.client.discovery.EnableDiscoveryClient; import org.springframework.cloud.openfeign.EnableFeignClients; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.hateoas.CollectionModel; import org.springframework.stereotype.Component; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; import java.util.Collection; import java.util.stream.Collectors; @EnableFeignClients @EnableDiscoveryClient @SpringBootApplication public class ApiGatewayApplication { public static void main(String[] args) { SpringApplication.run(ApiGatewayApplication.class, args); } } record Car(String name) { } @FeignClient(name = "car-service", fallback = Fallback.class) interface CarClient { @GetMapping("/cars") CollectionModel<Car> readCars(); } @Component class Fallback implements CarClient { @Override public CollectionModel<Car> readCars() { return CollectionModel.empty(); } } @RestController class CoolCarController { private final CarClient carClient; public CoolCarController(CarClient carClient) { this.carClient = carClient; } @GetMapping("/cool-cars") public Collection<Car> coolCars() { return carClient.readCars() .getContent() .stream() .filter(this::isCool) .collect(Collectors.toList()); } private boolean isCool(Car car) { return !car.name().equals("AMC Gremlin") && !car.name().equals("Triumph Stag") && !car.name().equals("Ford Pinto") && !car.name().equals("Yugo GV"); } }
-
Spring Cloud Gateway MVC 2023.0.0 doesn’t allow you to configure a TokenRelay filter in YAML, so a
RouterFunction
bean toApiGatewayApplication
.package com.example.apigateway; // other imports omitted for brevity import org.springframework.web.servlet.function.RouterFunction; import org.springframework.web.servlet.function.ServerResponse; import static org.springframework.cloud.gateway.server.mvc.filter.LoadBalancerFilterFunctions.lb; import static org.springframework.cloud.gateway.server.mvc.filter.TokenRelayFilterFunctions.tokenRelay; import static org.springframework.cloud.gateway.server.mvc.handler.GatewayRouterFunctions.route; import static org.springframework.cloud.gateway.server.mvc.handler.HandlerFunctions.http; import static org.springframework.cloud.gateway.server.mvc.predicate.GatewayRequestPredicates.path; @EnableFeignClients @EnableDiscoveryClient @SpringBootApplication public class ApiGatewayApplication { @Bean public RouterFunction<ServerResponse> gatewayRouterFunctionsLoadBalancer() { return route("car-service") .route(path("/home/**"), http()) .filter(lb("car-service")) .filter(tokenRelay()) .build(); } public static void main(String[] args) { SpringApplication.run(ApiGatewayApplication.class, args); } }
-
Create an
application.yml
file in theresources
directory to enable service discovery.spring: cloud: gateway: discovery: locator: enabled: true
-
Start the API gateway application:
./gradlew bootRun
-
Confirm you can access the
/cool-cars
endpoint with HTTPie:http :8080/cool-cars
To secure your microservices, you’ll use OAuth 2.0 and OpenID Connect (OIDC) with Auth0. Auth0 is a popular identity provider that supports many different authentication and authorization protocols. It’s easy to use and has a generous free tier.
-
Open a terminal and run
auth0 login
to configure the Auth0 CLI to get an API key for your tenant. Then, runauth0 apps create
to register an OIDC app with the appropriate URLs:auth0 apps create \ --name "Kick-Ass Cars" \ --description "Microservices for Cool Cars" \ --type regular \ --callbacks http://localhost:8080/login/oauth2/code/okta \ --logout-urls http://localhost:8080 \ --reveal-secrets
-
Modify the
build.gradle
files in both the gateway and car service projects to use the Okta Spring Boot starter and spring-dotenv:implementation 'com.okta.spring:okta-spring-boot-starter:3.0.6' implementation 'me.paulschwarz:spring-dotenv:4.0.0'
-
Create an
api-gateway/.env
file and edit it to contain the values from the command above.OKTA_OAUTH2_ISSUER=https://<your-auth0-domain>/ OKTA_OAUTH2_CLIENT_ID= OKTA_OAUTH2_CLIENT_SECRET= OKTA_OAUTH2_AUDIENCE=https://<your-auth0-domain>/api/v2/
-
Update the gateway’s
application.properties
to configure the Okta Spring Boot starter with these values:api-gateway/src/main/resources/application.propertiesokta.oauth2.issuer=${OKTA_OAUTH2_ISSUER} okta.oauth2.client-id=${OKTA_OAUTH2_CLIENT_ID} okta.oauth2.client-secret=${OKTA_OAUTH2_CLIENT_SECRET} okta.oauth2.audience=${OKTA_OAUTH2_AUDIENCE}
-
Add the following properties to configure OpenFeign to work with OAuth 2.0:
api-gateway/src/main/resources/application.propertiesspring.cloud.openfeign.oauth2.enabled=true spring.cloud.openfeign.oauth2.clientRegistrationId=okta
-
Create
car-service/.env
and update its values.OKTA_OAUTH2_ISSUER=https://<your-auth0-domain>/ OKTA_OAUTH2_AUDIENCE=https://<your-auth0-domain>/api/v2/
NoteThe car service doesn’t need the client ID and secret because it’s acting as a resource server and simply validates the access token, without communicating with Auth0. -
Update the car service’s
application.properties
:car-service/src/main/resources/application.propertiesokta.oauth2.issuer=${OKTA_OAUTH2_ISSUER} okta.oauth2.audience=${OKTA_OAUTH2_AUDIENCE}
-
Add a
HomeController
class to the car service project that displays the access token’s claims.car-service/src/main/java/com/example/carservice/web/HomeController.javapackage com.example.carservice.web; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; import java.security.Principal; @RestController public class HomeController { private final static Logger log = LoggerFactory.getLogger(HomeController.class); @GetMapping("/home") public String home(Principal principal) { var username = principal.getName(); if (principal instanceof JwtAuthenticationToken token) { log.info("claims: " + token.getTokenAttributes()); } return "Hello, " + username; } }
-
Add a
HomeController
class to the API gateway project that displays your user’s name and access token.api-gateway/src/main/java/com/example/apigateway/web/HomeController.javapackage com.example.apigateway.web; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient; import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @RestController class HomeController { @GetMapping("/") public String howdy(@AuthenticationPrincipal OidcUser user) { return "Hello, " + user.getFullName(); } @GetMapping("/print-token") public String printAccessToken(@RegisteredOAuth2AuthorizedClient("okta") OAuth2AuthorizedClient authorizedClient) { var accessToken = authorizedClient.getAccessToken(); System.out.println("Access Token Value: " + accessToken.getTokenValue()); System.out.println("Token Type: " + accessToken.getTokenType().getValue()); System.out.println("Expires At: " + accessToken.getExpiresAt()); return "Access token printed"; } }
-
Restart both the car service and API gateway applications using Ctrl+C and
./gradlew bootRun
. -
Open
http://localhost:8080
in your favorite browser. You’ll be redirected to Auth0 to log in. After authenticating, you’ll see your name in lights! ✨ -
If you go to
http://localhost:8080/cool-cars
, you won’t see any data and there will be an error in your gateway app’s console.[503] during [GET] to [http://car-service/cars]
-
Go to
http://localhost:8080/print-token
and view the access token printed to the console. -
Check if it’s a valid access token by copying/pasting it into jwt.io. You’ll see it’s invalid. This is because Auth0 returns an opaque token when you don’t pass in an
audience
parameter.
-
Create a
SecurityConfiguration
class in the API gateway project to configure Spring Security to send anaudience
parameter to Auth0.api-gateway/src/main/java/com/example/apigateway/config/SecurityConfiguration.javapackage com.example.apigateway.config; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver; import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; import org.springframework.security.web.SecurityFilterChain; import java.util.function.Consumer; @Configuration public class SecurityConfiguration { @Value("${okta.oauth2.audience:}") private String audience; private final ClientRegistrationRepository clientRegistrationRepository; public SecurityConfiguration(ClientRegistrationRepository clientRegistrationRepository) { this.clientRegistrationRepository = clientRegistrationRepository; } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authorize -> authorize .anyRequest().authenticated() ) .oauth2Login(oauth2 -> oauth2 .authorizationEndpoint(authorization -> authorization .authorizationRequestResolver( authorizationRequestResolver(this.clientRegistrationRepository) ) ) ); return http.build(); } private OAuth2AuthorizationRequestResolver authorizationRequestResolver( ClientRegistrationRepository clientRegistrationRepository) { DefaultOAuth2AuthorizationRequestResolver authorizationRequestResolver = new DefaultOAuth2AuthorizationRequestResolver( clientRegistrationRepository, "/oauth2/authorization"); authorizationRequestResolver.setAuthorizationRequestCustomizer( authorizationRequestCustomizer()); return authorizationRequestResolver; } private Consumer<OAuth2AuthorizationRequest.Builder> authorizationRequestCustomizer() { return customizer -> customizer .additionalParameters(params -> params.put("audience", audience)); } }
-
Restart the API gateway and now
http://localhost:8080/print-token
will print a valid JWT. Prove the other URLs work: -
Copy the JWT from the console and access the car service directly.
TOKEN=<access-token> http :8090/cars Authorization:"Bearer $TOKEN"
-
Change the default scopes in the gateway project to request a refresh token using the
offline_access
scope. Also, change the audience to be one that quickly expires its access tokens..envOKTA_OAUTH2_AUDIENCE=https://fast-expiring-api OKTA_OAUTH2_SCOPES=openid,profile,email,offline_access
-
Add a property to
application.properties
to read the updated scopes and add logging for WebClient.api-gateway/src/main/resources/application.propertiesokta.oauth2.scopes=${OKTA_OAUTH2_SCOPES} logging.level.org.springframework.web.reactive.function.client=DEBUG
-
Create a new API in Auth0 and configure it to have a 30-second access token lifetime.
auth0 apis create --name fast-expiring --identifier https://fast-expiring-api \ --token-lifetime 30 --offline-access --no-input
-
Restart the API gateway and go to
http://localhost:8080/print-token
to see your access token. -
Copy the expired time to timestamp-converter.com (under ISO 8601) to see when it expires in your local timezone.
-
Wait 30 seconds and refresh the page. You’ll see a request for a new token and an updated
Expires At
timestamp in your terminal.
If you find yourself in a situation where you don’t have an internet connection, it can be handy to run Keycloak locally in a Docker container. Since the Okta Spring Boot starter is a thin wrapper around Spring Security, it works with Keycloak, too.
Note
|
The Okta Spring Boot starter does validate the issuer to ensure it’s an Okta URL, so you must use Spring Security’s properties instead of the okta.oauth2.* properties when using Keycloak.
|
-
An easy way to get a pre-configured Keycloak instance is to use JHipster's
jhipster-sample-app-oauth2
application. It gets updated with every JHipster release. Clone it with the following command:git clone https://github.com/jhipster/jhipster-sample-app-oauth2.git --depth=1 cd jhipster-sample-app-oauth2
-
Start Keycloak with Docker Compose:
docker compose -f src/main/docker/keycloak.yml up -d
-
Configure the gateway to use Keycloak by removing the
okta.oauth2.*
properties and using Spring Security’s inapplication.properties
:api-gateway/src/main/resources/application.propertiesspring.security.oauth2.client.provider.okta.issuer-uri=http://localhost:9080/realms/jhipster spring.security.oauth2.client.registration.okta.client-id=web_app spring.security.oauth2.client.registration.okta.client-secret=web_app spring.security.oauth2.client.registration.okta.scope=openid,profile,email,offline_access
-
Update the car service to use Keycloak by removing the
okta.oauth2.*
properties and using Spring Security’s inapplication.properties
:car-service/src/main/resources/application.propertiesspring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:9080/realms/jhipster spring.security.oauth2.resourceserver.jwt.audiences=account
-
Restart both apps, open
http://localhost:8080
, and you’ll be able to log in with Keycloak. -
Use
admin
/admin
for credentials, and you can accesshttp://localhost:8080/cool-cars
as you did before.
I hope you enjoyed this demo, and it helped you learn how to use Spring Boot with microservices in a secure way. Using OpenID Connect is a recommended practice for authenticating with microservices, OAuth 2.0 is great for securing communication between them. And, Auth0 makes it easy to do both.
Using short-lived access tokens is recommended for enhanced security and refresh tokens make them easier on your users. Finally, isn’t it neat how the Okta Spring Boot starter works with Keycloak too?!
🍃 Find the source code on GitHub: @oktadev/auth0-java-microservices-examples
✨ Read the blog post: Java Microservices with Spring Boot and Spring Cloud