Skip to content

Commit 97cebb8

Browse files
authored
Merge pull request quarkusio#49643 from rubik-cube-man/graphql-client-init-authorization
Add support for Authorization to be sent inside of the client init for WebSockets for GraphQL
2 parents 7735015 + e4676ba commit 97cebb8

File tree

17 files changed

+628
-50
lines changed

17 files changed

+628
-50
lines changed
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
package io.quarkus.smallrye.graphql.client.deployment;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertThrows;
5+
import static org.junit.jupiter.api.Assertions.assertTrue;
6+
7+
import java.util.Map;
8+
import java.util.stream.Stream;
9+
10+
import jakarta.annotation.security.RolesAllowed;
11+
import jakarta.json.JsonValue;
12+
13+
import org.eclipse.microprofile.graphql.GraphQLApi;
14+
import org.eclipse.microprofile.graphql.Query;
15+
import org.jboss.shrinkwrap.api.asset.EmptyAsset;
16+
import org.junit.jupiter.api.Disabled;
17+
import org.junit.jupiter.api.extension.RegisterExtension;
18+
import org.junit.jupiter.params.ParameterizedTest;
19+
import org.junit.jupiter.params.provider.Arguments;
20+
import org.junit.jupiter.params.provider.MethodSource;
21+
22+
import io.quarkus.test.QuarkusUnitTest;
23+
import io.smallrye.common.annotation.NonBlocking;
24+
import io.smallrye.graphql.api.Subscription;
25+
import io.smallrye.graphql.client.Response;
26+
import io.smallrye.graphql.client.UnexpectedCloseException;
27+
import io.smallrye.graphql.client.dynamic.api.DynamicGraphQLClient;
28+
import io.smallrye.graphql.client.dynamic.api.DynamicGraphQLClientBuilder;
29+
import io.smallrye.graphql.client.websocket.WebsocketSubprotocol;
30+
import io.smallrye.mutiny.Multi;
31+
32+
/**
33+
* Due to the complexity of establishing a WebSocket, WebSocket/Subscription testing of the GraphQL server is done here,
34+
* as the client framework comes in very useful for establishing the connection to the server.
35+
* <br>
36+
* This test ensures that websockets that are established with Authorization inside the connection_init payload are
37+
* authenticated and authorized in the same way that if included in the header.
38+
* The only difference being that the endpoint cannot be secured with HTTP permissions, as the connection_init payload
39+
* is only sent after the websocket is opened.
40+
*/
41+
public class DynamicGraphQLClientWebSocketAuthenticationClientInitTest {
42+
43+
static String url = "http://" + System.getProperty("quarkus.http.host", "localhost") + ":" +
44+
System.getProperty("quarkus.http.test-port", "8081") + "/graphql";
45+
46+
@RegisterExtension
47+
static QuarkusUnitTest test = new QuarkusUnitTest()
48+
.withApplicationRoot((jar) -> jar
49+
.addClasses(SecuredApi.class, Foo.class)
50+
.addAsResource("application-secured.properties", "application.properties")
51+
.addAsResource("users.properties")
52+
.addAsResource("roles.properties")
53+
.addAsManifestResource(EmptyAsset.INSTANCE, "beans.xml"))
54+
.overrideConfigKey("quarkus.smallrye-graphql.authorization-client-init-payload-name", "Authorization");
55+
56+
private static Stream<Arguments> websocketArguments() {
57+
return Stream.of(WebsocketSubprotocol.values())
58+
.map(Enum::name)
59+
.map(Arguments::of);
60+
}
61+
62+
@ParameterizedTest
63+
@MethodSource("websocketArguments")
64+
public void testAuthenticatedUserForQueryWebSocketOverInitParams(String subprotocolName) throws Exception {
65+
DynamicGraphQLClientBuilder clientBuilder = DynamicGraphQLClientBuilder.newBuilder()
66+
.url(url)
67+
.initPayload(Map.of("Authorization", "Basic ZGF2aWQ6cXdlcnR5MTIz"))
68+
.executeSingleOperationsOverWebsocket(true)
69+
.subprotocols(WebsocketSubprotocol.valueOf(subprotocolName));
70+
try (DynamicGraphQLClient client = clientBuilder.build()) {
71+
// Test that repeated queries yields the same result
72+
for (int i = 0; i < 3; i++) {
73+
Response response = client.executeSync("{ foo { message} }");
74+
assertTrue(response.hasData());
75+
assertEquals("foo", response.getData().getJsonObject("foo").getString("message"));
76+
}
77+
78+
// Unauthorized query
79+
Response response = client.executeSync("{ bar { message} }");
80+
assertTrue(response.hasData());
81+
assertEquals(JsonValue.ValueType.NULL, response.getData().get("bar").getValueType());
82+
}
83+
}
84+
85+
@ParameterizedTest
86+
@MethodSource("websocketArguments")
87+
public void testUnauthenticatedUserForQueryWebSocketOverInitParams(String subprotocolName) throws Exception {
88+
// Validate that our unit test code actually has a correctly secured endpoint
89+
DynamicGraphQLClientBuilder clientBuilder = DynamicGraphQLClientBuilder.newBuilder()
90+
.url(url)
91+
.executeSingleOperationsOverWebsocket(true)
92+
.subprotocols(WebsocketSubprotocol.valueOf(subprotocolName));
93+
try (DynamicGraphQLClient client = clientBuilder.build()) {
94+
Response response = client.executeSync("{ foo { message} }");
95+
assertTrue(response.hasData());
96+
assertEquals(JsonValue.ValueType.NULL, response.getData().get("foo").getValueType());
97+
}
98+
}
99+
100+
@ParameterizedTest
101+
@MethodSource("websocketArguments")
102+
@Disabled("Reliant on next version of SmallRye GraphQL to prevent it hanging")
103+
public void testIncorrectCredentialsForQueryWebSocketOverInitParams(String subprotocolName) {
104+
UnexpectedCloseException exception = assertThrows(UnexpectedCloseException.class, () -> {
105+
// Validate that our unit test code actually has a correctly secured endpoint
106+
DynamicGraphQLClientBuilder clientBuilder = DynamicGraphQLClientBuilder.newBuilder()
107+
.url(url)
108+
.executeSingleOperationsOverWebsocket(true)
109+
.initPayload(Map.of("Authorization", "Basic ZnJlZDpXUk9OR19QQVNTV09SRA=="))
110+
.subprotocols(WebsocketSubprotocol.valueOf(subprotocolName));
111+
try (DynamicGraphQLClient client = clientBuilder.build()) {
112+
client.executeSync("{ foo { message} }");
113+
}
114+
});
115+
assertEquals((short) 4403, exception.getCloseStatusCode());
116+
assertTrue(exception.getMessage().contains("Forbidden"));
117+
}
118+
119+
@ParameterizedTest
120+
@MethodSource("websocketArguments")
121+
@Disabled("Reliant on next version of SmallRye GraphQL to prevent it hanging")
122+
public void testAuthenticatedUserForQueryWebSocketOverHeadersAndInitParams(String subprotocolName) {
123+
UnexpectedCloseException exception = assertThrows(UnexpectedCloseException.class, () -> {
124+
DynamicGraphQLClientBuilder clientBuilder = DynamicGraphQLClientBuilder.newBuilder()
125+
.url(url)
126+
// Header takes precedence over init payload
127+
.header("Authorization", "Basic ZnJlZDpmb28=")
128+
// This should be ignored as the header is set
129+
.initPayload(Map.of("Authorization", "Basic ZGF2aWQ6cXdlcnR5MTIz"))
130+
.executeSingleOperationsOverWebsocket(true)
131+
.subprotocols(WebsocketSubprotocol.valueOf(subprotocolName));
132+
try (DynamicGraphQLClient client = clientBuilder.build()) {
133+
// Executing the query should fail because the server will error as we've defined two methods of auth
134+
client.executeSync("{ foo { message} }");
135+
}
136+
});
137+
assertEquals((short) 4400, exception.getCloseStatusCode());
138+
assertTrue(exception.getMessage().contains("Authorization specified in multiple locations"));
139+
}
140+
141+
public static class Foo {
142+
143+
private String message;
144+
145+
public Foo(String foo) {
146+
this.message = foo;
147+
}
148+
149+
public String getMessage() {
150+
return message;
151+
}
152+
153+
public void setMessage(String message) {
154+
this.message = message;
155+
}
156+
157+
}
158+
159+
@GraphQLApi
160+
public static class SecuredApi {
161+
162+
@Query
163+
@RolesAllowed("fooRole")
164+
@NonBlocking
165+
public Foo foo() {
166+
return new Foo("foo");
167+
}
168+
169+
@Query
170+
@RolesAllowed("barRole")
171+
public Foo bar() {
172+
return new Foo("bar");
173+
}
174+
175+
@Subscription
176+
@RolesAllowed("fooRole")
177+
public Multi<Foo> fooSub() {
178+
return Multi.createFrom().emitter(emitter -> {
179+
emitter.emit(new Foo("foo"));
180+
emitter.complete();
181+
});
182+
}
183+
184+
@Subscription
185+
@RolesAllowed("barRole")
186+
public Multi<Foo> barSub() {
187+
return Multi.createFrom().emitter(emitter -> {
188+
emitter.emit(new Foo("bar"));
189+
emitter.complete();
190+
});
191+
}
192+
}
193+
}

