This is a follow-up post for Error handling for a Spring-based REST API and Protecting endpoints with Spring Security Resource Server.
In this post, we’ll discuss how to customize error handling for a REST API protected with OAuth 2 using Spring Security Resource Server. We’ll use the approach described in the post Error handling for a Spring-based REST API.
Configuring Error Handling in Spring Security
Spring Security’s Configuration DSL to configure HttpSecurity
exposes APIs to customize
- an
AuthenticationEntryPoint
to handle authentication failures, and - an
AccessDeniedHandler
to handle authorization failures.
In the case of Spring Security Resource Server, the BearerTokenAuthenticationEntryPoint
and BearerTokenAccessDeniedHandler
are the default implementations. You can override them by custom implementations, say CustomOAuth2AuthenticationEntryPoint
and CustomOAuth2AccessDeniedHandler
, using the configuration DSL as follows.
// src/main/java/dev/mflash/guides/tokenval/introspection/security/SecurityConfiguration.java
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
protected @Override void configure(HttpSecurity http) throws Exception {
.authorizeRequests()
http// other configurations
.oauth2ResourceServer()
.and()new CustomOAuth2AuthenticationEntryPoint())
.authenticationEntryPoint(new CustomOAuth2AccessDeniedHandler())
.accessDeniedHandler(
.opaqueToken();
}
// rest of the implementation
}
Status Code and Header for Authentication and Authorization failure
In case of an authentication failure, we should respond with a 401 Unauthorized status code. Similarly, in the case of authorization failure, we should return a 403 Forbidden status code.
Besides the status code, it is also customary to send a WWW-Authenticate header. This header provides the reasoning behind the failure and a method to gain access to the requested resource.
Apart from this, we want the error response in a custom JSON format described in the post Error handling for a Spring-based REST API. To achieve this, let’s implement the CustomOAuth2AuthenticationEntryPoint
and CustomOAuth2AccessDeniedHandler
by dutifully reusing (read: copying) the code from the BearerTokenAuthenticationEntryPoint
and BearerTokenAccessDeniedHandler
classes to suit our needs.
Implementing the CustomOAuth2AuthenticationEntryPoint
In this implementation, we’ll return a custom response in JSON format along with a 401 Unauthorized
status code and WWW-Authenticate
header.
public class CustomOAuth2AuthenticationEntryPoint implements AuthenticationEntryPoint {
private static final Logger logger = LoggerFactory.getLogger(CustomOAuth2AuthenticationEntryPoint.class);
private String realmName;
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e)
throws IOException {
.error(e.getLocalizedMessage(), e);
logger
HttpStatus status = HttpStatus.UNAUTHORIZED;
String errorMessage = "Insufficient authentication details";
Map<String, String> parameters = new LinkedHashMap<>();
if (Objects.nonNull(realmName)) {
.put("realm", realmName);
parameters
}
if (e instanceof OAuth2AuthenticationException) {
OAuth2Error error = ((OAuth2AuthenticationException) e).getError();
.put("error", error.getErrorCode());
parameters
if (StringUtils.hasText(error.getDescription())) {
= error.getDescription();
errorMessage .put("error_description", errorMessage);
parameters
}
if (StringUtils.hasText(error.getUri())) {
.put("error_uri", error.getUri());
parameters
}
if (error instanceof BearerTokenError) {
BearerTokenError bearerTokenError = (BearerTokenError) error;
if (StringUtils.hasText(bearerTokenError.getScope())) {
.put("scope", bearerTokenError.getScope());
parameters
}
= ((BearerTokenError) error).getHttpStatus();
status
}
}
String message = RestResponse.builder()
.status(status)"Unauthenticated")
.error(
.message(errorMessage).getRequestURI())
.path(request
.json();
String wwwAuthenticate = computeWWWAuthenticateHeaderValue(parameters);
.addHeader("WWW-Authenticate", wwwAuthenticate);
response.setStatus(status.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write(message);
response
}
}
In this implementation, we’re
- logging the error using a logger
- initializing the default status code
status
and error messageerrorMessage
- initializing a map of parameters
parameters
to generate theWWW-Authenticate
header later - putting the
realmName
if it is available in theparameters
map - putting some error-related fields on the
parameters
map if the exception occurs due to an OAuth2 mishap - creating a custom response JSON
message
, and - finally returning the
message
with theWWW-Authenticate
header.
You would notice that computeWWWAuthenticateHeaderValue
is a static method that generates the value of the WWW-Authenticate
header. Let’s implement it now, in a utility class, say WWWAuthenticateHeaderBuilder
.
Implementing the WWWAuthenticateHeaderBuilder
The MDN documentation describes the following syntax for the value of the WWW-Authenticate
header.
WWW-Authenticate: <type> realm=<realm>[, charset="UTF-8"][, error=<error_code>][, error_description=<error_description>][, error_uri=<error_uri>][, scope=<scope>]
where
<type>
is the authentication scheme. In our case, it isBearer
.realm=<realm>
is a description of where the authentication and authorization take place. This can be a description of the environment, url of the token provider, or the hostname of the server.charset=<charset>
is the preferred encoding scheme for a client to provide credentials.error=<error_code>
is a standard error code corresponding to the status codes (e.g.,invalid_token
in case of an invalid token along with a401 Unauthorized
status code)error_description=<error_description>
is a detailed message describing the nature of error.error_uri=<error_uri>
is a link to the reference documentation of the error.
You’d notice that we’ve collected these parameters in a map in the CustomOAuth2AuthenticationEntryPoint
implementation. Using this map, we can construct the WWW-Authenticate
header as follows.
public final class WWWAuthenticateHeaderBuilder {
public static String computeWWWAuthenticateHeaderValue(Map<String, String> parameters) {
StringJoiner wwwAuthenticate = new StringJoiner(", ", "Bearer ", "");
if (!parameters.isEmpty()) {
.forEach((k, v) -> wwwAuthenticate.add(k + "=\"" + v + "\""));
parameters
}
return wwwAuthenticate.toString();
}
}
Implementing the CustomOAuth2AccessDeniedHandler
Similarly, we can implement the CustomOAuth2AccessDeniedHandler
class with the difference that we now set the appropriate status code and the corresponding error code and description for authorization failure.
public class CustomOAuth2AccessDeniedHandler implements AccessDeniedHandler {
public static final Logger logger = LoggerFactory.getLogger(CustomOAuth2AccessDeniedHandler.class);
private String realmName;
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e)
throws IOException {
.error(e.getLocalizedMessage(), e);
logger
Map<String, String> parameters = new LinkedHashMap<>();
String errorMessage = e.getLocalizedMessage();
if (Objects.nonNull(realmName)) {
.put("realm", realmName);
parameters
}
if (request.getUserPrincipal() instanceof AbstractOAuth2TokenAuthenticationToken) {
= "The request requires higher privileges than provided by the access token.";
errorMessage
.put("error", "insufficient_scope");
parameters.put("error_description", errorMessage);
parameters.put("error_uri", "https://tools.ietf.org/html/rfc6750#section-3.1");
parameters
}
String message = RestResponse.builder()
HttpStatus.FORBIDDEN)
.status(
.message(errorMessage).getRequestURI())
.path(request
.json();
String wwwAuthenticate = computeWWWAuthenticateHeaderValue(parameters);
.addHeader("WWW-Authenticate", wwwAuthenticate);
response.setStatus(HttpStatus.FORBIDDEN.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write(message);
response
}
public void setRealmName(String realmName) {
this.realmName = realmName;
}
}
Testing the error handling
Launch the application and access the /private
endpoint with no token and subsequently, with an invalid token.
$ http :8080/spring-security-oidc/private
HTTP/1.1 401# other headers
{"error": "Unauthenticated",
"message": "Insufficient authentication details",
"path": "/spring-security-oidc/private",
"status": 401,
"timestamp": "2021-01-18T23:31:51.817978500"
}
'Authorization:Bearer eyJraWQiOiJxczFVSzFqWnN0OGFFYlRxOElaZ1NTaDlHd3pha3Jqa0hFcG1MeGRQblNJIiwiYWxnIjoiUlMyNTYifQ.eyJ2ZXIiOjEsImp0aSI6IkFULldpcDNYZzNQSFRSYjgwX1M0dUZPbWNSOVhVaHQxbF95TGl1QVdzOVE5SnMiLCJpc3MiOiJodHRwczovL2Rldi00MjczNDI5Lm9rdGEuY29tL29hdXRoMi9kZWZhdWx0IiwiYXVkIjoiYXBpOi8vZGVmYXVsdCIsImlhdCI6MTYwNTM3OTMzOCwiZXhwIjoxNjA1MzgyOTM4LCJjaWQiOiIwb2FybGUxY1o3bjdlc29xTzVkNSIsInNjcCI6WyJ3cml0ZTptZXNzYWdlcyJdLCJzdWIiOiIwb2FybGUxY1o3bjdlc29xTzVkNSJ9.DbVQW0lDqPWpZ8RM6FBPI4N6ey9UKb9v3oMTNMifyF9rx7hfQb8YVFGeNVHMPCkYDUfCHFQPplAo0tubVjN-Fh5xzs4y0Wai58Ju-viMGSn-lo5G5Vz8_EjH47R0OQHWz-CqFr6NPNdarKs-KK_GuFYOxoOdcCJ1rwACtKdAHz8ihG69VKncYtkfWvvIRA270Wpo7_PAtnkdAxz-LVvLIkdT9OTQOg7oFfnI7k0EJhmg9BAEzWWmxprzVgfCTSLsCBz5nfHtQdv8aD3AauvY61s0M59rMRCO37P7EN7Fd1HRN0klYm-QycVYxYpXIAVbw5KDPWtKEs0rz-mpS_y9KQ'
$ http :8080/spring-security-oidc/private
HTTP/1.1 403"invalid_token", error_description="The access token expired.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"
WWW-Authenticate: Bearer error=# other headers
{"error": "Forbidden",
"message": "The access token expired",
"path": "/spring-security-oidc/private",
"status": 403,
"timestamp": "2021-01-18T23:32:55.227262100"
}
Source code
Related
- Error handling for a Spring-based REST API
- Protecting endpoints with Spring Security Resource Server
- The OAuth 2.0 Authorization Framework: Bearer Token Usage
BearerTokenAuthenticationEntryPoint
in Spring Security Resource ServerBearerTokenAccessDeniedHandler
in Spring Security Resource Server