diff --git a/graphql-spring-boot-autoconfigure/src/main/java/graphql/kickstart/autoconfigure/web/servlet/DefaultWsCsrfToken.java b/graphql-spring-boot-autoconfigure/src/main/java/graphql/kickstart/autoconfigure/web/servlet/DefaultWsCsrfToken.java new file mode 100644 index 00000000..81e8ef3a --- /dev/null +++ b/graphql-spring-boot-autoconfigure/src/main/java/graphql/kickstart/autoconfigure/web/servlet/DefaultWsCsrfToken.java @@ -0,0 +1,20 @@ +package graphql.kickstart.autoconfigure.web.servlet; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +class DefaultWsCsrfToken implements WsCsrfToken { + + private final String token; + private final String parameterName; + + @Override + public String getToken() { + return token; + } + + @Override + public String getParameterName() { + return parameterName; + } +} diff --git a/graphql-spring-boot-autoconfigure/src/main/java/graphql/kickstart/autoconfigure/web/servlet/GraphQLSubscriptionWebsocketProperties.java b/graphql-spring-boot-autoconfigure/src/main/java/graphql/kickstart/autoconfigure/web/servlet/GraphQLSubscriptionWebsocketProperties.java index 47123b7b..16b83d42 100644 --- a/graphql-spring-boot-autoconfigure/src/main/java/graphql/kickstart/autoconfigure/web/servlet/GraphQLSubscriptionWebsocketProperties.java +++ b/graphql-spring-boot-autoconfigure/src/main/java/graphql/kickstart/autoconfigure/web/servlet/GraphQLSubscriptionWebsocketProperties.java @@ -12,4 +12,12 @@ class GraphQLSubscriptionWebsocketProperties { private String path = "/subscriptions"; private List allowedOrigins = emptyList(); + private CsrfProperties csrf = new CsrfProperties(); + + @Data + static + class CsrfProperties { + + private boolean enabled = false; + } } diff --git a/graphql-spring-boot-autoconfigure/src/main/java/graphql/kickstart/autoconfigure/web/servlet/GraphQLWebsocketAutoConfiguration.java b/graphql-spring-boot-autoconfigure/src/main/java/graphql/kickstart/autoconfigure/web/servlet/GraphQLWebsocketAutoConfiguration.java index 54933903..6d15b62f 100644 --- a/graphql-spring-boot-autoconfigure/src/main/java/graphql/kickstart/autoconfigure/web/servlet/GraphQLWebsocketAutoConfiguration.java +++ b/graphql-spring-boot-autoconfigure/src/main/java/graphql/kickstart/autoconfigure/web/servlet/GraphQLWebsocketAutoConfiguration.java @@ -26,6 +26,7 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Conditional; +import org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository; import org.springframework.web.servlet.DispatcherServlet; import org.springframework.web.socket.server.standard.ServerEndpointExporter; import org.springframework.web.socket.server.standard.ServerEndpointRegistration; @@ -62,11 +63,7 @@ public GraphQLWebsocketServlet graphQLWebsocketServlet( } keepAliveListener().ifPresent(listeners::add); return new GraphQLWebsocketServlet( - graphQLInvoker, - invocationInputFactory, - graphQLObjectMapper, - listeners, - websocketProperties.getAllowedOrigins()); + graphQLInvoker, invocationInputFactory, graphQLObjectMapper, listeners); } private Optional keepAliveListener() { @@ -78,10 +75,28 @@ private Optional keepAliveListener() { return Optional.empty(); } + @Bean + public WsCsrfFilter wsCsrfFilter( + @Autowired(required = false) WsCsrfTokenRepository csrfTokenRepository) { + return new WsCsrfFilter(websocketProperties.getCsrf(), csrfTokenRepository); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnClass(HttpSessionCsrfTokenRepository.class) + public WsCsrfTokenRepository wsCsrfTokenRepository() { + return new WsSessionCsrfTokenRepository(); + } + @Bean @ConditionalOnClass(ServerContainer.class) - public ServerEndpointRegistration serverEndpointRegistration(GraphQLWebsocketServlet servlet) { - return new GraphQLWsServerEndpointRegistration(websocketProperties.getPath(), servlet); + public ServerEndpointRegistration serverEndpointRegistration( + GraphQLWebsocketServlet servlet, WsCsrfFilter csrfFilter) { + return new GraphQLWsServerEndpointRegistration( + websocketProperties.getPath(), + servlet, + csrfFilter, + websocketProperties.getAllowedOrigins()); } @Bean diff --git a/graphql-spring-boot-autoconfigure/src/main/java/graphql/kickstart/autoconfigure/web/servlet/GraphQLWsServerEndpointRegistration.java b/graphql-spring-boot-autoconfigure/src/main/java/graphql/kickstart/autoconfigure/web/servlet/GraphQLWsServerEndpointRegistration.java index d6329425..a46c556d 100644 --- a/graphql-spring-boot-autoconfigure/src/main/java/graphql/kickstart/autoconfigure/web/servlet/GraphQLWsServerEndpointRegistration.java +++ b/graphql-spring-boot-autoconfigure/src/main/java/graphql/kickstart/autoconfigure/web/servlet/GraphQLWsServerEndpointRegistration.java @@ -1,32 +1,63 @@ package graphql.kickstart.autoconfigure.web.servlet; import graphql.kickstart.servlet.GraphQLWebsocketServlet; +import java.util.ArrayList; +import java.util.List; import jakarta.websocket.HandshakeResponse; import jakarta.websocket.server.HandshakeRequest; import jakarta.websocket.server.ServerEndpointConfig; import org.springframework.context.Lifecycle; import org.springframework.web.socket.server.standard.ServerEndpointRegistration; -/** @author Andrew Potter */ +/** + * @author Andrew Potter + */ public class GraphQLWsServerEndpointRegistration extends ServerEndpointRegistration implements Lifecycle { + private static final String ALL = "*"; private final GraphQLWebsocketServlet servlet; + private final WsCsrfFilter csrfFilter; + private final List allowedOrigins; - public GraphQLWsServerEndpointRegistration(String path, GraphQLWebsocketServlet servlet) { + public GraphQLWsServerEndpointRegistration( + String path, + GraphQLWebsocketServlet servlet, + WsCsrfFilter csrfFilter, + List allowedOrigins) { super(path, servlet); this.servlet = servlet; + if (allowedOrigins == null || allowedOrigins.isEmpty()) { + this.allowedOrigins = List.of(ALL); + } else { + this.allowedOrigins = new ArrayList<>(allowedOrigins); + } + this.csrfFilter = csrfFilter; } @Override public boolean checkOrigin(String originHeaderValue) { - return servlet.checkOrigin(originHeaderValue); + if (originHeaderValue == null || originHeaderValue.isBlank()) { + return allowedOrigins.contains(ALL); + } + if (allowedOrigins.contains(ALL)) { + return true; + } + String originToCheck = trimTrailingSlash(originHeaderValue); + return allowedOrigins.stream() + .map(this::trimTrailingSlash) + .anyMatch(originToCheck::equalsIgnoreCase); + } + + private String trimTrailingSlash(String origin) { + return (origin.endsWith("/") ? origin.substring(0, origin.length() - 1) : origin); } @Override public void modifyHandshake( ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) { super.modifyHandshake(sec, request, response); + csrfFilter.doFilter(request); servlet.modifyHandshake(sec, request, response); } diff --git a/graphql-spring-boot-autoconfigure/src/main/java/graphql/kickstart/autoconfigure/web/servlet/WsCsrfFilter.java b/graphql-spring-boot-autoconfigure/src/main/java/graphql/kickstart/autoconfigure/web/servlet/WsCsrfFilter.java new file mode 100644 index 00000000..8e749384 --- /dev/null +++ b/graphql-spring-boot-autoconfigure/src/main/java/graphql/kickstart/autoconfigure/web/servlet/WsCsrfFilter.java @@ -0,0 +1,37 @@ +package graphql.kickstart.autoconfigure.web.servlet; + +import static org.springframework.util.CollectionUtils.firstElement; + +import graphql.kickstart.autoconfigure.web.servlet.GraphQLSubscriptionWebsocketProperties.CsrfProperties; +import jakarta.websocket.server.HandshakeRequest; +import java.util.Objects; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +class WsCsrfFilter { + + private final CsrfProperties csrfProperties; + private final WsCsrfTokenRepository tokenRepository; + + void doFilter(HandshakeRequest request) { + if (csrfProperties.isEnabled() && tokenRepository != null) { + WsCsrfToken csrfToken = tokenRepository.loadToken(request); + boolean missingToken = csrfToken == null; + if (missingToken) { + csrfToken = tokenRepository.generateToken(request); + tokenRepository.saveToken(csrfToken, request); + } + + String actualToken = + firstElement(request.getParameterMap().get(csrfToken.getParameterName())); + if (!Objects.equals(csrfToken.getToken(), actualToken)) { + throw new IllegalStateException( + "Invalid CSRF Token '" + + actualToken + + "' was found on the request parameter '" + + csrfToken.getParameterName() + + "'."); + } + } + } +} diff --git a/graphql-spring-boot-autoconfigure/src/main/java/graphql/kickstart/autoconfigure/web/servlet/WsCsrfToken.java b/graphql-spring-boot-autoconfigure/src/main/java/graphql/kickstart/autoconfigure/web/servlet/WsCsrfToken.java new file mode 100644 index 00000000..40accff0 --- /dev/null +++ b/graphql-spring-boot-autoconfigure/src/main/java/graphql/kickstart/autoconfigure/web/servlet/WsCsrfToken.java @@ -0,0 +1,10 @@ +package graphql.kickstart.autoconfigure.web.servlet; + +import java.io.Serializable; + +public interface WsCsrfToken extends Serializable { + + String getToken(); + + String getParameterName(); +} diff --git a/graphql-spring-boot-autoconfigure/src/main/java/graphql/kickstart/autoconfigure/web/servlet/WsCsrfTokenRepository.java b/graphql-spring-boot-autoconfigure/src/main/java/graphql/kickstart/autoconfigure/web/servlet/WsCsrfTokenRepository.java new file mode 100644 index 00000000..1f9f81d6 --- /dev/null +++ b/graphql-spring-boot-autoconfigure/src/main/java/graphql/kickstart/autoconfigure/web/servlet/WsCsrfTokenRepository.java @@ -0,0 +1,12 @@ +package graphql.kickstart.autoconfigure.web.servlet; + +import jakarta.websocket.server.HandshakeRequest; + +public interface WsCsrfTokenRepository { + + WsCsrfToken loadToken(HandshakeRequest request); + + WsCsrfToken generateToken(HandshakeRequest request); + + void saveToken(WsCsrfToken csrfToken, HandshakeRequest request); +} diff --git a/graphql-spring-boot-autoconfigure/src/main/java/graphql/kickstart/autoconfigure/web/servlet/WsSessionCsrfTokenRepository.java b/graphql-spring-boot-autoconfigure/src/main/java/graphql/kickstart/autoconfigure/web/servlet/WsSessionCsrfTokenRepository.java new file mode 100644 index 00000000..6f131354 --- /dev/null +++ b/graphql-spring-boot-autoconfigure/src/main/java/graphql/kickstart/autoconfigure/web/servlet/WsSessionCsrfTokenRepository.java @@ -0,0 +1,42 @@ +package graphql.kickstart.autoconfigure.web.servlet; + +import jakarta.servlet.http.HttpSession; +import jakarta.websocket.server.HandshakeRequest; +import java.util.UUID; +import org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository; + +class WsSessionCsrfTokenRepository implements WsCsrfTokenRepository { + + private static final String DEFAULT_CSRF_PARAMETER_NAME = "_csrf"; + + private static final String DEFAULT_CSRF_TOKEN_ATTR_NAME = + HttpSessionCsrfTokenRepository.class.getName().concat(".CSRF_TOKEN"); + + private String sessionAttributeName = DEFAULT_CSRF_TOKEN_ATTR_NAME; + + @Override + public void saveToken(WsCsrfToken token, HandshakeRequest request) { + HttpSession session = (HttpSession) request.getHttpSession(); + if (session != null) { + if (token == null) { + session.removeAttribute(this.sessionAttributeName); + } else { + session.setAttribute(this.sessionAttributeName, token); + } + } + } + + @Override + public WsCsrfToken loadToken(HandshakeRequest request) { + HttpSession session = (HttpSession) request.getHttpSession(); + if (session == null) { + return null; + } + return (WsCsrfToken) session.getAttribute(this.sessionAttributeName); + } + + @Override + public WsCsrfToken generateToken(HandshakeRequest request) { + return new DefaultWsCsrfToken(UUID.randomUUID().toString(), DEFAULT_CSRF_PARAMETER_NAME); + } +} diff --git a/graphql-spring-boot-autoconfigure/src/test/java/graphql/kickstart/autoconfigure/web/servlet/GraphQLWsServerEndpointRegistrationTest.java b/graphql-spring-boot-autoconfigure/src/test/java/graphql/kickstart/autoconfigure/web/servlet/GraphQLWsServerEndpointRegistrationTest.java new file mode 100644 index 00000000..87f770c0 --- /dev/null +++ b/graphql-spring-boot-autoconfigure/src/test/java/graphql/kickstart/autoconfigure/web/servlet/GraphQLWsServerEndpointRegistrationTest.java @@ -0,0 +1,53 @@ +package graphql.kickstart.autoconfigure.web.servlet; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +import graphql.kickstart.servlet.GraphQLWebsocketServlet; +import java.util.List; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class GraphQLWsServerEndpointRegistrationTest { + + private static final String PATH = "/subscriptions"; + + @Mock private GraphQLWebsocketServlet servlet; + @Mock private WsCsrfFilter csrfFilter; + + @ParameterizedTest + @CsvSource( + value = {"https://trusted.com", "NULL", "' '"}, + nullValues = {"NULL"}) + void givenDefaultAllowedOrigins_whenCheckOrigin_thenReturnTrue(String origin) { + var registration = createRegistration(); + var allowed = registration.checkOrigin("null".equals(origin) ? null : origin); + assertThat(allowed).isTrue(); + } + + private GraphQLWsServerEndpointRegistration createRegistration(String... allowedOrigins) { + return new GraphQLWsServerEndpointRegistration( + PATH, servlet, csrfFilter, List.of(allowedOrigins)); + } + + @ParameterizedTest(name = "{index} => allowedOrigin=''{0}'', originToCheck=''{1}''") + @CsvSource( + delimiterString = "|", + textBlock = + """ + * | https://trusted.com + https://trusted.com | https://trusted.com + https://trusted.com/ | https://trusted.com + https://trusted.com/ | https://trusted.com/ + https://trusted.com | https://trusted.com/ +""") + void givenAllowedOrigins_whenCheckOrigin_thenReturnTrue( + String allowedOrigin, String originToCheck) { + var registration = createRegistration(allowedOrigin); + var allowed = registration.checkOrigin(originToCheck); + assertThat(allowed).isTrue(); + } +} diff --git a/graphql-spring-boot-autoconfigure/src/test/java/graphql/kickstart/autoconfigure/web/servlet/WsCsrfFilterTest.java b/graphql-spring-boot-autoconfigure/src/test/java/graphql/kickstart/autoconfigure/web/servlet/WsCsrfFilterTest.java new file mode 100644 index 00000000..cdde5fae --- /dev/null +++ b/graphql-spring-boot-autoconfigure/src/test/java/graphql/kickstart/autoconfigure/web/servlet/WsCsrfFilterTest.java @@ -0,0 +1,86 @@ +package graphql.kickstart.autoconfigure.web.servlet; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import graphql.kickstart.autoconfigure.web.servlet.GraphQLSubscriptionWebsocketProperties.CsrfProperties; +import jakarta.websocket.server.HandshakeRequest; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class WsCsrfFilterTest { + + private CsrfProperties csrfProperties = new CsrfProperties(); + @Mock private WsCsrfTokenRepository tokenRepository; + @Mock private HandshakeRequest handshakeRequest; + + @Test + void givenCsrfDisabled_whenDoFilter_thenDoesNotLoadToken() { + csrfProperties.setEnabled(false); + WsCsrfFilter filter = new WsCsrfFilter(csrfProperties, tokenRepository); + filter.doFilter(handshakeRequest); + + verify(tokenRepository, never()).loadToken(any()); + } + + @Test + void givenCsrfEnabledAndRepositoryNull_whenDoFilter_thenDoesNotGetTokenFromRequest() { + csrfProperties.setEnabled(true); + WsCsrfFilter filter = new WsCsrfFilter(csrfProperties, null); + filter.doFilter(handshakeRequest); + + verify(handshakeRequest, never()).getParameterMap(); + } + + @Test + void givenNoTokenInSession_whenDoFilter_thenGenerateAndSaveToken() { + csrfProperties.setEnabled(true); + when(tokenRepository.loadToken(handshakeRequest)).thenReturn(null); + WsCsrfToken csrfToken = mock(WsCsrfToken.class); + when(tokenRepository.generateToken(handshakeRequest)).thenReturn(csrfToken); + + WsCsrfFilter filter = new WsCsrfFilter(csrfProperties, tokenRepository); + filter.doFilter(handshakeRequest); + + verify(tokenRepository).saveToken(csrfToken, handshakeRequest); + } + + @Test + void givenDifferentActualToken_whenDoFilter_thenThrowsException() { + csrfProperties.setEnabled(true); + WsCsrfToken csrfToken = new DefaultWsCsrfToken("some-token", "_csrf"); + when(tokenRepository.loadToken(handshakeRequest)).thenReturn(csrfToken); + when(handshakeRequest.getParameterMap()) + .thenReturn(Map.of("_csrf", List.of("different-token"))); + + WsCsrfFilter filter = new WsCsrfFilter(csrfProperties, tokenRepository); + assertThatThrownBy(() -> filter.doFilter(handshakeRequest)) + .isInstanceOf(IllegalStateException.class) + .hasMessage( + "Invalid CSRF Token 'different-token' was found on the request parameter '_csrf'."); + } + + @Test + void givenSameToken_whenDoFilter_thenDoesNotThrow() { + csrfProperties.setEnabled(true); + WsCsrfToken csrfToken = new DefaultWsCsrfToken("some-token", "_csrf"); + when(tokenRepository.loadToken(handshakeRequest)).thenReturn(csrfToken); + when(handshakeRequest.getParameterMap()) + .thenReturn(Map.of("_csrf", List.of("some-token"))); + + WsCsrfFilter filter = new WsCsrfFilter(csrfProperties, tokenRepository); + assertDoesNotThrow(() -> filter.doFilter(handshakeRequest)); + + verify(tokenRepository).loadToken(handshakeRequest); + } +} diff --git a/graphql-spring-boot-autoconfigure/src/test/java/graphql/kickstart/autoconfigure/web/servlet/WsSessionCsrfTokenRepositoryTest.java b/graphql-spring-boot-autoconfigure/src/test/java/graphql/kickstart/autoconfigure/web/servlet/WsSessionCsrfTokenRepositoryTest.java new file mode 100644 index 00000000..a933ab8e --- /dev/null +++ b/graphql-spring-boot-autoconfigure/src/test/java/graphql/kickstart/autoconfigure/web/servlet/WsSessionCsrfTokenRepositoryTest.java @@ -0,0 +1,72 @@ +package graphql.kickstart.autoconfigure.web.servlet; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import jakarta.servlet.http.HttpSession; +import jakarta.websocket.server.HandshakeRequest; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class WsSessionCsrfTokenRepositoryTest { + + public static final String TOKEN_SESSION_ATTRIBUTE_NAME = + "org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository.CSRF_TOKEN"; + @Mock private HandshakeRequest handshakeRequest; + @Mock private HttpSession httpSession; + @Mock private WsCsrfToken csrfToken; + private WsSessionCsrfTokenRepository tokenRepository = new WsSessionCsrfTokenRepository(); + + @Test + void givenNoSession_whenSaveToken_thenDoesNotThrow() { + when(handshakeRequest.getHttpSession()).thenReturn(null); + assertDoesNotThrow(() -> tokenRepository.saveToken(csrfToken, handshakeRequest)); + } + + @Test + void givenNoToken_whenSaveToken_thenRemovesFromSession() { + when(handshakeRequest.getHttpSession()).thenReturn(httpSession); + tokenRepository.saveToken(null, handshakeRequest); + verify(httpSession).removeAttribute(TOKEN_SESSION_ATTRIBUTE_NAME); + } + + @Test + void givenToken_whenSaveToken_thenSetsInSession() { + when(handshakeRequest.getHttpSession()).thenReturn(httpSession); + tokenRepository.saveToken(csrfToken, handshakeRequest); + verify(httpSession).setAttribute(TOKEN_SESSION_ATTRIBUTE_NAME, csrfToken); + } + + @Test + void givenNoSession_whenLoadToken_thenReturnNull() { + when(handshakeRequest.getHttpSession()).thenReturn(null); + WsCsrfToken csrfToken = tokenRepository.loadToken(handshakeRequest); + assertThat(csrfToken).isNull(); + } + + @Test + void givenTokenInSession_whenLoadToken_thenReturnTokenFromSession() { + when(handshakeRequest.getHttpSession()).thenReturn(httpSession); + when(httpSession.getAttribute(TOKEN_SESSION_ATTRIBUTE_NAME)).thenReturn(csrfToken); + WsCsrfToken loadedToken = tokenRepository.loadToken(handshakeRequest); + assertThat(loadedToken).isEqualTo(csrfToken); + } + + @Test + void whenGenerateToken_thenContainsUUID() { + var generatedToken = tokenRepository.generateToken(handshakeRequest); + assertDoesNotThrow(() -> UUID.fromString(generatedToken.getToken())); + } + + @Test + void whenGenerateToken_thenContainsCorrectParameterName() { + var generatedToken = tokenRepository.generateToken(handshakeRequest); + assertThat(generatedToken.getParameterName()).isEqualTo("_csrf"); + } +} diff --git a/graphql-spring-boot-test/build.gradle b/graphql-spring-boot-test/build.gradle index 04668781..1b9d5986 100644 --- a/graphql-spring-boot-test/build.gradle +++ b/graphql-spring-boot-test/build.gradle @@ -17,14 +17,18 @@ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ dependencies { - implementation("org.springframework:spring-web") - implementation("org.springframework.boot:spring-boot-starter-test") - implementation("com.fasterxml.jackson.core:jackson-databind") - implementation("com.jayway.jsonpath:json-path") + compileOnly 'jakarta.servlet:jakarta.servlet-api:6.0.0' + compileOnly 'jakarta.websocket:jakarta.websocket-api:2.1.0' + compileOnly 'jakarta.websocket:jakarta.websocket-client-api:2.1.0' + implementation "org.springframework:spring-web" + implementation "org.springframework.boot:spring-boot-starter-test" + implementation "com.fasterxml.jackson.core:jackson-databind" + implementation "com.jayway.jsonpath:json-path" implementation "org.awaitility:awaitility:$LIB_AWAITILITY_VER" - compileOnly("com.graphql-java-kickstart:graphql-java-servlet:$LIB_GRAPHQL_SERVLET_VER") - testImplementation("org.springframework.boot:spring-boot-starter-web") + compileOnly "com.graphql-java-kickstart:graphql-java-servlet:$LIB_GRAPHQL_SERVLET_VER" + testImplementation "org.springframework.boot:spring-boot-starter-web" testImplementation "org.springframework.boot:spring-boot-starter-websocket" testImplementation project(":graphql-spring-boot-starter") testImplementation "io.reactivex.rxjava2:rxjava:2.2.21" + }