Discover Cloud Solutions with HostingerGet a special discount.

hostingerLearn More
Published on

Spring Security Guide: Custom Authentication Failure

Authors
  • avatar
    Name
    Luis Carbonel
    Twitter

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:

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.

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 a WWW-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 a WWW-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 the CustomAuthenticationEntryPoint 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, the commence 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.