- Published on
Spring Boot Guide: OAuth2 Resource Server
- Authors
- Name
- Luis Carbonel
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.
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
- Overview
- Creating an Insecure API
- Spring Security by default
- Add Spring Security to our application
- Transforming our application into an OAuth2 Resource Server
- Test our application as an OAuth2 Resource Server
- Generate a JWT token to authenticate in our application
- Authenticate in our application with the JWT token
- Accessing Authentication information in Spring MVC
- Accessing Authentication with Method Injection
- Accessing Authentication with @CurrentSecurityContext annotation
- Accessing Authentication with @AuthenticationPrincipal annotation
- Accessing Authentication with SecurityContextHolder
- Custom claims validation
- Request-Level Authorization Model
- Method-Level Authorization Model
- Differences between Request-Level Authorization Model and Method-Level Authorization Model
- Conclusion
- References
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 isjava
.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 istasks:read
andtasks:write
.iss
: The issuer of the token. In this case, the issuer ishttps://devlach.com
.alg
: The algorithm used to sign the token. In this case, the algorithm isRS256
.
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 withSCOPE_
.
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.