diff --git a/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/JWTFederationFilter.java b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/JWTFederationFilter.java index 2c1af9835..255c3076c 100644 --- a/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/JWTFederationFilter.java +++ b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/JWTFederationFilter.java @@ -34,6 +34,7 @@ import org.apache.knox.gateway.util.CookieUtils; import org.apache.knox.gateway.util.ServletRequestUtils; import org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants; +import org.apache.knox.gateway.util.knoxidf.KnoxIDFUtils; import javax.security.auth.Subject; import javax.servlet.FilterChain; @@ -553,6 +554,10 @@ protected Set resolveRegisteredIssuerJwks(String issuer, HttpServletRequest @Override protected void handleValidationError(HttpServletRequest request, HttpServletResponse response, int status, String error) throws IOException { + if (Boolean.TRUE.equals(request.getAttribute(TOKEN_EXCHANGE_REQUEST_ATTR))) { + handleValidationError(request, response, status, "invalid_request", error); + return; + } if (error != null) { response.sendError(status, error); } @@ -561,6 +566,17 @@ protected void handleValidationError(HttpServletRequest request, HttpServletResp } } + /** + * Emit an RFC 8693 / RFC 6749 §5.2 JSON error response ({@code {"error": ..., "error_description": + * ...}}) for a token-exchange request. Called directly by {@link TokenExchangeHandler} when it has + * an explicit OAuth error code (e.g. {@code invalid_request}), and indirectly by the four-argument + * {@link #handleValidationError} for shared-path errors. + */ + void handleValidationError(HttpServletRequest request, HttpServletResponse response, int status, + String error, String description) throws IOException { + KnoxIDFUtils.writeErrorResponse(response, status, error, description); + } + /** * A function that let's configured unauthenticated path requests to * pass through without requiring authentication. diff --git a/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandler.java b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandler.java index 9fe7558f0..2810c4af0 100644 --- a/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandler.java +++ b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandler.java @@ -92,35 +92,35 @@ void handle(HttpServletRequest request, HttpServletResponse response, FilterChai // RFC 8693 section 2.1: subject_token and subject_token_type are REQUIRED. if (subjectTokenValue == null || subjectTokenValue.isEmpty()) { filter.handleValidationError(request, response, HttpServletResponse.SC_BAD_REQUEST, - "invalid_request: the subject_token parameter is required"); + "invalid_request", "the subject_token parameter is required"); return; } if (subjectTokenType == null || subjectTokenType.isEmpty()) { filter.handleValidationError(request, response, HttpServletResponse.SC_BAD_REQUEST, - "invalid_request: the subject_token_type parameter is required"); + "invalid_request", "the subject_token_type parameter is required"); return; } // RFC 8693 section 2.1: actor_token_type is REQUIRED when actor_token is present and MUST NOT // be present otherwise. if (hasActorToken && !hasActorTokenType) { filter.handleValidationError(request, response, HttpServletResponse.SC_BAD_REQUEST, - "invalid_request: actor_token_type is required when actor_token is present"); + "invalid_request", "actor_token_type is required when actor_token is present"); return; } if (!hasActorToken && hasActorTokenType) { filter.handleValidationError(request, response, HttpServletResponse.SC_BAD_REQUEST, - "invalid_request: actor_token_type must not be present without actor_token"); + "invalid_request", "actor_token_type must not be present without actor_token"); return; } // Only JWT-family token types are supported. if (isNotSupportedTokenType(subjectTokenType)) { filter.handleValidationError(request, response, HttpServletResponse.SC_BAD_REQUEST, - "unsupported_token_type: unsupported subject_token_type " + subjectTokenType); + "invalid_request", "unsupported subject_token_type " + subjectTokenType); return; } if (hasActorToken && isNotSupportedTokenType(actorTokenType)) { filter.handleValidationError(request, response, HttpServletResponse.SC_BAD_REQUEST, - "unsupported_token_type: unsupported actor_token_type " + actorTokenType); + "invalid_request", "unsupported actor_token_type " + actorTokenType); return; } @@ -148,7 +148,7 @@ void handle(HttpServletRequest request, HttpServletResponse response, FilterChai filter.continueWithEstablishedSecurityContext(subject, request, response, chain); } catch (ParseException | UnknownTokenException e) { filter.handleValidationError(request, response, HttpServletResponse.SC_UNAUTHORIZED, - "Failed to parse token in token exchange: " + e.getMessage()); + "invalid_request", "Failed to parse token in token exchange: " + e.getMessage()); } } diff --git a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/JWTFederationFilterTokenExchangeTest.java b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/JWTFederationFilterTokenExchangeTest.java index 81e6249c8..b60fbb30f 100644 --- a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/JWTFederationFilterTokenExchangeTest.java +++ b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/JWTFederationFilterTokenExchangeTest.java @@ -37,6 +37,9 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequestWrapper; import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; import java.net.URI; import java.util.Date; import java.util.HashMap; @@ -235,16 +238,16 @@ public void testSignatureVerificationFails() throws Exception { final HttpServletRequest request = buildTokenExchangeRequest( subjectJwt.serialize(), buildContextWithIssuerService(issuerSvc)); - final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class); - response.sendError(HttpServletResponse.SC_UNAUTHORIZED); - EasyMock.expectLastCall().once(); - EasyMock.replay(request, response, issuerSvc); + final JsonErrorResponse response = new JsonErrorResponse(); + EasyMock.replay(request, response.mock, issuerSvc); final TestFilterChain chain = new TestFilterChain(); - handler.doFilter(request, response, chain); + handler.doFilter(request, response.mock, chain); Assert.assertFalse(chain.doFilterCalled); - EasyMock.verify(mockAuth, issuerSvc, response); + Assert.assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.status()); + Assert.assertTrue(response.body(), response.body().contains("\"error\":\"invalid_request\"")); + EasyMock.verify(mockAuth, issuerSvc); } /** @@ -278,19 +281,20 @@ public void testExpiredTokenRejectedOnDynamicPath() throws Exception { final HttpServletRequest request = buildTokenExchangeRequest( expiredJwt.serialize(), buildContextWithIssuerService(issuerSvc)); - final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class); - response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Token has expired"); - EasyMock.expectLastCall().once(); - EasyMock.replay(request, response, issuerSvc); + final JsonErrorResponse response = new JsonErrorResponse(); + EasyMock.replay(request, response.mock, issuerSvc); final TestFilterChain chain = new TestFilterChain(); - handler.doFilter(request, response, chain); + handler.doFilter(request, response.mock, chain); Assert.assertFalse(chain.doFilterCalled); if (capturedJwt.hasCaptured()) { Assert.assertEquals(EXTERNAL_ISSUER, capturedJwt.getValue().getIssuer()); } - EasyMock.verify(mockAuth, issuerSvc, response); + Assert.assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.status()); + Assert.assertTrue(response.body(), response.body().contains("\"error\":\"invalid_request\"")); + Assert.assertTrue(response.body(), response.body().contains("Token has expired")); + EasyMock.verify(mockAuth, issuerSvc); } /** @@ -324,19 +328,20 @@ public void testFutureNbfRejectedOnDynamicPath() throws Exception { final HttpServletRequest request = buildTokenExchangeRequest( nbfJwt.serialize(), buildContextWithIssuerService(issuerSvc)); - final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class); - response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Bad request: the NotBefore check failed"); - EasyMock.expectLastCall().once(); - EasyMock.replay(request, response, issuerSvc); + final JsonErrorResponse response = new JsonErrorResponse(); + EasyMock.replay(request, response.mock, issuerSvc); final TestFilterChain chain = new TestFilterChain(); - handler.doFilter(request, response, chain); + handler.doFilter(request, response.mock, chain); Assert.assertFalse(chain.doFilterCalled); if (capturedJwt.hasCaptured()) { Assert.assertEquals(EXTERNAL_ISSUER, capturedJwt.getValue().getIssuer()); } - EasyMock.verify(mockAuth, issuerSvc, response); + Assert.assertEquals(HttpServletResponse.SC_BAD_REQUEST, response.status()); + Assert.assertTrue(response.body(), response.body().contains("\"error\":\"invalid_request\"")); + Assert.assertTrue(response.body(), response.body().contains("the NotBefore check failed")); + EasyMock.verify(mockAuth, issuerSvc); } /** @@ -371,19 +376,20 @@ public void testAudienceMismatchRejectedOnDynamicPath() throws Exception { final HttpServletRequest request = buildTokenExchangeRequest( subjectJwt.serialize(), buildContextWithIssuerService(issuerSvc)); - final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class); - response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Bad request: missing required token audience"); - EasyMock.expectLastCall().once(); - EasyMock.replay(request, response, issuerSvc); + final JsonErrorResponse response = new JsonErrorResponse(); + EasyMock.replay(request, response.mock, issuerSvc); final TestFilterChain chain = new TestFilterChain(); - handler.doFilter(request, response, chain); + handler.doFilter(request, response.mock, chain); Assert.assertFalse(chain.doFilterCalled); if (capturedJwt.hasCaptured()) { Assert.assertEquals(EXTERNAL_ISSUER, capturedJwt.getValue().getIssuer()); } - EasyMock.verify(mockAuth, issuerSvc, response); + Assert.assertEquals(HttpServletResponse.SC_BAD_REQUEST, response.status()); + Assert.assertTrue(response.body(), response.body().contains("\"error\":\"invalid_request\"")); + Assert.assertTrue(response.body(), response.body().contains("missing required token audience")); + EasyMock.verify(mockAuth, issuerSvc); } // --------------------------------------------------------------------------- @@ -408,16 +414,16 @@ public void testUntrustedIssuerRejectedNoHttpCall() throws Exception { final HttpServletRequest request = buildTokenExchangeRequest( subjectJwt.serialize(), buildContextWithIssuerService(issuerSvc)); - final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class); - response.sendError(HttpServletResponse.SC_UNAUTHORIZED); - EasyMock.expectLastCall().once(); - EasyMock.replay(request, response, issuerSvc); + final JsonErrorResponse response = new JsonErrorResponse(); + EasyMock.replay(request, response.mock, issuerSvc); final TestFilterChain chain = new TestFilterChain(); - handler.doFilter(request, response, chain); + handler.doFilter(request, response.mock, chain); Assert.assertFalse(chain.doFilterCalled); - EasyMock.verify(issuerSvc, response); + Assert.assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.status()); + Assert.assertTrue(response.body(), response.body().contains("\"error\":\"invalid_request\"")); + EasyMock.verify(issuerSvc); } /** @@ -437,16 +443,15 @@ public void testServiceUnavailable() throws Exception { final HttpServletRequest request = buildTokenExchangeRequest( subjectJwt.serialize(), buildServletContext(gws)); - final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class); - response.sendError(HttpServletResponse.SC_UNAUTHORIZED); - EasyMock.expectLastCall().once(); - EasyMock.replay(request, response); + final JsonErrorResponse response = new JsonErrorResponse(); + EasyMock.replay(request, response.mock); final TestFilterChain chain = new TestFilterChain(); - handler.doFilter(request, response, chain); + handler.doFilter(request, response.mock, chain); Assert.assertFalse(chain.doFilterCalled); - EasyMock.verify(response); + Assert.assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.status()); + Assert.assertTrue(response.body(), response.body().contains("\"error\":\"invalid_request\"")); } /** @@ -550,13 +555,15 @@ public void testInsecureDynamicJwksUriRejectedByDefault() throws Exception { final HttpServletRequest request = buildTokenExchangeRequest( subjectJwt.serialize(), buildContextWithIssuerService(issuerSvc)); - final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class); - EasyMock.replay(request, response, issuerSvc); + final JsonErrorResponse response = new JsonErrorResponse(); + EasyMock.replay(request, response.mock, issuerSvc); final TestFilterChain chain = new TestFilterChain(); - handler.doFilter(request, response, chain); + handler.doFilter(request, response.mock, chain); Assert.assertFalse("Insecure (non-HTTPS) dynamic JWKS URI must be rejected OOTB", chain.doFilterCalled); + Assert.assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.status()); + Assert.assertTrue(response.body(), response.body().contains("\"error\":\"invalid_request\"")); EasyMock.verify(mockAuth, issuerSvc); } @@ -810,6 +817,33 @@ public Object getAttribute(String name) { Assert.assertEquals(KNOX_ISSUER, capturedAttrs.get(KnoxIDFConstants.TOKEN_ISS_ATTRIBUTE)); } + @Test + public void testTokenExchangeParamErrorEmitsRfcJsonError() throws Exception { + handler.init(new TestFilterConfig(getProperties())); + + // grant_type marks a token-exchange dispatch, but subject_token is absent (niceMock returns null). + final HttpServletRequest request = EasyMock.createNiceMock(HttpServletRequest.class); + EasyMock.expect(request.getRequestURL()).andReturn(new StringBuffer(SERVICE_URL)).anyTimes(); + EasyMock.expect(request.getParameter(GRANT_TYPE)).andReturn(JWTFederationFilter.TOKEN_EXCHANGE).anyTimes(); + EasyMock.expect(request.getServletContext()) + .andReturn(buildContextWithIssuerService(EasyMock.createNiceMock(TrustedOidcIssuerService.class))).anyTimes(); + mockRequestAttributeStore(request); + + final JsonErrorResponse response = new JsonErrorResponse(); + EasyMock.replay(request, response.mock); + + final TestFilterChain chain = new TestFilterChain(); + handler.doFilter(request, response.mock, chain); + + Assert.assertFalse(chain.doFilterCalled); + Assert.assertEquals(HttpServletResponse.SC_BAD_REQUEST, response.status()); + Assert.assertEquals("application/json; charset=UTF-8", response.contentType()); + Assert.assertEquals("no-store", response.header("Cache-Control")); + Assert.assertEquals("no-cache", response.header("Pragma")); + Assert.assertTrue(response.body(), response.body().contains("\"error\":\"invalid_request\"")); + Assert.assertTrue(response.body(), response.body().contains("subject_token")); + } + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -876,4 +910,42 @@ private static void mockRequestAttributeStore(final HttpServletRequest request) .andAnswer(() -> attrs.get(EasyMock.getCurrentArguments()[0])).anyTimes(); } + private static final class JsonErrorResponse { + private final HttpServletResponse mock; + private final StringWriter body = new StringWriter(); + private final Capture status = EasyMock.newCapture(); + private final Capture contentType = EasyMock.newCapture(); + private final Map headers = new HashMap<>(); + + JsonErrorResponse() throws IOException { + mock = EasyMock.createNiceMock(HttpServletResponse.class); + mock.setStatus(EasyMock.captureInt(status)); + EasyMock.expectLastCall().anyTimes(); + mock.setContentType(EasyMock.capture(contentType)); + EasyMock.expectLastCall().anyTimes(); + mock.setHeader(EasyMock.anyString(), EasyMock.anyString()); + EasyMock.expectLastCall().andAnswer(() -> { + headers.put((String) EasyMock.getCurrentArguments()[0], (String) EasyMock.getCurrentArguments()[1]); + return null; + }).anyTimes(); + EasyMock.expect(mock.getWriter()).andReturn(new PrintWriter(body)).anyTimes(); + } + + int status() { + return status.getValue(); + } + + String body() { + return body.toString(); + } + + String contentType() { + return contentType.hasCaptured() ? contentType.getValue() : null; + } + + String header(String name) { + return headers.get(name); + } + } + } diff --git a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandlerTest.java b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandlerTest.java index a5296b349..f4e326237 100644 --- a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandlerTest.java +++ b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandlerTest.java @@ -39,6 +39,7 @@ import java.io.IOException; import java.text.ParseException; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; import java.util.Set; @@ -71,7 +72,8 @@ public void setUp() { public void testSubjectTokenRequired() throws Exception { handler.handle(request(null, JWT_TYPE, null, null), response, chain); assertEquals(HttpServletResponse.SC_BAD_REQUEST, filter.errorStatus); - assertTrue(filter.errorMessage.contains("subject_token")); + assertEquals("invalid_request", filter.error); + assertTrue(filter.errorDescription.contains("subject_token")); assertFalse(filter.continued); } @@ -79,7 +81,8 @@ public void testSubjectTokenRequired() throws Exception { public void testSubjectTokenTypeRequired() throws Exception { handler.handle(request("subtok", null, null, null), response, chain); assertEquals(HttpServletResponse.SC_BAD_REQUEST, filter.errorStatus); - assertTrue(filter.errorMessage.contains("subject_token_type")); + assertEquals("invalid_request", filter.error); + assertTrue(filter.errorDescription.contains("subject_token_type")); assertFalse(filter.continued); } @@ -88,7 +91,8 @@ public void testActorTokenTypeRequiredWhenActorPresent() throws Exception { filter.valid.put("subtok", jwt("alice", "KNOXSSO")); handler.handle(request("subtok", JWT_TYPE, "acttok", null), response, chain); assertEquals(HttpServletResponse.SC_BAD_REQUEST, filter.errorStatus); - assertTrue(filter.errorMessage.contains("actor_token_type is required")); + assertEquals("invalid_request", filter.error); + assertTrue(filter.errorDescription.contains("actor_token_type is required")); assertFalse(filter.continued); } @@ -96,7 +100,8 @@ public void testActorTokenTypeRequiredWhenActorPresent() throws Exception { public void testActorTokenTypeForbiddenWithoutActor() throws Exception { handler.handle(request("subtok", JWT_TYPE, null, JWT_TYPE), response, chain); assertEquals(HttpServletResponse.SC_BAD_REQUEST, filter.errorStatus); - assertTrue(filter.errorMessage.contains("must not be present")); + assertEquals("invalid_request", filter.error); + assertTrue(filter.errorDescription.contains("must not be present")); assertFalse(filter.continued); } @@ -104,7 +109,8 @@ public void testActorTokenTypeForbiddenWithoutActor() throws Exception { public void testUnsupportedSubjectTokenType() throws Exception { handler.handle(request("subtok", SAML2_TYPE, null, null), response, chain); assertEquals(HttpServletResponse.SC_BAD_REQUEST, filter.errorStatus); - assertTrue(filter.errorMessage.contains("unsupported_token_type")); + assertEquals("invalid_request", filter.error); + assertTrue(filter.errorDescription.contains("subject_token_type")); assertFalse(filter.continued); } @@ -113,7 +119,8 @@ public void testUnsupportedActorTokenType() throws Exception { filter.valid.put("subtok", jwt("alice", "KNOXSSO")); handler.handle(request("subtok", JWT_TYPE, "acttok", SAML2_TYPE), response, chain); assertEquals(HttpServletResponse.SC_BAD_REQUEST, filter.errorStatus); - assertTrue(filter.errorMessage.contains("unsupported_token_type")); + assertEquals("invalid_request", filter.error); + assertTrue(filter.errorDescription.contains("actor_token_type")); assertFalse(filter.continued); } @@ -186,6 +193,16 @@ public void testActorValidationFailureDoesNotEstablishContext() throws Exception assertFalse(filter.continued); } + @Test + public void testUnparseableSubjectTokenReturnsInvalidRequest() throws Exception { + filter.throwOnParse.add("subtok"); + handler.handle(request("subtok", JWT_TYPE, null, null), response, chain); + assertEquals(HttpServletResponse.SC_UNAUTHORIZED, filter.errorStatus); + assertEquals("invalid_request", filter.error); + assertTrue(filter.errorDescription.contains("Failed to parse token in token exchange")); + assertFalse(filter.continued); + } + private static String primaryName(Subject subject) { return subject.getPrincipals(PrimaryPrincipal.class).iterator().next().getName(); } @@ -227,8 +244,10 @@ private static JWT jwtWithActClaim(String subject, String issuer, Map valid = new HashMap<>(); + private final Set throwOnParse = new HashSet<>(); private int errorStatus = -1; - private String errorMessage; + private String error; + private String errorDescription; private boolean continued; private Subject establishedSubject; @@ -236,6 +255,9 @@ private static final class RecordingFilter extends JWTFederationFilter { JWT parseAndValidateJWT(HttpServletRequest request, HttpServletResponse response, FilterChain chain, String tokenValue) throws ParseException, IOException, ServletException { + if (throwOnParse.contains(tokenValue)) { + throw new ParseException("cannot parse " + tokenValue, 0); + } return valid.get(tokenValue); } @@ -254,10 +276,11 @@ protected void continueWithEstablishedSecurityContext(Subject subject, HttpServl } @Override - protected void handleValidationError(HttpServletRequest request, HttpServletResponse response, - int status, String error) { + void handleValidationError(HttpServletRequest request, HttpServletResponse response, + int status, String error, String description) { this.errorStatus = status; - this.errorMessage = error == null ? "" : error; + this.error = error == null ? "" : error; + this.errorDescription = description == null ? "" : description; } } } diff --git a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFUtils.java b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFUtils.java index f2f0e5ae3..6c7b3a3b2 100644 --- a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFUtils.java +++ b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFUtils.java @@ -22,13 +22,16 @@ import javax.servlet.ServletContext; import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; import javax.ws.rs.core.Response; +import java.io.IOException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; @@ -53,6 +56,28 @@ public static Response error(String error, String description, Response.Status s return Response.status(status).entity(JsonUtils.renderAsJsonString(errorMap)).build(); } + /** + * Writes an OAuth 2.0 error response (RFC 6749 §5.2 / RFC 8693 §2.2.2) directly to a servlet + * response as {@code {"error": ..., "error_description": ...}} with a JSON content type. Used by + * the filter layer, which works with {@link HttpServletResponse} rather than a JAX-RS + * {@link Response}. Uses {@link HttpServletResponse#setStatus(int)} (not {@code sendError}) so the + * JSON body is returned verbatim rather than replaced by the servlet container's HTML error page. + * The caller supplies the HTTP status; the {@code error_description} is omitted when {@code null}. + */ + public static void writeErrorResponse(HttpServletResponse response, int status, + String error, String description) throws IOException { + response.setStatus(status); + response.setContentType("application/json; charset=UTF-8"); + response.setHeader("Cache-Control", "no-store"); + response.setHeader("Pragma", "no-cache"); + final Map body = new LinkedHashMap<>(); + body.put("error", error); + if (description != null) { + body.put("error_description", description); + } + response.getWriter().write(JsonUtils.renderAsJsonString(body)); + } + private static Response.Status statusForError(String error) { if (error == null) { return Response.Status.BAD_REQUEST;