Discover Cloud Solutions with HostingerGet a special discount.

hostingerLearn More
Published on

Spring Boot Guide: OAuth2 Resource Server

Authors
  • avatar
    Name
    Luis Carbonel
    Twitter

Overview

In this article, we will explore the steps to create a Spring Boot OAuth2 Resource Server, from developing an insecure API to securing it using Spring Security. Learn about default Spring Security features, adding Spring Security to your application, and transforming it into an OAuth2 Resource Server. Understand token generation, access control based on user roles, and validating custom claims.

First of all, we are going to define the role of an OAuth2 Resource Server in an architecture.

OAuth2 Resource Server Architecture Understanding the key actors in OAuth2 Architecture is crucial in our endeavor.

The Role of an OAuth2 Resource Server

Before we dive into technicalities, let's clarify the OAuth2 Resource Server's role:

  • The client application requests permission from the authorization server to communicate with the resource server.
  • The authorization server determines whether to grant permission.
  • Upon permission grant, the authorization server generates or "mints" an access token with a limited lifespan. This token specifies the granted client permissions.
  • The client initiates a request to the resource server, including the access token.
  • The resource server validates that the access token possesses the correct permissions and responds accordingly.

We will zoom in on the resource server aspect in this guide.

Before delving into the creation of the Resource Server, let's first construct an insecure API to review fundamental concepts about default security provided by Spring Security. For further insights, you can refer to the post Spring Security by Default in a Spring Boot API. In fact, we will be utilizing the same project created in that post. Therefore, if you've read the previous post, you can skip the section related to API creation and jump directly to the section Transforming our application into an OAuth2 Resource Server.

Table of Contents

Creating an Insecure API

First of all, go to Spring Initializr and create a new project with the following settings:

Project Setup:

Dependencies

  • Gradle Project (Optional)
  • Java 17
  • Spring Web
  • Spring Data JDBC
  • H2 Database

gradle.build

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.2.0'
    id 'io.spring.dependency-management' version '1.1.4'
}

group = 'com.devlach'
version = '0.0.1-SNAPSHOT'

java {
    sourceCompatibility = '17'
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    runtimeOnly 'com.h2database:h2'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
}

tasks.named('test') {
    useJUnitPlatform()
}

Defining the Data Model

Now, let's create a simple entity to represent our tasks. We will use Spring Data JDBC, which does not require our entities to be annotated with @Entity. Here is the definition of the Task entity:

package com.devlach.springsecurityoauth2resourceserver;

import org.springframework.data.annotation.Id;

public record Task(
        @Id Long id,
        String description,
        boolean completed,
        String owner
) {
    public Task(String description, boolean completed, String owner) {
        this(null, description, completed, owner);
    }
}

Repository and Controller

Next, we will create a repository and a controller to handle the operations with our tasks:

package com.devlach.springsecurityoauth2resourceserver;

import org.springframework.data.repository.CrudRepository;

public interface TaskRepository extends CrudRepository<Task, Long> {
}
package com.devlach.springsecurityoauth2resourceserver;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.util.UriComponentsBuilder;

@RestController
@RequestMapping("/tasks")
public class TaskController {

    private final TaskRepository taskRepository;

    public TaskController(TaskRepository taskRepository) {
        this.taskRepository = taskRepository;
    }

    @PostMapping
    public ResponseEntity<Task> createTask(@RequestBody Task task, UriComponentsBuilder ucb) {
        Task savedTask = taskRepository.save(task);
        var urlLocation = ucb.path("/tasks/{id}").buildAndExpand(savedTask.id()).toUri();
        return ResponseEntity.created(
                urlLocation
        ).body(savedTask);
    }

    @GetMapping("/{taskIdRequested}")
    public ResponseEntity<Task> findById(@PathVariable Long taskIdRequested) {
        return taskRepository.findById(taskIdRequested)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    @GetMapping
    public Iterable<Task> findAll() {
        return taskRepository.findAll();
    }
}

Now that we have our API, we can run it and test it. But first, let's create some dummy data in our H2 database.

Go to src/main/resources and create the following files:

