- Published on
Spring Security Guide: Custom Authentication Failure
- Authors
- Name
- Luis Carbonel
Overview
Hi there! In this article, we're going to address a point often overlooked in development: Authentication Failure Handling. Specifically, we'll explore how to improve error handling in an OAuth 2.0 Resource Server with Spring Security.
This is not only an opportunity to deepen our technical understanding but also to enhance the interaction between our application and its end-users by ensuring that error messages are both informative and useful. By the end of this guide, you'll be equipped with the skills and knowledge needed to customize authentication error handling in your Spring Security projects, elevating the quality and security of your applications to the next level.
Introduction
Before diving into the code, if you're not familiar with the concept of an OAuth2 Resource Server, I recommend reading our article Spring Boot Guide: OAuth2 Resource Server. Where we detail how to transform your Spring Boot application into an OAuth 2.0 Resource Server. We won't start from scratch; instead, we'll use the project created in the aforementioned article as our base. Since our goal is to improve Authentication Error Handling, we'll focus on OAuth2 authentication errors, i.e., errors that occur when a client attempts to access a protected resource and does not have a valid access token.
Project Setup
Dependencies
- 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'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
runtimeOnly 'com.h2database:h2'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}
tasks.named('test') {
useJUnitPlatform()
}
Data Model
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
public interface TaskRepository extends CrudRepository<Task, Long> {
Iterable<Task> findAllByOwner(String owner);
}
Controller
package com.devlach.springsecurityoauth2resourceserver;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
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;
}
/* Method-Level Authorization */
@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));
}
}
String Security Configuration
SecurityFilterChain
package com.devlach.springsecurityoauth2resourceserver;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@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();
}
}
Application Properties
spring:
security:
oauth2:
resourceserver:
jwt:
public-key-location: classpath:public.pem
audiences: tasks-api-client
To obtain the public.pem file, you can use the following command:
OpenSSL
Generate a key pair using- 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
.
Schema and Data
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');
Generate a JWT token to authenticate in our application
@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"))
.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.
An Valid JWT Token
If we start our application, we should see a message like this in the console:
This is a JWT token that you can use to test the API:
{
copyToClipboard: "eyJhbGciOiJSUzI1NiJ9.....rest of the token"
}
Go to jwt.io and paste the token to see its content. You should see something like this:
{
"sub": "java",
"aud": "tasks-api-client",
"scp": [
"tasks:read",
"tasks:write"
],
"iss": "https://devlach.com",
"exp": 1709131286,
"iat": 1709044886
}
Now, we set the token in an environment variable to use it in the tests.
```shell
export VALID_JWT_TOKEN="eyJhbGciOiJSUzI1NiJ9.....rest of the token"
Now, let's test the /tasks
endpoint with the token we just generated.
In my case, I use httpie to do the tests, but you can use any http client you prefer.
http :8080/tasks "Authorization: Bearer VALID_JWT_TOKEN"
If everything is working, you should see the tasks that belong to the user java
.
HTTP/1.1 200
...
[
{
"completed": false,
"description": "Learn Java",
"id": 2,
"owner": "java"
}
]
Everything is going wonderfully so far, right? But what happens if we try to access the /tasks
endpoint without a valid token? In my case, I'm going to generate an expired token with an invalid audience.
Change the following lines in the commandLineRunner
method:
...
JwtClaimsSet builder = JwtClaimsSet.builder()
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusMillis(400)) // The Token will expire in 400 milliseconds
.subject("java")
.issuer("https://devlach.com")
.audience(List.of("invalid-audience")) // Invalid audience
.claim("scp", Arrays.asList("tasks:read", "tasks:write"))
.build();
...
The token will expire in 400 milliseconds and have an invalid audience. Now, let's test the /tasks
endpoint with this token.
- Current Invalid Token Payload
{
"sub": "java",
"aud": "invalid-audience",
"scp": [
"tasks:read",
"tasks:write"
],
"iss": "https://devlach.com",
"exp": 1709051101,
"iat": 1709051100
}
First, we export the token to an environment variable.
export INVALID_JWT_TOKEN="eyJhbGciOiJSUzI1NiJ9.....rest of the token"
Now, let's test the /tasks
endpoint with the token we just generated. What do you think will happen? Guess.
Remember to restart the application so that the changes take effect.
Invoking Authorization Errors
http :8080/tasks "Authorization: Bearer $INVALID_JWT_TOKEN"
You should see an error message like this:
HTTP/1.1 401
...
WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Jwt expired at 2024-02-27T16:25:01Z", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"
...
Well, we did it, we broke everything 🤣.
Take a look at the terminal where the application is running, you'll see a message like this:
org.springframework.security.oauth2.server.resource.InvalidBearerTokenException: An error occurred while attempting to decode the Jwt: Jwt expired at 2024-02-27T16:25:01Z
Caused by: org.springframework.security.oauth2.jwt.JwtValidationException: An error occurred while attempting to decode the Jwt: Jwt expired at 2024-02-27T16:25:01Z
What I want you to notice is that the response is correct and gives us a clue about what is wrong, which in this case is that the token has expired. But what about the invalid audience? Why is it not mentioned in the response? Why not have both pieces of information in the response? In my case, I would like something like this:
{
"type": "https://tools.ietf.org/html/rfc6750#section-3.1",
"title": "Invalid Token",
"status": 401,
"errors": [
{"errorCode": "invalid_token", "description": "Jwt expired at <DATE HERE>", "uri": "https://tools.ietf.org/html/rfc6750#section-3.1"},
{"errorCode": "invalid_token", "description": "The aud claim is not valid", "uri": "https://tools.ietf.org/html/rfc6750#section-3.1"}
]
}
This is a more informative response, and it would be helpful for the client to understand what is wrong with the token.
Now that we have a clear understanding of the problem, let's move on to the solution.
Processing Authentication Failures
If you're familiar with AuthenticationEntryPoint
, you can skip this explanation. However, if you're not, here's a brief overview.
Consider a common scenario where a client attempts to access a protected resource without being logged in. Spring Security handles this situation differently based on the client type:
- For a browser client, Spring Security redirects the user to a login page.
- For an HTTP Basic client, Spring Security responds with a
401 Unauthorized
and aWWW-Authenticate Basic
header. This indicates that the client should send an Authorization header with the credentials. - For a Bearer Token client, Spring Security responds with a
401 Unauthorized
and aWWW-Authenticate Bearer
header. This indicates that the client should send an Authorization header with a valid token.
Spring Security uses different AuthenticationEntryPoint
instances to handle each scenario:
- LoginUrlAuthenticationEntryPoint for browser clients.
- BasicAuthenticationEntryPoint for HTTP Basic clients.
- BearerTokenAuthenticationEntryPoint for Bearer Token clients.
We're interested in BearerTokenAuthenticationEntryPoint
because we're handling access tokens. Spring Security provides a default BearerTokenAuthenticationEntryPoint
that manages the response for us.
The problem we're addressing is the lack of sufficient information in the response when the token is invalid.
Customizing Error Handling
The first thing we need to do is implement a custom AuthenticationEntryPoint
to handle the response when a JwtValidationException
occurs. To do this, we'll create a class called CustomAuthenticationEntryPoint
that extends AuthenticationEntryPoint
and override the commence
method to customize the response.
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
// Your custom logic here
}
}
You might be wondering why we don't just extend the BearerTokenAuthenticationEntryPoint
class and override the commence
method to customize the response. The problem is that the BearerTokenAuthenticationEntryPoint
class is final, which means we can't extend it. But don't worry, we can use composition instead of inheritance.
Customizing the Response
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final AuthenticationEntryPoint delegate = new BearerTokenAuthenticationEntryPoint();
private final ObjectMapper mapper;
public CustomAuthenticationEntryPoint(ObjectMapper mapper) {
this.mapper = mapper;
}
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
this.delegate.commence(request, response, authException);
if(authException.getCause() instanceof JwtValidationException validationException) {
var problemDetail = ProblemDetail.forStatus(HttpStatus.UNAUTHORIZED);
problemDetail.setType(URI.create("https://tools.ietf.org/html/rfc6750#section-3.1"));
problemDetail.setProperty("errors", validationException.getErrors());
problemDetail.setTitle("Invalid Token");
mapper.writeValue(response.getWriter(), problemDetail);
}
}
}
Let's break down the code:
- We use the
@Component
annotation to register theCustomAuthenticationEntryPoint
as a Spring bean.
Composition over Inheritance
We declare a delegate of type AuthenticationEntryPoint
, specifically BearerTokenAuthenticationEntryPoint
. This delegate handles the default logic for authentication errors related to Bearer tokens.
private final AuthenticationEntryPoint delegate = new BearerTokenAuthenticationEntryPoint();
Using ObjectMapper for Custom Responses
- We use
ObjectMapper
to convert objects to JSON. This allows us to send custom responses in JSON format to clients.
private final ObjectMapper mapper;
Constructor with ObjectMapper Injection
- The constructor of the class allows us to inject ObjectMapper, ensuring that we use the same instance configured globally in our application.
public CustomAuthenticationEntryPoint(ObjectMapper mapper) {
this.mapper = mapper;
}
Overriding the commence Method
- The
commence
method is overridden to customize the authentication error handling process. Initially, thecommence
method of the delegate is invoked to execute its default logic.
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
this.delegate.commence(request, response, authException);
Detecting JwtValidationException
- Specifically, we look for JwtValidationException to identify issues with JWT validation. This allows us to handle JWT-specific errors more effectively.
if(authException.getCause() instanceof JwtValidationException validationException) {}
Thanks to Java 16, we can use the pattern matching to destructure the exception and access its properties.
Building the Custom Response with ProblemDetail
- Inside the if block, we construct a
ProblemDetail
object to represent the error in a standardized way. This object includes details such as the HTTP status, the error type (with a URI pointing to the relevant specification), and a descriptive title. In addition, we add the specific errors from the JWT validation.
var problemDetail = ProblemDetail.forStatus(HttpStatus.UNAUTHORIZED);
problemDetail.setType(URI.create("https://tools.ietf.org/html/rfc6750#section-3.1"));
problemDetail.setProperty("errors", validationException.getErrors());
problemDetail.setTitle("Invalid Token");
mapper.writeValue(response.getWriter(), problemDetail);
}
This detailed and customized approach significantly improves the development experience and security by providing clear and specific responses about authentication errors.
Testing the Custom AuthenticationEntryPoint
Now that we've implemented the CustomAuthenticationEntryPoint
, let's test it by invoking the /tasks
endpoint with the INVALID_JWT_TOKEN.
http :8080/tasks "Authorization: Bearer $INVALID_JWT_TOKEN"
You should see a response like this:
HTTP/1.1 401
...
WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Jwt expired at 2024-02-27T16:25:01Z", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"
...
The response is the same as before, nothing has changed. What's going on? Why isn't the CustomAuthenticationEntryPoint
being invoked?
The problem is that the CustomAuthenticationEntryPoint
is not being used by Spring Security. We need to add it to the SecurityFilterChain
.
Adding the CustomAuthenticationEntryPoint to the SecurityFilterChain
We need to add the CustomAuthenticationEntryPoint
to the SecurityFilterChain
. To do this, we'll modify the SecurityConfig
class to include the CustomAuthenticationEntryPoint
in the SecurityFilterChain
.
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity, CustomAuthenticationEntryPoint entryPoint) 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
.authenticationEntryPoint(entryPoint) // Add the CustomAuthenticationEntryPoint
.jwt(Customizer.withDefaults()))
.build();
}
}
Now, let's test the /tasks
endpoint with the INVALID_JWT_TOKEN
again. Don't forget to restart the application so that the changes take effect.
http :8080/tasks "Authorization: Bearer $INVALID_JWT_TOKEN" | jq
Add | jq
to format the output. If you don't have jq
installed, you can omit it. If you're on a Mac, you can install it with:
brew install jq
You should see a response like this:
{
"type": "https://tools.ietf.org/html/rfc6750#section-3.1",
"title": "Invalid Token",
"status": 401,
"errors": [
{
"errorCode": "invalid_token",
"description": "Jwt expired at 2024-02-27T16:25:01Z",
"uri": "https://tools.ietf.org/html/rfc6750#section-3.1"
},
{
"errorCode": "invalid_token",
"description": "The aud claim is not valid",
"uri": "https://tools.ietf.org/html/rfc6750#section-3.1"
}
]
}
Excellent! 🎉 Now we have a more informative and useful response for the client.
Conclusion
In this guide, we've learned how to customize error handling in a Spring Security OAuth 2.0 Resource Server. We started by identifying the problem with the default error handling and then implemented a custom AuthenticationEntryPoint
to address it. We used composition to extend the default behavior and provide a more informative response when a JwtValidationException
occurs. Finally, we added the CustomAuthenticationEntryPoint
to the SecurityFilterChain
to ensure that it's used by Spring Security.
All the source code of this guide is available on GitHub.