extensions/smallrye-graphql-client/deployment/src/test/java/io/quarkus/smallrye/graphql/client/deployment/DynamicGraphQLClientWebSocketAuthenticationHttpPermissionsTest.java

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import org.eclipse.microprofile.graphql.Query;
77
import org.jboss.shrinkwrap.api.asset.EmptyAsset;
88
import org.junit.jupiter.api.Assertions;
9-
import org.junit.jupiter.api.Disabled;
109
import org.junit.jupiter.api.Test;
1110
import org.junit.jupiter.api.extension.RegisterExtension;
1211

@@ -41,7 +40,6 @@ public class DynamicGraphQLClientWebSocketAuthenticationHttpPermissionsTest {
4140
.addAsResource("roles.properties")
4241
.addAsManifestResource(EmptyAsset.INSTANCE, "beans.xml"));
4342

44-
@Disabled("TODO: enable after upgrade to smallrye-graphql 1.6.1, with 1.6.0 a websocket upgrade failure causes a hang here")
4543
@Test
4644
public void testUnauthenticatedForQueryWebSocket() throws Exception {
4745
DynamicGraphQLClientBuilder clientBuilder = DynamicGraphQLClientBuilder.newBuilder()

extensions/smallrye-graphql-client/deployment/src/test/java/io/quarkus/smallrye/graphql/client/deployment/DynamicGraphQLClientWebSocketAuthenticationTest.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import static org.junit.jupiter.api.Assertions.assertNotNull;
77
import static org.junit.jupiter.api.Assertions.assertTrue;
88

9+
import java.util.Map;
910
import java.util.concurrent.atomic.AtomicBoolean;
1011

1112
import jakarta.annotation.security.RolesAllowed;
@@ -128,6 +129,19 @@ public void testUnauthorizedUserForSubscription() throws Exception {
128129
}
129130
}
130131