  • schema.sql
CREATE TABLE IF NOT EXISTS task
(
    id          BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    description VARCHAR(255) NOT NULL,
    completed   BOOLEAN NOT NULL,
    owner       VARCHAR(255) NOT NULL
);
  • data.sql
INSERT INTO task( description, completed, owner)
VALUES ('Learn SQL', false, 'sql'),
       ('Learn Java', false, 'java'),
       ('Learn Kotlin', false, 'kotlin');

Spring Security by default

When we add Spring Security to our application, we get a default security layer that protects us by default. Let's see what Spring Security provides by default.

  • Authentication required for all requests.
  • Security headers for all requests.
  • Provides basic authentication with a default user.
  • Contains an application firewall.
  • Protects against CSRF attacks.
  • Responds RESTfully to security errors.

Well, it's time to write some code. We are going to add Spring Security to our application, and we will understand how with the configuration we can obtain very attractive premeditated security behaviors.

Add Spring Security to our application

To add Spring Security to our application, we will add the spring-boot-starter-security dependency to our build.gradle.kts file:

dependencies {
    ...
    implementation 'org.springframework.boot:spring-boot-starter-security'
    ...
}

With this Spring Security is added to our application, and it will be automatically detected by Spring Boot.

Once this is done, let's restart our application and make a request to the API:

http :8080/tasks

You should get a 401 Unauthorized response:

HTTP/1.1 401 
...

This is because Spring Security requires authentication for all requests.

Basic authentication with a default user

Spring Security provides a default user to authenticate us in our application. This user is user and its password is a random value generated when starting the application.

To get the password of the default user, we can see it in the terminal output when we start the application:

...
Using generated security password: 0fe577ac-d0b7-4c99-b296-871df4a4a67e
...

Now, we can use this password to authenticate in our application:

http -a user:0fe577ac-d0b7-4c99-b296-871df4a4a67e :8080/tasks

You should get a 200 response:

HTTP/1.1 200 
...
[
    {
        "completed": false,
        "description": "Learn SQL",
        "id": 1,
        "owner": "sql"
    },
    {
        "completed": false,
        "description": "Learn Java",
        "id": 2,
        "owner": "java"
    },
    {
        "completed": false,
        "description": "Learn Kotlin",
        "id": 3,
        "owner": "kotlin"
    }
]

Transforming our application into an OAuth2 Resource Server

Now that we have seen how Spring Security protects us by default, we are going to transform our application into an OAuth2 Resource Server.

Add Spring Security OAuth2 Resource Server dependency

To do this, we will add the spring-boot-starter-oauth2-resource-server dependency to our build.gradle file:

dependencies {
 ...
 implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
}

This incorporates Spring Boot autoconfiguration and also adds the Spring Security Resource Server and JWT modules.

Configure our application as an OAuth2 Resource Server

Now, we are going to configure our application as an OAuth2 Resource Server. For this, we will need to indicate how our application will validate the JWT tokens that it receives.

Usually, these tokens are validated against a public key. So, first, we need to get the public key of the authorization server that will issue the tokens.

In our case for testing purposes, we will to generate a key pair using OpenSSL:

Generate a key pair using OpenSSL

  • Generate a private key:
openssl genrsa -out private.pem 4096
  • Generate a public key:
openssl rsa -in private.pem -outform PEM -pubout -out public.pem

You will get two files: private.pem and public.pem. Copy those files to src/main/resources.

Now, we need to configure our application to validate the tokens against the public key. For this, we will create a application.yml file in src/main/resources with the following content:

  • application.yml
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          public-key-location: classpath:public.pem

With this, we are activating Spring Boot's resource server autoconfiguration.

Test our application as an OAuth2 Resource Server

Now, we can test our application as an OAuth2 Resource Server.

First, we will take a look at the Spring Security logs to verify that our application is configured as an OAuth2 Resource Server:

Spring Security logs

We will to update the application.yml file to enable the Spring Security logs:

Add the following lines to application.yml:

logging:
  level:
    org.springframework.security: TRACE

Now, we can restart our application and make a request to the API:

./gradlew bootRun

If you look with attention at the logs, note that the following line does not appear:

Using generated security password: 0fe577ac-d0b7-4c99-b296-871df4a4a67e

This is because we have configured our application as an OAuth2 Resource Server, and the default user is no longer available. This generated password is for development use only. It is not secure and should not be used in production.

Verify the authentication method in the headers

Now, we can make a request to the API:

http :8080/tasks

You should get a 401 Unauthorized response:

HTTP/1.1 401 
...
WWW-Authenticate: Bearer
...

Note that the WWW-Authenticate header indicates that the authentication method is Bearer. Before configuring our application as an OAuth2 Resource Server, the authentication method was Basic.

Generate a JWT token to authenticate in our application

Now, we are going to generate a JWT token to authenticate in our application.

Go to SpringSecurityOauth2ResourceServerApplication and add the following method:

    @Bean
    public CommandLineRunner commandLineRunner(
            @Value("classpath:private.pem") RSAPrivateKey privateKey,
            @Value("classpath:public.pem") RSAPublicKey publicKey
    ) {
        return args -> {
            RSAKey key = new RSAKey.Builder(publicKey).privateKey(privateKey).build();
           var jwtEncoder =  new NimbusJwtEncoder(new ImmutableJWKSet<>(new JWKSet(key)));
            JwtClaimsSet builder = JwtClaimsSet.builder()
                    .issuedAt(Instant.now())
                    .expiresAt(Instant.now().plusSeconds(3000))
                    .subject("java")
                    .issuer("https://devlach.com")
                    .claim("scp", Arrays.asList("tasks:read", "tasks:write"))
                    .build();
            var jwtEncoded = jwtEncoder.encode(JwtEncoderParameters.from(builder));
            var consolePretty = """
                    This is a JWT token that you can use to test the API:
                    {
                     copyToClipboard: "%s"
                    }
                    """.formatted(jwtEncoded.getTokenValue());
            System.out.println(consolePretty);
        };
    }

This method will generate a JWT token that we can use to authenticate in our application.

Now, we can restart our application and copy the generated token:

./gradlew bootRun

You should see the following output in the console:

This is a JWT token that you can use to test the API:
{
 copyToClipboard: "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2RldmxhY2guY29tIiwic3ViIjoiamF2YSIsImV4cCI6MTcwMTI2ODE0NCwiaWF0IjoxNzAxMTgxNzQ0LCJzY29wZSI6WyJ0YXNrczpyZWFkIiwidGFza3M6d3JpdGUiXX0.Oa0qV2X9H32wsHzuLRJpPtzd2JtvkOJNLlngI4feiWEARLDEoaqIb1drH_7Pi8vbcwlIB5QUYEVQgi6mCrcDj0A348ThNJrsyULth8-VabmiFipjQpn6V1mAt1-epxPfKE2P2EOnl0dEOOwtpthiFRmkRIXcJ27FBGAwwMc0IEXp1zb33z_6V10zzeK_ICgoiAhMRb3rU-fFeZhJ9TgLpAm6uVTmFsFb7U-g-e0742w5Qy0S7jUQk-ern_x6EMGb2b1SuVvM0ee4nYCUUu6I5xABYTBRxHmga3W5dYZwqe9lVbwKD-sZ1zI1ero3ji0svIKd1XpmqxXaOJ5tMM6o1ge-5aNUkaq7tBc9Jml4axSkYe5tQCqATXtGqgvrSq3__TnazZan9Z93VTNTWJ2LpMzyTI9UBfB8pS_Olqy8j8nCJtPY6tbAqh4XqXHItOzbYeT12KJ7KtunLCVkMhoD_GqiwMhBT2ZUTteJqWXye68o2e03UbtirIbuQn8nIxYJrDTN5DLv6pOZprQPUjxidzC2XohqPXAjJa3aMhNKb2bF4gux15Ba69-p6-dJtDgh7Giv5VifCaHM6IyLTOQzP_hX2dIoPI9oDoMqYA1tW4Hr8M4-LO9lqEAvhgC8xdZWOCCf8_R70uuUub-gHJsjELZAhNtlEB2tLI27fHr4rDM"
}

Authenticate in our application with the JWT token

Now, we can authenticate our application with the JWT token, but first we will inspect the token to understand its structure.

Go to jwt.io and paste the token in the Encoded section.

You should see the following decoded token:

{
  "alg": "RS256",
  "sub": "java",
  "exp": 1701268144, 
  "iat": 1701181744,
  "scp": [
    "tasks:read",
    "tasks:write"
  ],
  "iss": "https://devlach.com"
}

As you can see, the token contains the following claims:

  • sub: The subject of the token. In this case, the subject is java.
  • exp: The expiration time of the token.
  • iat: The time the token was issued.
  • scp: The scope of the token. In this case, the scope is tasks:read and tasks:write.
  • iss: The issuer of the token. In this case, the issuer is https://devlach.com.
  • alg: The algorithm used to sign the token. In this case, the algorithm is RS256.

Now, we can query the API with the token:

  • Export the JWT token to an environment variable:
export TMP_TOKEN=eyJhbGciOi...
  • Make a request to the API:
http :8080/tasks "Authorization: Bearer $TMP_TOKEN"

You should get a 200 response:

[
    {
        "completed": false,
        "description": "Learn SQL",
        "id": 1,
        "owner": "sql"
    },
    {
        "completed": false,
        "description": "Learn Java",
        "id": 2,
        "owner": "java"
    },
    {
        "completed": false,
        "description": "Learn Kotlin",
        "id": 3,
        "owner": "kotlin"
    }
]

Success! We've effectively authenticated in our application using a JWT token. However, a challenge arises; we're able to access all tasks, including those not assigned to the java user. This prompts a crucial question: Why are tasks labeled under sql and kotlin still visible? Should generating a token specifically for java restrict our visibility to only java-related tasks?

Before continuing, let's take a look at the Spring Security logs to see what is happening:

SecurityContextHolder since already authenticated JwtAuthenticationToken [...]

Observe inside the JwtAuthenticationToken the details of the token:

JwtAuthenticationToken Principal=org.springframework.security.oauth2.jwt.Jwt@15d59f3, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=null], Granted Authorities=[SCOPE_tasks:read, SCOPE_tasks:write]

As you can see, the authentication instance contains:

  • A Principal, which is the set of claims of the token.
  • A Credentials, which is the token itself.
  • A set of Granted Authorities, which are the scopes of the token prefixed with SCOPE_.

Now, we can restrict the access to the tasks that belong to the user that is authenticated in the application.

Accessing Authentication information in Spring MVC

First, we need to know what are the use cases when you may need to look up the authenticated user:

  • You must acquire the profile or other information related to the principal.
  • A primary key or other principal identifiers are required to raise a query.
  • It is imperative to specify whether a principal has the permission to perform a specific action.
  • It is essential to transmit credentials to the services that follow.

In all these cases, we need to access the current authenticated user or principal.

Accessing Authentication with Method Injection

In the TaskController, we will add a new Authentication parameter in findAll method:

    @GetMapping
    public Iterable<Task> findAll(Authentication authentication) {
        ...
    }

To perform the findAll operation, we will add a findByOwner method in the TaskRepository:

    Iterable<Task> findByOwner(String owner);

