Compile outputs fun

Spring Boot series: API server with JWT authentication

Published 15 minutes agoJava, Spring Boot, OAuth2, API

Objective

To create a minimal microservice with some APIs that requires JWT authentication. The JWT will be issued by an external authorization server. The API server will verify that the validity of the JWT with the authorization server.

We will only focus on the microservice part. I'm using Keycloak as the authorization server but you can use any authorization server.

Plan of attack

1. Setup Keycloak

We will run Keycloak on Docker. Make sure you have installed Docker, then just run this commanddocker run -p 8080:8080 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:19.0.1 start-dev

This will run Keyclock on port 8080 with both admin user name and password set to `admin`. We will try to create a token with admin user to see if it's really up and running. Run this `curl` command:curl http://localhost:8080/realms/master/protocol/openid-connect/token --header "content-type: application/x-www-form-urlencoded" --data grant_type=password --data client_id=admin-cli --data client_secret= --data username=admin --data password=admin

You should get the console output like this{"access_token":"<base64_encoded_access_token>","expires_in":60,"refresh_expires_in":1800,"refresh_token":"<base64_encoded_refresh_token>","token_type":"Bearer","not-before-policy":0,"session_state":"<a_uuid>","scope":"profile email"}The `access_token` is the JWT we will use for API call later.

2. Create a Spring Boot project

Use Spring Initializr to create a new project with this configuration

  • Project: Gradle Project
  • Language: Java
  • Spring Boot: 2.7.3
  • Java Version: 17 (You can use lower version)
  • Dependencies:
    1. Spring Web - Allow us to create REST API

This is a very minimal configuration for a microservice with API. It does not have any security capability yet. We will add that later. Now click on `Generate` to download the generated project in a ZIP file. Extract it somewhere and open it with your favourite IDE.

The project comes with a default test to make sure the microservice can be started. Run the test with `gradlew test` command. It should build the project and run the test successfully.

You can also try to start the microservice by `gradlew bootRun` command, however the default port is 8080 and it conflicts with the Keycloak port. We will need to change the microserovice port to 9000.

Delete the file named `application.properties` in `src/main/resources`. That file is used to store application configuration in Java properties file format. We will use YAML format for better readibility.

Create a file named `application.yaml` in `src/main/resources` with these contents:

You can start the microservice now by `gradlew bootRun`.

3. Create a public API

Create a file named `PublicController.java` in `src/main/java/c4compile/jwtdemo` with these contents:

This will create a API on `/something` for GET operation that return a fixed string `Something!`. You can start the microservice with `gradlew bootRun` and test it with a `curl` command:curl http://localhost

However, manual test is bad. We should do automated test. Create a file named `PublicControllerTest.java` in `src/test/java/c4compile/jwtdemo` with these contents:

This file contains only a single test. It will start the microservice on random port and perform a GET action on `/something`, then it validates the response status and response body. It's what we are doing manually just now, but now it's a automated test so we can run it whenever we want to make sure we do not break anything when adding new features.

4. Create an admin API

Now we will create an API that requires an JWT. We need to include another library from Spring to use the JWT authentication. Add this line to `build.gradle` file in the `dependencies` section:

Run the test again with `gradlew test` command and the test should fail now with these messages:expected: <Something!> but was: <<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> ...The `/something` API now return a login page instead of the expected string! By adding the new dependency, the security feature is turned on by default. Ignore this for now. We will fix this indirectly later.

Create a file named `AdminController.java` in `src/main/java/c4compile/jwtdemo/admin` with these contents:

This will create an API on `/admin/something` for GET operation that returns `Something from [username]!`.

Create another file named `AdminSecurityConfig` in `src/main/java/c4compile/jwtdemo/admin` with these contents:

This will configure the API to only allowed authenticated user to visit APIs under `/admin/**`. We need to access the authorization server in order to validate a JWT. We can tell Spring Boot where it is by adding these to the `application.yml` file:

You can start the microservice manually and try it but I will prefer to create an automated test here. Create a file named `TokenUtils.java` in `src/test/java/c4compile/jwtdemo/token` with these contents:

This file contains a utility method for the test to get a JWT from the authorization server. The JWT is short-lived so we need to get it everytime we run the test.

Create a file named `AdminControllerTest.java` in `src/test/java/c4compile/jwtdemo/admin` with these contents:

This will create 3 tests:

  1. Perform an API call without JWT and we should get HTTP response status 401
  2. Perform an API call with a invalid JWT and we should get HTTP response status 403
  3. Perform an API call with a valid JWT and it should success
Run the tests with `gradlew test` and everything should success, even the test for public API! After we add the `SecurityFilterChain` bean, any route that is not matched in the chain will be accessible by everyone.

Now we have a API at `/something` that allows everyone to access it and another API at `/admin/something` that only allowed authorized user to access it.

5. Create a super admin API

Now we will create a API that requires an JWT with specific role. We will not generate a JWT for that. This is just to show how we can have multiple `SecurityFilterChain` for different APIs. Create a file named `SuperAdminController.java` in `src/main/java/c4compile/jwtdemo/superadmin` with these contents:

It doesn't matter what the API is doing because we should not able to call it anyway. Create a file named `SuperAdminSecurityConfig` in `src/main/java/c4compile/jwtdemo/superadmin` with these contents:

Spring Boot does not allow multiple beans that has the same type and name. The name of the bean is actually the method name. So the method name has to be different from the method name in other class. This will configure the API to only allowed authenticated user with `SUPER_ADMIN` role to visit APIs under `/super-admin/**`.

We will create another set of automated test to test it. Create a file named `SuperAdminControllerTest.java` in `src/test/java/c4compile/jwtdemo/superadmin` with these contents:

This will create 2 tests:

  1. Perform an API call without JWT and we should get HTTP response status 401
  2. Perform an API call with a valid JWT and we should get HTTP response status 403
Run the tests with `gradlew test` and everything should success.

Now we have

  • a API at `/something` that allows everyone to access it
  • a API at `/admin/something` that only allowed authorized user to access it
  • a API at `/super-admin/something` that only allowed authorized user with `SUPER_ADMIN` role to access it
All the code are in respective modules. The full source code is here.