Defining error format is important part of REST API design.
Spring-Boot and Spring Security provide pretty nice error handling for RESTful APIs out of the box. Although it has to be documented, especially when contract-first approach to API design is used.
It is good idea to follow some common format for error responses. But OAuth2 specification and Spring Boot format may not satisfy those requirements.
By default, Spring Boot returns errors messages like this:
1{
2 "timestamp": "2018-06-27T16:36:47.390+0000",
3 "status": 404,
4 "error": "Not Found",
5 "message": "Not Found",
6 "path": "/service/v1/user/1d28c5cb-54fe-4f75-9af0-37fd611d0ece"
7}The format of this response is defined by the DefaultErrorAttributes class.
It make sense to adopt your “custom” error message format to this one just to save make your life easier.
There are few possible ways to define your custom error response:
You may use
@ControllerAdviceto create a single global error handling component:java1@ExceptionHandler(ServiceException.class) 2@ResponseBody 3public ErrorResponse handleServiceException(HttpServletRequest req, HttpServletResponse response, ServiceException e) 4{ 5 ErrorResponse error = new ErrorResponse(); 6 error.setResponseMessage(e.getMessage()); 7 //Set custom non standard http status code 8 response.setStatus(499); 9 return error; 10}You may hide exception from
DefaultErrorAttributesby clearing a request attribute:java1@ExceptionHandler(IllegalArgumentException.class) 2void handleIllegalArgumentException(HttpServletRequest request, HttpServletResponse response) throws IOException { 3 request.setAttribute(DefaultErrorAttributes.class.getName() + ".ERROR", null); 4 response.sendError(HttpStatus.BAD_REQUEST.value(), "custom message"); 5}You may also provide your own
ErrorAttributesimplementation to get full control: on error payload:java1@Bean 2public ErrorAttributes errorAttributes() { 3 return new DefaultErrorAttributes() { 4 5 @Override 6 public Map<String, Object> getErrorAttributes( 7 RequestAttributes requestAttributes, 8 boolean includeStackTrace) { 9 Map<String, Object> errorAttributes = super.getErrorAttributes(requestAttributes, includeStackTrace); 10 Object errorMessage = requestAttributes.getAttribute(RequestDispatcher.ERROR_MESSAGE, RequestAttributes.SCOPE_REQUEST); 11 if (errorMessage != null) { 12 errorAttributes.put("message", errorMessage); 13 } 14 return errorAttributes; 15 } 16 17 }; 18}
This may work for you…Unless you’re using Spring-Security-OAuth2.
Spring-Security-OAuth2
Spring-Security-OAuth2 implements resource server specification according to OAuth 2.0 (RFC6749) section 7.2
If a resource access request fails, the resource server SHOULD inform the client of the error. While the specifics of such error responses are beyond the scope of this specification, this document establishes a common registry in Section 11.4 for error values to be shared among OAuth token authentication schemes.
New authentication schemes designed primarily for OAuth token authentication SHOULD define a mechanism for providing an error status code to the client, in which the error values allowed are registered in the error registry established by this specification.
Such schemes MAY limit the set of valid error codes to a subset of the registered values. If the error code is returned using a named parameter, the parameter name SHOULD be “error”.
Other schemes capable of being used for OAuth token authentication, but not primarily designed for that purpose, MAY bind their error values to the registry in the same manner.
New authentication schemes MAY choose to also specify the use of the “error_description” and “error_uri” parameters to return error information in a manner parallel to their usage in this specification.
So error response will look like:
HTTP/1.1 400 Bad Request
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store
Pragma: no-cache
{
"error":"invalid_request",
"error_description":"..."
}
Fragment of OpenAPI 3 definition will look like this (openapi.yaml):
1components:
2
3 responses:
4 ...
5 401Unauthorized:
6 description: Authorization required
7 schema:
8 $ref: '#/definitions/OAuth2ErrorResponse'
9 403Forbidden:
10 description: Access is denied
11 schema:
12 $ref: '#/definitions/OAuth2ErrorResponse'
13
14 schema:
15 OAuth2ErrorResponse:
16 description: |-
17 Spring-Security-OAuth2 implements resource server specification according to
18 [RFC6749 section 7.2](https://tools.ietf.org/html/rfc6749#section-7.2)
19 properties:
20 error:
21 description: |-
22 A single ASCII (USASCII) error code from the list
23 type: string
24 enum:
25 - invalid_request
26 - invalid_client
27 - invalid_grant
28 - unauthorized_client
29 - unsupported_grant_type
30 - invalid_scope
31 - insufficient_scope
32 - invalid_token
33 - redirect_uri_mismatch
34 - unsupported_response_type
35 - access_denied
36 error_description:
37 description: |-
38 Human-readable ASCII (USASCII) text providing
39 additional information, used to assist the client developer in
40 understanding the error that occurred.
41 type: string
42 pattern: "[\x20-\x7E|\x23-\x5B|\x5D-\x7E]+"
43 error_uri:
44 description: |-
45 A URI identifying a human-readable web page with
46 information about the error, used to provide the client
47 developer with additional information about the error.
48 type: string
49 pattern: "[\x20-\x7E|\x23-\x5B|\x5D-\x7E]+"
50 externalDocs:
51 description: OAuth 2.0 (RFC6749) Section 7.2
52 url: https://tools.ietf.org/html/rfc6749#section-7.2If you’re using swagger-codegen-plugin,
it make sense to define import mapping for this type (pom.xml):
1<build>
2 <plugins>
3 ...
4 <plugin>
5 <groupId>io.swagger</groupId>
6 <artifactId>swagger-codegen-maven-plugin</artifactId>
7 <executions>
8 <execution>
9 <id>generate-java-api</id>
10 <phase>generate-sources</phase>
11 <goals>
12 <goal>generate</goal>
13 </goals>
14 <configuration>
15 ...
16 <language>spring</language>
17 <configOptions>
18 ...
19 <useBeanValidation>true</useBeanValidation>
20 <java8>true</java8>
21 <interfaceOnly>true</interfaceOnly>
22 </configOptions>
23 <importMappings>
24 <importMapping>OAuth2ErrorResponse=org.springframework.security.oauth2.common.exceptions.OAuth2Exception
25 </importMapping>
26 </importMappings>
27 </configuration>
28 </execution>
29 </executions>
30 </plugin>
31 ...
32 </plugins>
33</build>If you want to describe common error response in swagger, in some cases, you can not differentiate error thrown by spring-security-oauth2 from errors thrown by controller.
E.g. for access_denied case: the reason could be declarative security (@PreAuthorize), custom business logic
in your service (thus security exception is thrown by your code) or oauth2-related exception which is thrown
at the higher level.
This is because spring-security-oauth2 has a different class for error response: OAuth2Exception.
But you may still tune error response by adding some additional fields. You may define some common set of fields that are present in all error responses, thus define a consistent contract for API consumers.
Customizing Error Response for OAuth2
The class
DefaultWebResponseExceptionTranslator
translates thrown exceptions (e.g. InsufficientAuthenticationException)
to OAuth2Exception.
You may add some additional information to error response by customizing OAuth2Exception.additionalFields.
You have to use your own WebResponseExceptionTranslator instead of default one.
In case of resource server you may inject your ExceptionTranslator (OAuth2ResourceServerConfiguration.kt):
1import org.springframework.context.annotation.Configuration
2import org.springframework.security.config.annotation.web.builders.HttpSecurity
3import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer
4import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter
5import org.springframework.security.oauth2.config.annotation.web.configurers.ResourceServerSecurityConfigurer
6import org.springframework.security.oauth2.provider.error.OAuth2AccessDeniedHandler
7import org.springframework.security.oauth2.provider.error.OAuth2AuthenticationEntryPoint
8
9@Configuration
10@EnableResourceServer
11class OAuth2ResourceServerConfiguration(
12 private val exceptionTranslator: CustomWebResponseExceptionTranslator
13) : ResourceServerConfigurerAdapter() {
14
15 override fun configure(resources: ResourceServerSecurityConfigurer) {
16 val authenticationEntryPoint = OAuth2AuthenticationEntryPoint()
17 authenticationEntryPoint.setExceptionTranslator(exceptionTranslator)
18 resources.authenticationEntryPoint(authenticationEntryPoint)
19
20 val accessDeniedHandler = OAuth2AccessDeniedHandler()
21 accessDeniedHandler.setExceptionTranslator(exceptionTranslator)
22 resources.accessDeniedHandler(accessDeniedHandler)
23 }
24}If you’re hacking Authorization server - then solution is:
1@Configuration
2@EnableAuthorizationServer
3protected static class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
4
5 @Autowired
6 CustomWebResponseExceptionTranslator exceptionTranslator;
7
8 @Override
9 public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
10 ...
11 endpoints.exceptionTranslator(exceptionTranslator);
12 }
13}…and then hack a OAuth2Exception before marshalling (CustomWebResponseExceptionTranslator.kt):
1import org.springframework.http.ResponseEntity
2import org.springframework.security.oauth2.common.exceptions.OAuth2Exception
3import org.springframework.security.oauth2.provider.error.DefaultWebResponseExceptionTranslator
4import org.springframework.stereotype.Component
5import java.lang.Exception
6import java.time.Clock
7import java.time.ZoneId
8import java.time.format.DateTimeFormatter
9
10@Component
11class CustomWebResponseExceptionTranslator(private val clock: Clock) : DefaultWebResponseExceptionTranslator() {
12
13 private val dateTimeFormat = DateTimeFormatter.ISO_OFFSET_DATE_TIME.withZone(ZoneId.of("UTC"))
14
15 override fun translate(e: Exception): ResponseEntity<OAuth2Exception> {
16 return with(super.translate(e)) {
17 body?.let {
18 it.addAdditionalInformation("timestamp", dateTimeFormat.format(clock.instant()))
19 it.addAdditionalInformation("status", it.httpErrorCode.toString())
20 it.addAdditionalInformation("message", it.message)
21 it.addAdditionalInformation("code", it.oAuth2ErrorCode.toUpperCase())
22 }
23 this
24 }
25 }
26}Error response will now have an extra fields:
1{
2 "error": "unauthorized",
3 "error_description": "Full authentication is required to access this resource",
4 "code": "UNAUTHORIZED",
5 "message": "Full authentication is required to access this resource",
6 "status": "401",
7 "timestamp": "2018-06-28T23:55:28.86Z"
8}Now you may describe a generic error message in your swagger file with required fields having both "error" field
(which is required by OAuth 2.0 specification)
and some other fields, e.g. "timestamp", "code", and "message".