Now, we can implement the findAll method:

    @GetMapping
    public Iterable<Task> findAll(Authentication authentication) {
        return taskRepository.findByOwner(authentication.getName());
    }

Now, we can restart our application and make a request to the API:

./gradlew bootRun

Try to make a request to the API:

http :8080/tasks "Authorization: Bearer $TMP_TOKEN"

You should get a 200 response:

[
    {
        "completed": false,
        "description": "Learn Java",
        "id": 2,
        "owner": "java"
    }
]

It works! Now, we can only see the tasks that belong to the authenticated user. In this case java. 🥳

Accessing Authentication with @CurrentSecurityContext annotation

The @CurrentSecurityContext annotation is a convenient way to remove some of the boilerplate code to get the values like principal, credentials, and authorities from the SecurityContext.

Wi Bearer JWT Authentication, the Authentication#getPrincipal() method returns a Jwt instance. This instance contains the claims of the token. Now, we can obtain the principal like this:

    @GetMapping
    public ResponseEntity<Iterable<Task>> findAll(@CurrentSecurityContext(expression = "authentication.principal") Jwt principal) {
        var owner = principal.getSubject();
        return ResponseEntity.ok(taskRepository.findAllByOwner(owner));
    }

Now, we can restart our application and make a request to the API:

./gradlew bootRun
http :8080/tasks "Authorization: Bearer $TMP_TOKEN"

You should get a 200 response:

[
    {
        "completed": false,
        "description": "Learn Java",
        "id": 2,
        "owner": "java"
    }
]

This is handy if you are needing to get JWT-specific information, like calling Jwt#getIssuer or Jwt#getAudience.

You must know that the @CurrentSecurityContext annotation is limited to be processed by Controller methods.

You can obtain any attribute from the Authentication instance that you need. For example, the above snippet can be simplified to call Authentication#getName like so:

    @GetMapping
    public ResponseEntity<Iterable<Task>> findAll(@CurrentSecurityContext(expression = "authentication.name") String owner) {
        return ResponseEntity.ok(taskRepository.findAllByOwner(owner));
    }

This is a convenient way to remove some of the boilerplate code to get the values like principal, credentials, and authorities, but although this is a nice improvement, there is still boilerplate like the SpEL expression. Keep in mind that SpEL is a powerful expression language. However, SpEL expressions are not compiled along with your code, and it is a good practice to avoid them when possible since errors in SpEL expressions are not caught until runtime.

To avoid SpEL expressions, we can use the @AuthenticationPrincipal annotation.

Accessing Authentication with @AuthenticationPrincipal annotation

The @AuthenticationPrincipal annotation resolve Authentication.getPrincipal() to a method argument. It is a convenient way to remove some of the boilerplate code to get the values like principal. It is the equivalent of @CurrentSecurityContext(expression = "authentication.principal") and it also allows SpEL expressions.

Now, we can implement the findAll method like this:

    @GetMapping
    public ResponseEntity<Iterable<Task>> findAll(@AuthenticationPrincipal Jwt principal) {
        var owner = principal.getSubject();
        return ResponseEntity.ok(taskRepository.findAllByOwner(owner));
   }

Create your own meta annotation

Also, you can create your own meta annotation to avoid SpEL expressions:

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@CurrentSecurityContext(expression="authentication.name")
public @interface CurrentUsername {
}

In this case, we are creating a meta-annotation called @CurrentUsername that is equivalent to @CurrentSecurityContext(expression = "authentication.name").

Now, we can implement the findAll method:

    @GetMapping
    public ResponseEntity<Iterable<Task>> findAll(@CurrentUsername String owner) {
        return ResponseEntity.ok(taskRepository.findAllByOwner(owner));
    }

Until now, we have seen how to access the current authenticated user. But if we need to access the Authentication instance, from any part of our application? For example, in a service.

Accessing Authentication with SecurityContextHolder

We have already learned how to access the Authentication instance using Spring MVC and pass it to other layers of your application. It is great for the use cases where we need to access the Authentication instance from a controller method.