132+
@Test
133+
public void testAuthenticatedUserButDefinedWithClientInitForQueryWebSocket() throws Exception {
134+
DynamicGraphQLClientBuilder clientBuilder = DynamicGraphQLClientBuilder.newBuilder()
135+
.url(url)
136+
// Because quarkus.smallrye-graphql.authorization-client-init-payload-name is undefined, this will be ignored.
137+
.initPayload(Map.of("Authorization", "Basic ZGF2aWQ6cXdlcnR5MTIz"))
138+
.executeSingleOperationsOverWebsocket(true);
139+
try (DynamicGraphQLClient client = clientBuilder.build()) {
140+
Response response = client.executeSync("{ foo { message} }");
141+
assertEquals(JsonValue.ValueType.NULL, response.getData().get("foo").getValueType());
142+
}
143+
}
144+
131145
@Test
132146
public void testUnauthorizedUserForQueryWebSocket() throws Exception {
133147
DynamicGraphQLClientBuilder clientBuilder = DynamicGraphQLClientBuilder.newBuilder()
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
david=fooRole
1+
david=fooRole
2+
fred=barRole
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
david=qwerty123
1+
david=qwerty123
2+
fred=foo
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package io.quarkus.smallrye.graphql.runtime;
2+
3+
import java.util.Map;
4+
import java.util.Optional;
5+
6+
import jakarta.json.JsonObject;
7+
8+
import io.quarkus.smallrye.graphql.runtime.exception.SmallRyeAuthSecurityIdentityAlreadyAssignedException;
9+
import io.smallrye.graphql.websocket.GraphQLWebSocketSession;
10+
import io.smallrye.graphql.websocket.graphqltransportws.GraphQLTransportWSSubprotocolHandler;
11+
import io.vertx.ext.web.RoutingContext;
12+
13+
/**
14+
* An extension over GraphQLTransportWSSubprotocolHandler which supports defining an Authorization header in the client init
15+
* message.
16+
* This key of the Authorization header is defined by the `authorizationClientInitPayloadName` property.
17+
*/
18+
public class SmallRyeAuthGraphQLTransportWSSubprotocolHandler extends GraphQLTransportWSSubprotocolHandler {
19+
20+
private final SmallRyeAuthGraphQLWSHandler authHander;
21+
22+
public SmallRyeAuthGraphQLTransportWSSubprotocolHandler(GraphQLWebSocketSession session,
23+
Map<String, Object> context,
24+
RoutingContext ctx,
25+
SmallRyeGraphQLAbstractHandler handler,
26+
Optional<String> authorizationClientInitPayloadName) {
27+
super(session, context);
28+
29+
this.authHander = new SmallRyeAuthGraphQLWSHandler(session, ctx, handler, authorizationClientInitPayloadName);
30+
}
31+
32+
@Override
33+
protected void onMessage(JsonObject message) {
34+
if (message != null && message.getString("type").equals("connection_init")) {
35+
Map<String, Object> payload = (Map<String, Object>) message.get("payload");
36+
37+
this.authHander.handlePayload(payload, () -> {
38+
// Identity has been updated. Now pass the successful connection_init back to the SmallRye GraphQL library to take over
39+
super.onMessage(message);
40+
}, failure -> {
41+
// Failure handling Authorization. This method triggers a 4401 (Unauthorized).
42+
if (!session.isClosed()) {
43+
if (failure instanceof SmallRyeAuthSecurityIdentityAlreadyAssignedException) {
44+
session.close((short) 4400, "Authorization specified in multiple locations");
45+
} else {
46+
session.close((short) 4403, "Forbidden");
47+
}
48+
}
49+
});
50+
} else {
51+
super.onMessage(message);
52+
}
53+
}
54+
55+
@Override
56+
public void onClose() {
57+
super.onClose();
58+
59+
this.authHander.cancelAuthExpiry();
60+
}
61+
}

0 commit comments

Comments
 (0)