diff --git a/graphql/src/main/java/net/brianlevine/keycloak/graphql/GraphQLController.java b/graphql/src/main/java/net/brianlevine/keycloak/graphql/GraphQLController.java index 2311455..8a3ed43 100644 --- a/graphql/src/main/java/net/brianlevine/keycloak/graphql/GraphQLController.java +++ b/graphql/src/main/java/net/brianlevine/keycloak/graphql/GraphQLController.java @@ -7,12 +7,15 @@ import graphql.schema.idl.SchemaPrinter; import io.leangen.graphql.GraphQLRuntime; import io.leangen.graphql.GraphQLSchemaGenerator; +import io.vertx.core.Vertx; import jakarta.ws.rs.core.Request; import jakarta.ws.rs.core.HttpHeaders; import net.brianlevine.keycloak.graphql.queries.ErrorQuery; import net.brianlevine.keycloak.graphql.queries.RealmQuery; import net.brianlevine.keycloak.graphql.queries.UserQuery; +import net.brianlevine.keycloak.graphql.subscriptions.EventsSubscription; import net.brianlevine.keycloak.graphql.util.OverrideTypeInfoGenerator; +import org.keycloak.events.Event; import org.keycloak.models.KeycloakSession; import java.util.HashMap; @@ -20,22 +23,31 @@ public class GraphQLController { - private GraphQL graphQL; + private static GraphQL graphQL; public GraphQLController() { } - private GraphQL getSchema() { + public static GraphQL getSchema() { + return getSchema(false); + } + + public static GraphQL getSchema(boolean reset) { + + if (reset) { + graphQL = null; + } if (graphQL == null) { RealmQuery realmQuery = new RealmQuery(); ErrorQuery errorQuery = new ErrorQuery(); UserQuery userQuery = new UserQuery(); + EventsSubscription testSubscription = new EventsSubscription(); //Schema generated from query classes GraphQLSchema schema = new GraphQLSchemaGenerator() - .withBasePackages("net.brianlevine.keycloak.graphql") - .withOperationsFromSingletons(realmQuery, errorQuery, userQuery) + .withBasePackages("net.brianlevine.keycloak.graphql", "org.keycloak.events", "org.keycloak.events.admin") + .withOperationsFromSingletons(realmQuery, errorQuery, userQuery, testSubscription) .withRelayConnectionCheckRelaxed() .withTypeInfoGenerator(new OverrideTypeInfoGenerator().withHierarchicalNames(false)) .generate(); @@ -51,11 +63,20 @@ private GraphQL getSchema() { return graphQL; } - public Map executeQuery(String query, String operationName, KeycloakSession session, Request request, HttpHeaders headers, Map variables) { + public Map executeQuery( + String query, + String operationName, + KeycloakSession session, + Request request, + HttpHeaders headers, + Vertx vertx, + Map variables) { + Map ctx = new HashMap<>(); ctx.put("keycloak.session", session); ctx.put("request", request); ctx.put("headers", headers); + ctx.put("vertx", vertx); ExecutionResult executionResult = getSchema().execute(ExecutionInput.newExecutionInput() .query(query) diff --git a/graphql/src/main/java/net/brianlevine/keycloak/graphql/KeycloakGraphQLDataFetcherExceptionHandler.java b/graphql/src/main/java/net/brianlevine/keycloak/graphql/KeycloakGraphQLDataFetcherExceptionHandler.java index 8aed7a2..7c70407 100644 --- a/graphql/src/main/java/net/brianlevine/keycloak/graphql/KeycloakGraphQLDataFetcherExceptionHandler.java +++ b/graphql/src/main/java/net/brianlevine/keycloak/graphql/KeycloakGraphQLDataFetcherExceptionHandler.java @@ -27,7 +27,7 @@ public DataFetcherExceptionHandlerResult onException(DataFetcherExceptionHandler return DataFetcherExceptionHandlerResult.newResult().error(error).build(); } - @Override + protected void logException(ExceptionWhileDataFetching error, Throwable exception) { LOGGER.error(error.getMessage(), exception); } diff --git a/graphql/src/main/java/net/brianlevine/keycloak/graphql/SubscriptionServer.java b/graphql/src/main/java/net/brianlevine/keycloak/graphql/SubscriptionServer.java new file mode 100644 index 0000000..da09000 --- /dev/null +++ b/graphql/src/main/java/net/brianlevine/keycloak/graphql/SubscriptionServer.java @@ -0,0 +1,76 @@ +package net.brianlevine.keycloak.graphql; + + +import io.vertx.core.AbstractVerticle; +import io.vertx.core.http.HttpServerOptions; +import io.vertx.ext.web.Router; +import io.vertx.ext.web.handler.graphql.ApolloWSHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.Map; + + +public class SubscriptionServer extends AbstractVerticle { + + private static final Logger LOGGER = LoggerFactory.getLogger(SubscriptionServer.class); + private static final int DEFAULT_PORT = 8081; + + @Override + public void start() { + + String sPort = System.getenv("SUBSCRIPTION_PORT"); + int port = (sPort != null) ? Integer.parseInt(sPort) : DEFAULT_PORT; + + LOGGER.info("Starting SubscriptionServer on port {}...", port); + + Router router = Router.router(vertx); + Map context = new HashMap<>(); + context.put("vertex", getVertx()); + + //noinspection deprecation + ApolloWSHandler h = ApolloWSHandler.create(GraphQLController.getSchema()).beforeExecute((a) -> { + a.builder().graphQLContext(context); + }); + router.route("/graphql").handler(h); + + HttpServerOptions httpServerOptions = new HttpServerOptions() + .addWebSocketSubProtocol("graphql-ws") + .setLogActivity(true); + + vertx.createHttpServer(httpServerOptions) + .requestHandler(router) + .listen(port, "0.0.0.0") + .onComplete( + (e) -> LOGGER.info("SubscriptionServer started on port {}", e.actualPort()), + (t) -> LOGGER.error("SubscriptionServer FAILED to start: ", t) + ).onFailure((e) -> LOGGER.error("SubscriptionServer FAILED to start: ", e)); + } + +// private GraphQL createGraphQL() { +// String schema = vertx.fileSystem().readFileBlocking("links.graphqls").toString(); +// +// SchemaParser schemaParser = new SchemaParser(); +// TypeDefinitionRegistry typeDefinitionRegistry = schemaParser.parse(schema); +// +// RuntimeWiring runtimeWiring = newRuntimeWiring() +// .type("Subscription", builder -> builder.dataFetcher("links", this::linksFetcher)) +// .build(); +// +// SchemaGenerator schemaGenerator = new SchemaGenerator(); +// GraphQLSchema graphQLSchema = schemaGenerator.makeExecutableSchema(typeDefinitionRegistry, runtimeWiring); +// +// return GraphQL.newGraphQL(graphQLSchema) +// .build(); +// } + + + + +// private Publisher linksFetcher(DataFetchingEnvironment env) { +// return Flowable.interval(1, TimeUnit.SECONDS) // Ticks +// .zipWith(Flowable.fromIterable(links), (tick, link) -> link) // Emit link on each tick +// .observeOn(RxHelper.scheduler(context)); // Observe on the verticle context thread +// } +} diff --git a/graphql/src/main/java/net/brianlevine/keycloak/graphql/events/MulticastEventListenerProvider.java b/graphql/src/main/java/net/brianlevine/keycloak/graphql/events/MulticastEventListenerProvider.java new file mode 100644 index 0000000..b986fc8 --- /dev/null +++ b/graphql/src/main/java/net/brianlevine/keycloak/graphql/events/MulticastEventListenerProvider.java @@ -0,0 +1,62 @@ +package net.brianlevine.keycloak.graphql.events; + +import io.reactivex.rxjava3.processors.MulticastProcessor; +import org.jboss.logging.Logger; +import org.keycloak.events.Event; +import org.keycloak.events.EventListenerProvider; +import org.keycloak.events.admin.AdminEvent; + +/** + * An EventListenerProvider that sends Keycloak Events and AdminEvents to + * a reactive MulticaseProcessor which can be subscribed to to receive those events. + * This is used to implement the server side for Event and AdminEvent GraphQL subscriptions. + */ +public class MulticastEventListenerProvider + implements EventListenerProvider { + + private static final Logger LOGGER = Logger.getLogger(MulticastEventListenerProvider.class); + + private final MulticastProcessor eventMulticastProcessor; + private final MulticastProcessor adminEventMulticastProcessor; + + public MulticastEventListenerProvider(MulticastProcessor eventMulticastProcessor, MulticastProcessor adminEventMulticastProcessor) { + this.eventMulticastProcessor = eventMulticastProcessor; + this.adminEventMulticastProcessor = adminEventMulticastProcessor; + } + + @Override + public void onEvent(Event event) { + sendEvent(event); + } + + + @Override + public void onEvent(AdminEvent adminEvent, boolean b) { + sendEvent(adminEvent); + } + + private void sendEvent(Event e) { + if (eventMulticastProcessor.hasSubscribers()) { + boolean res = eventMulticastProcessor.offer(e); + + if (!res) { + LOGGER.warn("multicastProcessor.offer() returned false"); + } + } + } + + private void sendEvent(AdminEvent e) { + if (adminEventMulticastProcessor.hasSubscribers()) { + boolean res = adminEventMulticastProcessor.offer(e); + + if (!res) { + LOGGER.warn("multicastProcessor.offer() returned false"); + } + } + } + + + @Override + public void close() { + } +} diff --git a/graphql/src/main/java/net/brianlevine/keycloak/graphql/events/MulticastEventListenerProviderFactory.java b/graphql/src/main/java/net/brianlevine/keycloak/graphql/events/MulticastEventListenerProviderFactory.java new file mode 100644 index 0000000..c067736 --- /dev/null +++ b/graphql/src/main/java/net/brianlevine/keycloak/graphql/events/MulticastEventListenerProviderFactory.java @@ -0,0 +1,57 @@ +package net.brianlevine.keycloak.graphql.events; + +import com.google.auto.service.AutoService; +import io.reactivex.rxjava3.processors.MulticastProcessor; + +import org.keycloak.Config; +import org.keycloak.events.Event; +import org.keycloak.events.EventListenerProvider; +import org.keycloak.events.EventListenerProviderFactory; +import org.keycloak.events.admin.AdminEvent; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; + +/** + * Creates MulticastEventListenerProviders for Keycloak Events and AdminEvents + */ +@AutoService(EventListenerProviderFactory.class) +public class MulticastEventListenerProviderFactory implements EventListenerProviderFactory { + private static MulticastProcessor eventMulticastProcessor; + private static MulticastProcessor adminEventMulticastProcessor; + + public static MulticastProcessor getEventMulticastProcessor() { + return eventMulticastProcessor; + } + + public static MulticastProcessor getAdminEventMulticastProcessor() { + return adminEventMulticastProcessor; + } + + @Override + public EventListenerProvider create(KeycloakSession keycloakSession) { + return new MulticastEventListenerProvider(eventMulticastProcessor, adminEventMulticastProcessor); + } + + @Override + public void init(Config.Scope scope) { + eventMulticastProcessor = MulticastProcessor.create(false); + eventMulticastProcessor.startUnbounded(); + adminEventMulticastProcessor = MulticastProcessor.create(false); + adminEventMulticastProcessor.startUnbounded(); + } + + @Override + public void postInit(KeycloakSessionFactory keycloakSessionFactory) { + + } + + @Override + public void close() { + + } + + @Override + public String getId() { + return "multicast-event-listener"; + } +} \ No newline at end of file diff --git a/graphql/src/main/java/net/brianlevine/keycloak/graphql/queries/RealmQuery.java b/graphql/src/main/java/net/brianlevine/keycloak/graphql/queries/RealmQuery.java index 024bab9..1d97bb2 100644 --- a/graphql/src/main/java/net/brianlevine/keycloak/graphql/queries/RealmQuery.java +++ b/graphql/src/main/java/net/brianlevine/keycloak/graphql/queries/RealmQuery.java @@ -19,10 +19,10 @@ import org.keycloak.services.resources.admin.AdminAuth; import org.keycloak.services.resources.admin.permissions.AdminPermissions; - import java.util.List; import java.util.Objects; + public class RealmQuery { @GraphQLQuery(name = "realms", description = "Return a collection of realms that are viewable by the caller.") diff --git a/graphql/src/main/java/net/brianlevine/keycloak/graphql/rest/GraphQLConsoleService.java b/graphql/src/main/java/net/brianlevine/keycloak/graphql/rest/GraphQLConsoleService.java deleted file mode 100644 index 4e248c2..0000000 --- a/graphql/src/main/java/net/brianlevine/keycloak/graphql/rest/GraphQLConsoleService.java +++ /dev/null @@ -1,34 +0,0 @@ -package net.brianlevine.keycloak.graphql.rest; - -import org.keycloak.models.ClientModel; -import org.keycloak.models.Constants; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.RealmModel; -import org.keycloak.services.resources.AbstractSecuredLocalService; - -import java.net.URI; -import java.util.Set; - -public class GraphQLConsoleService extends AbstractSecuredLocalService { - public GraphQLConsoleService(KeycloakSession session, ClientModel client) { - super(session, client); - } - - public static GraphQLConsoleService getGraphQLConsoleService(KeycloakSession session) { - RealmModel realm = session.getContext().getRealm(); - - //TODO: We should probably define our own client. - ClientModel client = session.clients().getClientByClientId(realm, Constants.ADMIN_CONSOLE_CLIENT_ID); - return new GraphQLConsoleService(session, client); - } - - @Override - protected Set getValidPaths() { - return Set.of(); - } - - @Override - protected URI getBaseRedirectUri() { - return null; - } -} diff --git a/graphql/src/main/java/net/brianlevine/keycloak/graphql/rest/GraphQLResourceProvider.java b/graphql/src/main/java/net/brianlevine/keycloak/graphql/rest/GraphQLResourceProvider.java index a5c08cf..4f5bb3e 100644 --- a/graphql/src/main/java/net/brianlevine/keycloak/graphql/rest/GraphQLResourceProvider.java +++ b/graphql/src/main/java/net/brianlevine/keycloak/graphql/rest/GraphQLResourceProvider.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; +import io.vertx.core.Vertx; import jakarta.ws.rs.*; import jakarta.ws.rs.core.*; @@ -28,10 +29,12 @@ public class GraphQLResourceProvider implements RealmResourceProvider { private final KeycloakSession session; private final GraphQLController graphql; + private final Vertx vertx; - public GraphQLResourceProvider(KeycloakSession session, GraphQLController graphql) { + public GraphQLResourceProvider(KeycloakSession session, GraphQLController graphql, Vertx vertx) { this.session = session; this.graphql = graphql; + this.vertx = vertx; LOGGER.debug("Created GraphQLResourceProvider"); } @@ -62,6 +65,7 @@ public Response postGraphQL(Map body, @Context Request request, session, request, headers, + vertx, variables != null ? (Map) variables : Collections.emptyMap()); ObjectMapper mapper = new ObjectMapper(); diff --git a/graphql/src/main/java/net/brianlevine/keycloak/graphql/rest/GraphQLResourceProviderFactory.java b/graphql/src/main/java/net/brianlevine/keycloak/graphql/rest/GraphQLResourceProviderFactory.java index 41a646b..c9c40f0 100644 --- a/graphql/src/main/java/net/brianlevine/keycloak/graphql/rest/GraphQLResourceProviderFactory.java +++ b/graphql/src/main/java/net/brianlevine/keycloak/graphql/rest/GraphQLResourceProviderFactory.java @@ -1,37 +1,63 @@ package net.brianlevine.keycloak.graphql.rest; import com.google.auto.service.AutoService; +import io.vertx.core.Vertx; import net.brianlevine.keycloak.graphql.GraphQLController; +import net.brianlevine.keycloak.graphql.SubscriptionServer; import org.keycloak.Config; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.services.resource.RealmResourceProvider; import org.keycloak.services.resource.RealmResourceProviderFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; @AutoService(RealmResourceProviderFactory.class) public class GraphQLResourceProviderFactory implements RealmResourceProviderFactory { + private static final Logger LOGGER = LoggerFactory.getLogger(GraphQLResourceProviderFactory.class); + public static final String PROVIDER_ID = "graphql"; public static final String GRAPHQL_TOOLS_ROLE = "graphql-tools"; private GraphQLController graphql; + private SubscriptionServer subscriptionServer; + private Vertx vertx; @Override public RealmResourceProvider create(KeycloakSession keycloakSession) { - return new GraphQLResourceProvider(keycloakSession, graphql); + return new GraphQLResourceProvider(keycloakSession, graphql, vertx); } @Override public void init(Config.Scope scope) { graphql = new GraphQLController(); + + vertx = Vertx.vertx(); + subscriptionServer = new SubscriptionServer(); + +// DeploymentOptions deploymentOptions = new DeploymentOptions(); +// deploymentOptions.setInstances(1); +// deploymentOptions.setThreadingModel(ThreadingModel.EVENT_LOOP); +// deploymentOptions.setWorkerPoolSize(10); + vertx.deployVerticle(subscriptionServer); + } @Override public void postInit(KeycloakSessionFactory keycloakSessionFactory) { } + @Override public void close() { + if (subscriptionServer != null) { + try { + subscriptionServer.stop(); + } catch (Exception e) { + LOGGER.error("Error stopping subscription server.", e); + } + } } @Override diff --git a/graphql/src/main/java/net/brianlevine/keycloak/graphql/subscriptions/EventsSubscription.java b/graphql/src/main/java/net/brianlevine/keycloak/graphql/subscriptions/EventsSubscription.java new file mode 100644 index 0000000..32ff48a --- /dev/null +++ b/graphql/src/main/java/net/brianlevine/keycloak/graphql/subscriptions/EventsSubscription.java @@ -0,0 +1,26 @@ +package net.brianlevine.keycloak.graphql.subscriptions; + +import io.leangen.graphql.annotations.GraphQLSubscription; +import net.brianlevine.keycloak.graphql.events.MulticastEventListenerProviderFactory; +import org.keycloak.events.Event; +import org.keycloak.events.admin.AdminEvent; +import org.reactivestreams.Publisher; + + +public class EventsSubscription { + + + @GraphQLSubscription(description = "Subscribe to Keycloak events.") + public Publisher events(/*@GraphQLEnvironment ResolutionEnvironment env*/) { + // Note: can add .filter() here if we have an argument that specified what event + // types to return. + return MulticastEventListenerProviderFactory.getEventMulticastProcessor(); + } + + @GraphQLSubscription(description = "Subscribe to Keycloak Admin Events.") + public Publisher adminEvents(/*@GraphQLEnvironment ResolutionEnvironment env*/) { + // Note: can add .filter() here if we have an argument that specified what event + // types to return. + return MulticastEventListenerProviderFactory.getAdminEventMulticastProcessor(); + } +} diff --git a/graphql/src/main/java/net/brianlevine/keycloak/graphql/util/SubscriptionClient.java b/graphql/src/main/java/net/brianlevine/keycloak/graphql/util/SubscriptionClient.java new file mode 100644 index 0000000..056225f --- /dev/null +++ b/graphql/src/main/java/net/brianlevine/keycloak/graphql/util/SubscriptionClient.java @@ -0,0 +1,72 @@ +package net.brianlevine.keycloak.graphql.util; + +import io.vertx.core.AbstractVerticle; +import io.vertx.core.Launcher; +import io.vertx.core.http.HttpClient; +import io.vertx.core.http.HttpClientOptions; +import io.vertx.core.http.WebSocket; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.web.handler.graphql.ApolloWSMessageType; + +public class SubscriptionClient extends AbstractVerticle { + + public static void main(String[] args) { + Launcher.executeCommand("run", SubscriptionClient.class.getName()); + } + + @Override + public void start() { + HttpClient httpClient = vertx.createHttpClient(new HttpClientOptions().setDefaultPort(8081)); + + String sub1 = """ + subscription { + events { + id + details + type + } + } + """; + + sendSubscription(httpClient, sub1); + + String sub2 = """ + subscription { + adminEvents { + id + representation + } + } + """; + + sendSubscription(httpClient, sub2); + } + + private static void sendSubscription(HttpClient httpClient, String query) { + httpClient.webSocket("/graphql", websocketRes -> { + if (websocketRes.succeeded()) { + WebSocket webSocket = websocketRes.result(); + + webSocket.handler(message -> { + System.out.println(message.toJsonObject().encodePrettily()); + }); + + JsonObject request = new JsonObject() + .put("id", "1") + .put("type", ApolloWSMessageType.CONNECTION_INIT.getText()); + + webSocket.write(request.toBuffer()); + + request = new JsonObject() + .put("id", "1") + .put("type", ApolloWSMessageType.START.getText()) + .put("payload", new JsonObject() + .put("query", query)); + webSocket.write(request.toBuffer()); + } else { + websocketRes.cause().printStackTrace(); + } + }); + } + +}