Now, we are going to learn how to access the Authentication instance from any part of our application.

In this previous example, we have seen how to access the Authentication instance from a controller method:

    @GetMapping
    public ResponseEntity<Iterable<Task>> findAll(@AuthenticationPrincipal Jwt principal) {
        var owner = principal.getSubject();
        return ResponseEntity.ok(taskRepository.findAllByOwner(owner));
    }

To use SecurityContextHolder we need to change the findAll method:

    @GetMapping
    public ResponseEntity<Iterable<Task>> findAll() {
        var context = SecurityContextHolder.getContext(); // This is the SecurityContext
        var authentication = context.getAuthentication(); // This is the Authentication instance
        var owner = authentication.getName();
        return ResponseEntity.ok(taskRepository.findAllByOwner(owner));
    }

You may be wondering how SecurityContextHolder has access to the SecurityContext anywhere in the application. The answer is that Spring Security uses a ThreadLocal to store the SecurityContext and make it available anywhere in the application.

Well, now let's try to make a request to the API:

./gradlew bootRun
http :8080/tasks "Authorization: Bearer $TMP_TOKEN"

You should get a 200 response:

[
    {
        "completed": false,
        "description": "Learn Java",
        "id": 2,
        "owner": "java"
    }
]

Custom claims validation

Until now, we are doing great, excellent work, but what if we want to be more strict, and we want to validate for example that the token has a specific claim, for example that the audiences contains tasks-api-client?

Fortunately for us, this is a simple task; we only need to add a JwtDecoder bean to our application:

    @Bean
    public JwtDecoder jwtDecoder(
    @Value("classpath:public.pem") RSAPublicKey publicKey
            ) {
        String audience = "tasks-api-client";
        var audiences = new JwtClaimValidator<List<String>>(AUD, claims -> claims != null
        && claims.contains(audience));
        DelegatingOAuth2TokenValidator<Jwt> validator = new DelegatingOAuth2TokenValidator<>(audiences);
        var jwtDecoder = NimbusJwtDecoder.withPublicKey(publicKey).build();
        jwtDecoder.setJwtValidator(validator);
        return jwtDecoder;
        }

Remember that the token ($TMP_TOKEN) we generated earlier does not include the aud claim.

Now, we can restart our application and make a request to the API:

./gradlew bootRun
http :8080/tasks "Authorization: Bearer $TMP_TOKEN"

You should get a 401 Unauthorized response:

HTTP/1.1 401 
...
WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: The aud claim is not valid", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"
...

Even if you want to save code, you can add to your application.yml:

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          public-key-location: classpath:public.pem
          audiences: tasks-api-client // This is the audience that we want to validate

Congratulations on getting this far! We have accumulated considerable knowledge, but there is still a more fascinating stretch ahead of us.

In the previous section, we discussed validating claims, but what if we want to validate custom claims? Imagine that for a specific endpoint, we want to verify that the user has a particular scope.

A specific case would be to make sure that, when creating tasks, the user has the scope tasks:write, and when querying tasks, the scope is tasks:read. I know this may be getting a bit complicated, but don't worry, Spring Security provides an elegant solution using Authorization Models.

There are two types of Authorization Models in Spring Security, Request-Level Authorization Model and Method-Level Authorization Model.

Request-Level Authorization Model

The Request-Level Authorization Model is the most common and is based on the HttpServletRequest instance. In Spring Security, request-level authorization offers control at a broad level. It involves making statements based on request characteristics, specifying conditions like "If the request URI is X, require authority Y." For instance, ensuring that requests to /cashcards mandate the cashcard:read scope.

Breaking it down:

  • Request: The request object is the HttpServletRequest instance.
  • Pattern: The matching rule dictating if the request adheres to a specified criterion.
  • Authority: The authorization rule that must be satisfied for the request to be allowed.

