This is an automated email from the ASF dual-hosted git repository.
roryqi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/main by this push:
new f7489c7195 [#11581] fix: Return JSON ErrorResponse for 401/403 auth
failures in AuthenticationFilter (#11614)
f7489c7195 is described below
commit f7489c71953a96531a35ca167f0a488dbda556a2
Author: Akshay Thorat <[email protected]>
AuthorDate: Thu Jun 11 23:11:48 2026 -0700
[#11581] fix: Return JSON ErrorResponse for 401/403 auth failures in
AuthenticationFilter (#11614)
### What changes were proposed in this pull request?
`AuthenticationFilter.sendAuthErrorResponse()` now writes a Gravitino
`ErrorResponse` JSON body with `application/json` content type instead
of calling `response.sendError()`, which triggered Jetty's default HTML
error page.
Changes:
- **ErrorConstants**: Added `UNAUTHORIZED_CODE = 1011`
- **ErrorResponse**: Added `unauthorized(type, message, throwable)`
factory method
- **AuthenticationFilter**: Rewrote `sendAuthErrorResponse()` to produce
JSON for `UnauthorizedException` (401), `ForbiddenException` (403), and
generic exceptions (500)
- **TestAuthenticationFilter**: Updated existing tests to verify JSON
output; added 3 new tests for 401, 403, and 500 JSON responses
### Why are the changes needed?
`AuthenticationFilter` rejected unauthenticated requests with
`response.sendError(status, message)`, which triggers Jetty's default
HTML error page. REST clients expect a JSON error response body, causing
them to fail parsing 401/403 responses and surface opaque errors.
Fix: #11581
### Does this PR introduce _any_ user-facing change?
Yes. 401/403/500 error responses from the `AuthenticationFilter` now
return a JSON body with `Content-Type: application/json` instead of
HTML.
### How was this patch tested?
- Updated existing unit tests to assert JSON body, content type, and
status code
- Added `testUnauthorizedErrorReturnsJsonBody`,
`testForbiddenErrorReturnsJsonBody`, and
`testInternalServerErrorReturnsJsonBody`
- Verified `IcebergAuthenticationFilter` tests still pass (subclass
overrides `sendAuthErrorResponse` independently)
---
.../gravitino/dto/responses/ErrorConstants.java | 3 +
.../gravitino/dto/responses/ErrorResponse.java | 13 +++
.../authentication/AuthenticationFilter.java | 36 +++++--
.../authentication/TestAuthenticationFilter.java | 115 ++++++++++++++++++++-
4 files changed, 154 insertions(+), 13 deletions(-)
diff --git
a/common/src/main/java/org/apache/gravitino/dto/responses/ErrorConstants.java
b/common/src/main/java/org/apache/gravitino/dto/responses/ErrorConstants.java
index 4953463d74..870addfba9 100644
---
a/common/src/main/java/org/apache/gravitino/dto/responses/ErrorConstants.java
+++
b/common/src/main/java/org/apache/gravitino/dto/responses/ErrorConstants.java
@@ -54,6 +54,9 @@ public class ErrorConstants {
/** Error codes for drop an in use entity. */
public static final int IN_USE_CODE = 1010;
+ /** Error codes for unauthorized access. */
+ public static final int UNAUTHORIZED_CODE = 1011;
+
/** Error codes for invalid state. */
public static final int UNKNOWN_ERROR_CODE = 1100;
diff --git
a/common/src/main/java/org/apache/gravitino/dto/responses/ErrorResponse.java
b/common/src/main/java/org/apache/gravitino/dto/responses/ErrorResponse.java
index 10b510a581..a47d4cbb80 100644
--- a/common/src/main/java/org/apache/gravitino/dto/responses/ErrorResponse.java
+++ b/common/src/main/java/org/apache/gravitino/dto/responses/ErrorResponse.java
@@ -348,6 +348,19 @@ public class ErrorResponse extends BaseResponse {
getStackTrace(throwable));
}
+ /**
+ * Create a new unauthorized error instance of {@link ErrorResponse}.
+ *
+ * @param type The type of the error.
+ * @param message The message of the error.
+ * @param throwable The throwable that caused the error.
+ * @return The new instance.
+ */
+ public static ErrorResponse unauthorized(String type, String message,
Throwable throwable) {
+ return new ErrorResponse(
+ ErrorConstants.UNAUTHORIZED_CODE, type, message,
getStackTrace(throwable));
+ }
+
private static List<String> getStackTrace(Throwable throwable) {
if (throwable == null) {
return null;
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/authentication/AuthenticationFilter.java
b/server-common/src/main/java/org/apache/gravitino/server/authentication/AuthenticationFilter.java
index f796a107e0..e2a1ac9245 100644
---
a/server-common/src/main/java/org/apache/gravitino/server/authentication/AuthenticationFilter.java
+++
b/server-common/src/main/java/org/apache/gravitino/server/authentication/AuthenticationFilter.java
@@ -33,7 +33,10 @@ import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.gravitino.auth.AuthConstants;
+import org.apache.gravitino.dto.responses.ErrorResponse;
+import org.apache.gravitino.exceptions.ForbiddenException;
import org.apache.gravitino.exceptions.UnauthorizedException;
+import org.apache.gravitino.server.web.ObjectMapperProvider;
import org.apache.gravitino.utils.PrincipalUtils;
public class AuthenticationFilter implements Filter {
@@ -114,22 +117,35 @@ public class AuthenticationFilter implements Filter {
}
/**
- * Sends an error response when authentication fails. Subclasses can
override this to customize
- * the error response format (e.g., Iceberg REST server returns JSON error
bodies).
- *
- * <p>TODO: Gravitino server should override this method to return a correct
JSON response
- * following the Gravitino error response spec.
+ * Sends a JSON error response when authentication fails. Subclasses can
override this to
+ * customize the error response format (e.g., Iceberg REST server returns
Iceberg-specific JSON
+ * error bodies).
*
* @param response the HTTP servlet response
* @param exception the authentication exception
*/
protected void sendAuthErrorResponse(HttpServletResponse response, Exception
exception)
throws IOException {
- int status =
- exception instanceof UnauthorizedException
- ? HttpServletResponse.SC_UNAUTHORIZED
- : HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
- response.sendError(status, exception.getMessage());
+ int httpStatus;
+ ErrorResponse errorResponse;
+
+ if (exception instanceof UnauthorizedException) {
+ httpStatus = HttpServletResponse.SC_UNAUTHORIZED;
+ errorResponse =
+ ErrorResponse.unauthorized(
+ exception.getClass().getSimpleName(), exception.getMessage(),
exception);
+ } else if (exception instanceof ForbiddenException) {
+ httpStatus = HttpServletResponse.SC_FORBIDDEN;
+ errorResponse = ErrorResponse.forbidden(exception.getMessage(),
exception);
+ } else {
+ httpStatus = HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
+ errorResponse = ErrorResponse.internalError(exception.getMessage(),
exception);
+ }
+
+ response.setStatus(httpStatus);
+ response.setContentType("application/json");
+ response.setCharacterEncoding(StandardCharsets.UTF_8.name());
+ ObjectMapperProvider.objectMapper().writeValue(response.getWriter(),
errorResponse);
}
/**
diff --git
a/server-common/src/test/java/org/apache/gravitino/server/authentication/TestAuthenticationFilter.java
b/server-common/src/test/java/org/apache/gravitino/server/authentication/TestAuthenticationFilter.java
index 8c227727b4..fa8a01bc40 100644
---
a/server-common/src/test/java/org/apache/gravitino/server/authentication/TestAuthenticationFilter.java
+++
b/server-common/src/test/java/org/apache/gravitino/server/authentication/TestAuthenticationFilter.java
@@ -27,8 +27,11 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
+import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.collect.Lists;
import java.io.IOException;
+import java.io.PrintWriter;
+import java.io.StringWriter;
import java.util.Collections;
import java.util.Vector;
import javax.servlet.FilterChain;
@@ -37,7 +40,11 @@ import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.gravitino.UserPrincipal;
import org.apache.gravitino.auth.AuthConstants;
+import org.apache.gravitino.dto.responses.ErrorResponse;
+import org.apache.gravitino.exceptions.ForbiddenException;
import org.apache.gravitino.exceptions.UnauthorizedException;
+import org.apache.gravitino.server.web.ObjectMapperProvider;
+import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
public class TestAuthenticationFilter {
@@ -66,6 +73,9 @@ public class TestAuthenticationFilter {
FilterChain mockChain = mock(FilterChain.class);
HttpServletRequest mockRequest = mock(HttpServletRequest.class);
HttpServletResponse mockResponse = mock(HttpServletResponse.class);
+ StringWriter stringWriter = new StringWriter();
+ PrintWriter printWriter = new PrintWriter(stringWriter);
+ when(mockResponse.getWriter()).thenReturn(printWriter);
when(mockRequest.getHeaders(AuthConstants.HTTP_HEADER_AUTHORIZATION))
.thenReturn(new
Vector<>(Collections.singletonList("user")).elements());
when(authenticator.supportsToken(any())).thenReturn(true);
@@ -73,7 +83,17 @@ public class TestAuthenticationFilter {
when(authenticator.authenticateToken(any()))
.thenThrow(new UnauthorizedException("UNAUTHORIZED"));
filter.doFilter(mockRequest, mockResponse, mockChain);
- verify(mockResponse).sendError(HttpServletResponse.SC_UNAUTHORIZED,
"UNAUTHORIZED");
+ verify(mockResponse).setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ verify(mockResponse).setContentType("application/json");
+ verify(mockResponse).setCharacterEncoding("UTF-8");
+
+ printWriter.flush();
+ String json = stringWriter.toString();
+ ObjectMapper mapper = ObjectMapperProvider.objectMapper();
+ ErrorResponse errorResponse = mapper.readValue(json, ErrorResponse.class);
+ Assertions.assertEquals(1011, errorResponse.getCode());
+ Assertions.assertEquals("UnauthorizedException", errorResponse.getType());
+ Assertions.assertEquals("UNAUTHORIZED", errorResponse.getMessage());
}
@Test
@@ -109,6 +129,9 @@ public class TestAuthenticationFilter {
FilterChain mockChain = mock(FilterChain.class);
HttpServletRequest mockRequest = mock(HttpServletRequest.class);
HttpServletResponse mockResponse = mock(HttpServletResponse.class);
+ StringWriter stringWriter = new StringWriter();
+ PrintWriter printWriter = new PrintWriter(stringWriter);
+ when(mockResponse.getWriter()).thenReturn(printWriter);
when(mockRequest.getHeaders(AuthConstants.HTTP_HEADER_AUTHORIZATION))
.thenReturn(new
Vector<>(Collections.singletonList("user")).elements());
when(authenticator1.supportsToken(any())).thenReturn(false);
@@ -120,7 +143,16 @@ public class TestAuthenticationFilter {
.thenThrow(new UnauthorizedException("UNAUTHORIZED"));
filter.doFilter(mockRequest, mockResponse, mockChain);
- verify(mockResponse).sendError(HttpServletResponse.SC_UNAUTHORIZED,
"UNAUTHORIZED");
+ verify(mockResponse).setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ verify(mockResponse).setContentType("application/json");
+
+ printWriter.flush();
+ String json = stringWriter.toString();
+ ObjectMapper mapper = ObjectMapperProvider.objectMapper();
+ ErrorResponse errorResponse = mapper.readValue(json, ErrorResponse.class);
+ Assertions.assertEquals(1011, errorResponse.getCode());
+ Assertions.assertEquals("UnauthorizedException", errorResponse.getType());
+ Assertions.assertEquals("UNAUTHORIZED", errorResponse.getMessage());
}
@Test
@@ -169,6 +201,9 @@ public class TestAuthenticationFilter {
FilterChain mockChain = mock(FilterChain.class);
HttpServletRequest mockRequest = mock(HttpServletRequest.class);
HttpServletResponse mockResponse = mock(HttpServletResponse.class);
+ StringWriter stringWriter = new StringWriter();
+ PrintWriter printWriter = new PrintWriter(stringWriter);
+ when(mockResponse.getWriter()).thenReturn(printWriter);
when(mockRequest.getRequestURI()).thenReturn(path);
when(mockRequest.getHeaders(AuthConstants.HTTP_HEADER_AUTHORIZATION))
.thenReturn(new
Vector<>(Collections.singletonList("user")).elements());
@@ -180,7 +215,81 @@ public class TestAuthenticationFilter {
filter.doFilter(mockRequest, mockResponse, mockChain);
// Auth flow ran and rejected — proves these paths are not exempted.
- verify(mockResponse).sendError(HttpServletResponse.SC_UNAUTHORIZED,
"UNAUTHORIZED");
+ verify(mockResponse).setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ verify(mockResponse).setContentType("application/json");
}
}
+
+ @Test
+ public void testUnauthorizedErrorReturnsJsonBody() throws Exception {
+ AuthenticationFilter filter = new
AuthenticationFilter(Lists.newArrayList());
+
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ StringWriter stringWriter = new StringWriter();
+ PrintWriter printWriter = new PrintWriter(stringWriter);
+ when(response.getWriter()).thenReturn(printWriter);
+
+ filter.sendAuthErrorResponse(
+ response, new UnauthorizedException("The provided credentials did not
support"));
+
+ verify(response).setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ verify(response).setContentType("application/json");
+ verify(response).setCharacterEncoding("UTF-8");
+
+ printWriter.flush();
+ String json = stringWriter.toString();
+ ObjectMapper mapper = ObjectMapperProvider.objectMapper();
+ ErrorResponse errorResponse = mapper.readValue(json, ErrorResponse.class);
+ Assertions.assertEquals(1011, errorResponse.getCode());
+ Assertions.assertEquals("UnauthorizedException", errorResponse.getType());
+ Assertions.assertEquals("The provided credentials did not support",
errorResponse.getMessage());
+ }
+
+ @Test
+ public void testForbiddenErrorReturnsJsonBody() throws Exception {
+ AuthenticationFilter filter = new
AuthenticationFilter(Lists.newArrayList());
+
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ StringWriter stringWriter = new StringWriter();
+ PrintWriter printWriter = new PrintWriter(stringWriter);
+ when(response.getWriter()).thenReturn(printWriter);
+
+ filter.sendAuthErrorResponse(response, new ForbiddenException("Access
denied"));
+
+ verify(response).setStatus(HttpServletResponse.SC_FORBIDDEN);
+ verify(response).setContentType("application/json");
+ verify(response).setCharacterEncoding("UTF-8");
+
+ printWriter.flush();
+ String json = stringWriter.toString();
+ ObjectMapper mapper = ObjectMapperProvider.objectMapper();
+ ErrorResponse errorResponse = mapper.readValue(json, ErrorResponse.class);
+ Assertions.assertEquals(1008, errorResponse.getCode());
+ Assertions.assertEquals("ForbiddenException", errorResponse.getType());
+ Assertions.assertEquals("Access denied", errorResponse.getMessage());
+ }
+
+ @Test
+ public void testInternalServerErrorReturnsJsonBody() throws Exception {
+ AuthenticationFilter filter = new
AuthenticationFilter(Lists.newArrayList());
+
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ StringWriter stringWriter = new StringWriter();
+ PrintWriter printWriter = new PrintWriter(stringWriter);
+ when(response.getWriter()).thenReturn(printWriter);
+
+ filter.sendAuthErrorResponse(response, new RuntimeException("Something
went wrong"));
+
+ verify(response).setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+ verify(response).setContentType("application/json");
+ verify(response).setCharacterEncoding("UTF-8");
+
+ printWriter.flush();
+ String json = stringWriter.toString();
+ ObjectMapper mapper = ObjectMapperProvider.objectMapper();
+ ErrorResponse errorResponse = mapper.readValue(json, ErrorResponse.class);
+ Assertions.assertEquals(1002, errorResponse.getCode());
+ Assertions.assertEquals("RuntimeException", errorResponse.getType());
+ Assertions.assertEquals("Something went wrong",
errorResponse.getMessage());
+ }
}