While Spring Security allows versatile mapping of request elements to authorization rules, a prevalent practice is associating URIs with specific permissions. An illustration is the default filter chain definition:

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
    httpSecurity
        .authorizeHttpRequests(authorizeRequests ->
            authorizeRequests
                .anyRequest().authenticated()
        )
        ...
}

This definition states that any request to any URI must be authenticated.

In the code snippet below, we'll demonstrate how to fine-tune authorization rules at the request level using Spring Security. Specifically, we'll enforce the tasks:read scope for GET requests to the /tasks endpoint, and the tasks:write scope for POST requests to the same endpoint.

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
    return httpSecurity.authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests
                    .requestMatchers(HttpMethod.GET, "/tasks/**").hasAuthority("SCOPE_tasks:read")
                    .requestMatchers(HttpMethod.POST, "/tasks/**").hasAuthority("SCOPE_tasks:write")
                    .anyRequest().authenticated())
            .oauth2ResourceServer(oauth2ResourceServer -> oauth2ResourceServer.jwt(Customizer.withDefaults()))
            .build();
}

Restart the application and make a request to the API and check the logs:

...
Sending AnonymousAuthenticationToken [Principal=anonymousUser, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=0E7A61D2524AA6E6856C06DC0B2EC9DA], Granted Authorities=[ROLE_ANONYMOUS]] to authentication entry point since access is denied
...

You can add the following line audience(List.of("tasks-api-client")) to our CommandLineRunner:

    @Bean
    public CommandLineRunner commandLineRunner(
            @Value("classpath:private.pem") RSAPrivateKey privateKey,
            @Value("classpath:public.pem") RSAPublicKey publicKey
    ) {
        return args -> {
            RSAKey key = new RSAKey.Builder(publicKey).privateKey(privateKey).build();
            var jwtEncoder = new NimbusJwtEncoder(new ImmutableJWKSet<>(new JWKSet(key)));
            JwtClaimsSet builder = JwtClaimsSet.builder()
                    .issuedAt(Instant.now())
                    .expiresAt(Instant.now().plusSeconds(86400))
                    .subject("java")
                    .issuer("https://devlach.com")
                    .audience(List.of("tasks-api-client")) // This is the audience that we want to validate
                    .claim("scp", Arrays.asList("tasks:read", "tasks:write"))
                    .build();
            var jwtEncoded = jwtEncoder.encode(JwtEncoderParameters.from(builder));
            var consolePretty = """
                    This is a JWT token that you can use to test the API:
                    {
                     copyToClipboard: "%s"
                    }
                    """.formatted(jwtEncoded.getTokenValue());
            System.out.println(consolePretty);
        };
    }

Remember to restart the application and copy the new token.

./gradlew bootRun
export TMP_TOKEN=ey....

After that, you can make a request to the API:

HTTP/1.1 200
...
[
    {
        "completed": false,
        "description": "Learn Java",
        "id": 2,
        "owner": "java"
    }
]

But what would happen if we generate a token for our friend java with a different scope:

    @Bean
    public CommandLineRunner commandLineRunner(
            @Value("classpath:private.pem") RSAPrivateKey privateKey,
            @Value("classpath:public.pem") RSAPublicKey publicKey
    ) {
        return args -> {
                    ...
                    .claim("scp", Arrays.asList("tasks:different-scope"))
                    ...
        };
    }

Again, remember to restart the application and copy the new token.

./gradlew bootRun

You will get a 403 Forbidden response:

HTTP/1.1 403 
...

Excellent! We have successfully validated a custom claim with the Request-Level Authorization Model.

Method-Level Authorization Model

Moving on to Method-Level Authorization, activated via @EnableMethodSecurity, this approach lets you attach rules to individual methods, often in Spring MVC controllers. For instance:

    @GetMapping
    @PreAuthorize("hasAuthority('SCOPE_tasks:read')")
    public ResponseEntity<Iterable<Task>> findAll() {
        var context = SecurityContextHolder.getContext();
        var authentication = context.getAuthentication();
        var owner = authentication.getName();
        return ResponseEntity.ok(taskRepository.findAllByOwner(owner));
    }

In the above example, we are using the @PreAuthorize annotation to validate that the user has the SCOPE_tasks:read scope.

Now, we can restart our application and make a request to the API with a token that has the SCOPE_tasks:read scope and has the audience tasks-api-client:

Remember to update the line .claim("scp", Arrays.asList("tasks:different-scope")) to .claim("scp", Arrays.asList("tasks:read", "tasks:write")) in our CommandLineRunner.


```shell
./gradlew bootRun

The response should be 200:

HTTP/1.1 200
...
[
    {
        "completed": false,
        "description": "Learn Java",
        "id": 2,
        "owner": "java"
    }
]

Now, try to change the scope to SCOPE_tasks:different-scope:

    @GetMapping
    @PreAuthorize("hasAuthority('SCOPE_tasks:different-scope')") // This is the scope that we want to validate
    public ResponseEntity<Iterable<Task>> findAll() {
        var context = SecurityContextHolder.getContext();
        var authentication = context.getAuthentication();
        var owner = authentication.getName();
        return ResponseEntity.ok(taskRepository.findAllByOwner(owner));
    }

Now, we can restart our application and make a request to the API:

./gradlew bootRun

The response should be 403:

HTTP/1.1 403
...

If you check the logs, you will see the following error:

... Failed to authorize ReflectiveMethodInvocation: public org.springframework.http.ResponseEntity com.devlach.springsecurityoauth2resourceserver.TaskController.findAll(); target is of class [com.devlach.springsecurityoauth2resourceserver.TaskController] with authorization manager org.springframework.security.config.annotation.method.configuration.DeferringObservationAuthorizationManager@1267e94 and decision ExpressionAuthorizationDecision [granted=false, expressionAttribute=hasAuthority('SCOPE_tasks:x')]
... Sending JwtAuthenticationToken [Principal=org.springframework.security.oauth2.jwt.Jwt@11d6d71f, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=null], Granted Authorities=[SCOPE_tasks:read, SCOPE_tasks:write]] to access denied handler since access is denied

In essence, the Method-Level Authorization Model is a handy way to add authorization rules to individual methods.

Differences between Request-Level Authorization Model and Method-Level Authorization Model

HTTP-Agnostic Flexibility

  • Request-Level: Tied to HTTP requests and method invocations, often coinciding with Spring MVC.
  • Method-Level: Applies to any public method of a Spring-managed bean, providing HTTP-agnostic flexibility. Useful for scenarios involving non-HTTP protocols.

Request-Level Security Advantages

  • Protects Diverse Endpoints: Allows protection of non-method-invocable endpoints like static content (CSS, JS).
  • Single Declaration for Sections: Enables protecting entire sections of a website with a single declaration, simplifying complex configurations.
  • Catch-All for New Endpoints: Provides a catch-all for new endpoints introduced to the application.

Method-Level Security Advantages:

  • Fine-Grained Authorization: Well-suited for fine-grained authorization decisions based on method parameters, return values, and method signatures.
  • Secure Method Invocation and Return: Secures both the method invocation and the method return.
  • Applicability Beyond HTTP: Extends security to services and repositories decoupled from HTTP requests.

Complementary Usage

  • A non-trivial application benefits from both models used complementarily.
  • Coarse-Grained vs Fine-Grained: Request security excels at coarse-grained authorization (e.g., protecting entire sections), while method security is ideal for fine-grained decisions.

Conclusion

Building an OAuth2 Resource Server with Spring Boot is a layered process, involving setting up basic security, implementing JWT validation, and customizing authorization controls. This guide aimed to illuminate each step, providing a pathway from basic setup to advanced configurations.

All the source code of this guide is available on GitHub.

References