diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..c41cc9e35e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000..e0f15db2eb --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "java.configuration.updateBuildConfiguration": "automatic" +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000..7bb40ee5d8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +version: "3" + +services: + postgres: + image: postgres + volumes: + - db:/var/lib/postgresql/data + environment: + POSTGRES_USER: keycloak + POSTGRES_PASSWORD: keycloak + ports: + - 5432:5432 + keycloak: + image: quay.io/keycloak/keycloak:16.1.1 + volumes: + - ./target/keycloak-scim-1.0-SNAPSHOT-jar-with-dependencies.jar:/opt/jboss/keycloak/standalone/deployments/keycloak-scim-1.0-SNAPSHOT.jar + environment: + DB_VENDOR: POSTGRES + DB_ADDR: postgres + DB_DATABASE: keycloak + DB_USER: keycloak + DB_SCHEMA: public + DB_PASSWORD: keycloak + KEYCLOAK_USER: admin + KEYCLOAK_PASSWORD: admin + ports: + - 8080:8080 + depends_on: + - postgres + +volumes: + db: diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000000..9ec62e56e4 --- /dev/null +++ b/pom.xml @@ -0,0 +1,198 @@ + + + + 4.0.0 + + sh.libre.scim + keycloak-scim + 1.0-SNAPSHOT + + keycloak-scim + + http://www.example.com + + + UTF-8 + 11 + 11 + 16.1.0 + 1.6.10 + 2.12.1 + 3.15.1.Final + 1.7.0 + + + + + org.keycloak + keycloak-core + provided + ${keycloak.version} + + + org.keycloak + keycloak-server-spi + provided + ${keycloak.version} + + + org.keycloak + keycloak-server-spi-private + provided + ${keycloak.version} + + + org.keycloak + keycloak-services + provided + ${keycloak.version} + + + org.keycloak + keycloak-model-jpa + provided + ${keycloak.version} + + + com.unboundid.product.scim2 + scim2-sdk-client + 2.3.7 + + + javax.ws.rs + javax.ws.rs-api + 2.1.1 + provided + + + javax.xml.bind + jaxb-api + 2.3.1 + provided + + + com.fasterxml.jackson.core + jackson-core + ${jackson.version} + provided + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + provided + + + com.fasterxml.jackson.core + jackson-annotations + ${jackson.version} + provided + + + com.fasterxml.jackson.module + jackson-module-jaxb-annotations + ${jackson.version} + provided + + + com.fasterxml.jackson.datatype + jackson-datatype-jdk8 + ${jackson.version} + provided + + + com.fasterxml.jackson.jaxrs + jackson-jaxrs-json-provider + ${jackson.version} + provided + + + com.fasterxml.jackson.jaxrs + jackson-jaxrs-base + ${jackson.version} + provided + + + org.jboss.resteasy + resteasy-jaxrs + ${resteasy.version} + provided + + + org.jboss.resteasy + resteasy-multipart-provider + ${resteasy.version} + provided + + + org.jboss.resteasy + resteasy-jackson2-provider + ${resteasy.version} + provided + + + org.jboss.resteasy + resteasy-jaxb-provider + ${resteasy.version} + provided + + + org.jboss.resteasy + resteasy-client + ${resteasy.version} + provided + + + io.github.resilience4j + resilience4j-retry + ${resilience4jVersion} + + + org.hibernate + hibernate-core + 5.6.5.Final + + + org.hibernate + hibernate-validator + 7.0.2.Final + + + + + + + org.wildfly.plugins + wildfly-maven-plugin + 2.1.0 + + false + + + + org.apache.maven.plugins + maven-assembly-plugin + 2.6 + + + make-assembly + package + + single + + + + + ${main.class} + + + + jar-with-dependencies + + + + + + + + \ No newline at end of file diff --git a/src/main/java/sh/libre/scim/core/ScimClient.java b/src/main/java/sh/libre/scim/core/ScimClient.java new file mode 100644 index 0000000000..1e1533b593 --- /dev/null +++ b/src/main/java/sh/libre/scim/core/ScimClient.java @@ -0,0 +1,156 @@ +package sh.libre.scim.core; + +import com.unboundid.scim2.client.ScimService; +import com.unboundid.scim2.common.exceptions.ScimException; +import com.unboundid.scim2.common.types.Email; +import com.unboundid.scim2.common.types.Meta; +import com.unboundid.scim2.common.types.Name; +import com.unboundid.scim2.common.types.UserResource; +import io.github.resilience4j.core.IntervalFunction; +import io.github.resilience4j.retry.RetryConfig; +import io.github.resilience4j.retry.RetryRegistry; +import java.net.URI; +import java.util.ArrayList; +import java.lang.RuntimeException; +import javax.persistence.EntityManager; +import javax.persistence.NoResultException; +import javax.persistence.TypedQuery; +import javax.ws.rs.client.Client; + +import org.jboss.logging.Logger; +import org.jboss.resteasy.client.jaxrs.ResteasyClientBuilder; +import org.keycloak.models.UserModel; +import sh.libre.scim.jpa.ScimResource; + +public class ScimClient { + + final Logger LOGGER = Logger.getLogger(ScimClient.class); + final Client client = ResteasyClientBuilder.newClient(); + final ScimService scimService; + final RetryRegistry registry; + final String name; + final String realmId; + final EntityManager entityManager; + + public ScimClient(String name, String url, String realmId, EntityManager entityManager) { + this.name = name; + this.realmId = realmId; + this.entityManager = entityManager; + var target = client.target(url); + scimService = new ScimService(target); + + RetryConfig retryConfig = RetryConfig.custom() + .maxAttempts(10) + .intervalFunction(IntervalFunction.ofExponentialBackoff()) + .build(); + registry = RetryRegistry.of(retryConfig); + } + + public void createUser(UserModel kcUser) { + LOGGER.info("Create User"); + var user = toUser(kcUser); + var retry = registry.retry("create-" + kcUser.getId()); + var spUser = retry.executeSupplier(() -> { + try { + return scimService.create("Users", user); + } catch (ScimException e) { + throw new RuntimeException(e); + } + }); + var scimUser = toScimUser(spUser); + entityManager.persist(scimUser); + } + + public void replaceUser(UserModel kcUser) { + LOGGER.info("Replace User"); + try { + var resource = querUserById(kcUser.getId()); + var user = toUser(kcUser); + user.setId(resource.getRemoteId()); + var meta = new Meta(); + var uri = new URI("Users/" + user.getId()); + meta.setLocation(uri); + user.setMeta(meta); + var retry = registry.retry("replace-" + kcUser.getId()); + retry.executeSupplier(() -> { + try { + return scimService.replace(user); + } catch (ScimException e) { + throw new RuntimeException(e); + } + }); + } catch (NoResultException e) { + LOGGER.warnf("Failde to replce user %s, scim mapping not found", kcUser.getId()); + } catch (Exception e) { + LOGGER.error(e); + } + } + + public void deleteUser(String userId) { + LOGGER.info("Delete User"); + try { + var resource = querUserById(userId); + var retry = registry.retry("delete-" + userId); + retry.executeSupplier(() -> { + try { + scimService.delete("Users", resource.getRemoteId()); + } catch (ScimException e) { + throw new RuntimeException(e); + } + return ""; + }); + entityManager.remove(resource); + } catch (NoResultException e) { + LOGGER.warnf("Failde to replce user %s, scim mapping not found", userId); + } + } + + private TypedQuery queryUser(String query) { + return entityManager + .createNamedQuery(query, ScimResource.class) + .setParameter("realmId", realmId) + .setParameter("type", "Users") + .setParameter("serviceProvider", name); + } + + private ScimResource querUserById(String id) { + return queryUser("findByLocalId").setParameter("id", id).getSingleResult(); + } + + private ScimResource scimUser() { + var resource = new ScimResource(); + resource.setType("Users"); + resource.setRealmId(realmId); + resource.setServiceProvider(name); + return resource; + } + + private ScimResource toScimUser(UserResource user) { + var resource = scimUser(); + resource.setRemoteId(user.getId()); + resource.setLocalId(user.getExternalId()); + return resource; + } + + private UserResource toUser(UserModel kcUser) { + var user = new UserResource(); + user.setExternalId(kcUser.getId()); + user.setUserName(kcUser.getUsername()); + var name = new Name(); + name.setGivenName(kcUser.getFirstName()); + name.setFamilyName(kcUser.getLastName()); + user.setName(name); + + var emails = new ArrayList(); + if (kcUser.getEmail() != "") { + var email = new Email().setPrimary(true).setValue(kcUser.getEmail()); + emails.add(email); + } + user.setEmails(emails); + return user; + } + + public void close() { + client.close(); + } +} diff --git a/src/main/java/sh/libre/scim/core/ScimDispatcher.java b/src/main/java/sh/libre/scim/core/ScimDispatcher.java new file mode 100644 index 0000000000..40bef841e5 --- /dev/null +++ b/src/main/java/sh/libre/scim/core/ScimDispatcher.java @@ -0,0 +1,58 @@ +package sh.libre.scim.core; + +import java.util.ArrayList; +import java.util.function.Consumer; +import org.jboss.logging.Logger; +import javax.persistence.EntityManager; + +import org.keycloak.connections.jpa.JpaConnectionProvider; +import org.keycloak.models.KeycloakSession; + +public class ScimDispatcher { + final KeycloakSession session; + final EntityManager entityManager; + final Logger LOGGER = Logger.getLogger(ScimDispatcher.class); + ArrayList clients = new ArrayList(); + + public ScimDispatcher(KeycloakSession session) { + this.session = session; + entityManager = session.getProvider(JpaConnectionProvider.class).getEntityManager(); + reloadClients(); + } + + public void reloadClients() { + close(); + LOGGER.info("Cleared SCIM Clients"); + var realm = session.getContext().getRealm(); + clients = new ArrayList(); + var kcClients = session.clients().getClientsStream(realm); + for (var kcClient : kcClients.toList()) { + var endpoint = kcClient.getAttribute("scim-endpoint"); + var name = kcClient.getAttribute("scim-name"); + if (endpoint != "") { + if (name == "") { + name = kcClient.getName(); + } + clients.add(new ScimClient( + name, + endpoint, + realm.getId(), + entityManager)); + LOGGER.infof("Added %s SCIM Client (%s)", name, endpoint); + } + } + + } + + public void close() { + for (var client : clients) { + client.close(); + } + } + + public void run(Consumer f) { + for (var client : clients) { + f.accept(client); + } + } +} diff --git a/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java new file mode 100644 index 0000000000..ff23030ed2 --- /dev/null +++ b/src/main/java/sh/libre/scim/event/ScimEventListenerProvider.java @@ -0,0 +1,72 @@ +package sh.libre.scim.event; + +import org.jboss.logging.Logger; +import org.keycloak.events.Event; +import org.keycloak.events.EventListenerProvider; +import org.keycloak.events.EventType; +import org.keycloak.events.admin.AdminEvent; +import org.keycloak.events.admin.OperationType; +import org.keycloak.events.admin.ResourceType; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.UserModel; + +import sh.libre.scim.core.ScimDispatcher; + +public class ScimEventListenerProvider implements EventListenerProvider { + final Logger LOGGER = Logger.getLogger(ScimEventListenerProvider.class); + ScimDispatcher dispatcher; + KeycloakSession session; + + public ScimEventListenerProvider(KeycloakSession session) { + this.session = session; + dispatcher = new ScimDispatcher(session); + + } + + @Override + public void close() { + dispatcher.close(); + } + + @Override + public void onEvent(Event event) { + if (event.getType() == EventType.REGISTER) { + var user = getUser(event.getUserId()); + dispatcher.run((client) -> client.createUser(user)); + } + if (event.getType() == EventType.UPDATE_EMAIL || event.getType() == EventType.UPDATE_PROFILE) { + var user = getUser(event.getUserId()); + dispatcher.run((client) -> client.replaceUser(user)); + } + if (event.getType() == EventType.DELETE_ACCOUNT) { + dispatcher.run((client) -> client.deleteUser(event.getUserId())); + } + } + + @Override + public void onEvent(AdminEvent event, boolean includeRepresentation) { + if (event.getResourceType() == ResourceType.CLIENT) { + dispatcher.reloadClients(); + } + if (event.getResourceType() == ResourceType.USER) { + var userId = event.getResourcePath().replace("users/", ""); + LOGGER.infof("%s %s", userId, event.getOperationType()); + if (event.getOperationType() == OperationType.CREATE) { + // session.getTransactionManager().rollback(); + var user = getUser(userId); + dispatcher.run((client) -> client.createUser(user)); + } + if (event.getOperationType() == OperationType.UPDATE) { + var user = getUser(userId); + dispatcher.run((client) -> client.replaceUser(user)); + } + if (event.getOperationType() == OperationType.DELETE) { + dispatcher.run((client) -> client.deleteUser(userId)); + } + } + } + + private UserModel getUser(String id) { + return session.users().getUserById(session.getContext().getRealm(), id); + } +} diff --git a/src/main/java/sh/libre/scim/event/ScimEventListenerProviderFactory.java b/src/main/java/sh/libre/scim/event/ScimEventListenerProviderFactory.java new file mode 100644 index 0000000000..debe59b8fb --- /dev/null +++ b/src/main/java/sh/libre/scim/event/ScimEventListenerProviderFactory.java @@ -0,0 +1,32 @@ +package sh.libre.scim.event; + +import org.keycloak.Config.Scope; +import org.keycloak.events.EventListenerProvider; +import org.keycloak.events.EventListenerProviderFactory; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; + +public class ScimEventListenerProviderFactory implements EventListenerProviderFactory { + + @Override + public EventListenerProvider create(KeycloakSession session) { + return new ScimEventListenerProvider(session); + } + + @Override + public void init(Scope config) { + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public void close() { + } + + @Override + public String getId() { + return "scim"; + } +} diff --git a/src/main/java/sh/libre/scim/jpa/ScimResource.java b/src/main/java/sh/libre/scim/jpa/ScimResource.java new file mode 100644 index 0000000000..0034e257eb --- /dev/null +++ b/src/main/java/sh/libre/scim/jpa/ScimResource.java @@ -0,0 +1,88 @@ +package sh.libre.scim.jpa; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.IdClass; +import javax.persistence.NamedQuery; +import javax.persistence.NamedQueries; +import javax.persistence.Table; + +@Entity +@IdClass(ScimResourceId.class) +@Table(name = "SCIM_RESOURCE") +@NamedQueries({ + @NamedQuery(name = "findByLocalId", query = "from ScimResource where realmId = :realmId and type = :type and serviceProvider = :serviceProvider and localId = :id"), + @NamedQuery(name = "findByRemoteId", query = "from ScimResource where realmId = :realmId and type = :type and serviceProvider = :serviceProvider and remoteId = :id") }) +public class ScimResource { + + @Id + @Column(name = "REALM_ID", nullable = false) + private String realmId; + + @Id + @Column(name = "SERVICE_PROVIDER", nullable = false) + private String serviceProvider; + + @Id + @Column(name = "TYPE", nullable = false) + private String type; + + @Id + @Column(name = "REMOTE_ID", nullable = false) + private String remoteId; + + @Column(name = "LOCAL_ID", nullable = false) + private String localId; + + public ScimResource() { + } + + public ScimResource(String realmId, String serviceProvider, String type, String remoteId, String localId) { + this.realmId = realmId; + this.serviceProvider = serviceProvider; + this.type = type; + this.remoteId = remoteId; + this.localId = localId; + } + + public String getRealmId() { + return realmId; + } + + public void setRealmId(String realmId) { + this.realmId = realmId; + } + + public String getServiceProvider() { + return serviceProvider; + } + + public void setServiceProvider(String serviceProvider) { + this.serviceProvider = serviceProvider; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getRemoteId() { + return remoteId; + } + + public void setRemoteId(String remoteId) { + this.remoteId = remoteId; + } + + public String getLocalId() { + return localId; + } + + public void setLocalId(String localId) { + this.localId = localId; + } +} diff --git a/src/main/java/sh/libre/scim/jpa/ScimResourceId.java b/src/main/java/sh/libre/scim/jpa/ScimResourceId.java new file mode 100644 index 0000000000..c97ff53a52 --- /dev/null +++ b/src/main/java/sh/libre/scim/jpa/ScimResourceId.java @@ -0,0 +1,71 @@ +package sh.libre.scim.jpa; + +import java.io.Serializable; +import java.util.Objects; + +public class ScimResourceId implements Serializable { + private String realmId; + private String serviceProvider; + private String type; + private String remoteId; + + public ScimResourceId() { + } + + public ScimResourceId(String realmId, String serviceProvider, String type, String remoteId) { + this.realmId = realmId; + this.serviceProvider = serviceProvider; + this.type = type; + this.remoteId = remoteId; + } + + public String getRealmId() { + return realmId; + } + + public void setRealmId(String realmId) { + this.realmId = realmId; + } + + public String getServiceProvider() { + return serviceProvider; + } + + public void setServiceProvider(String serviceProvider) { + this.serviceProvider = serviceProvider; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getRemoteId() { + return realmId; + } + + public void setRemoteId(String remoteId) { + this.remoteId = remoteId; + } + + @Override + public boolean equals(Object other) { + if (this == other) + return true; + if (!(other instanceof ScimResourceId)) + return false; + var o = (ScimResourceId) other; + return (o.realmId == realmId && + o.serviceProvider == serviceProvider && + o.type == type && + o.remoteId == remoteId); + } + + @Override + public int hashCode() { + return Objects.hash(realmId, serviceProvider, type, remoteId); + } +} diff --git a/src/main/java/sh/libre/scim/jpa/ScimResourceProvider.java b/src/main/java/sh/libre/scim/jpa/ScimResourceProvider.java new file mode 100644 index 0000000000..3fcd97bd3c --- /dev/null +++ b/src/main/java/sh/libre/scim/jpa/ScimResourceProvider.java @@ -0,0 +1,29 @@ +package sh.libre.scim.jpa; + +import java.util.List; + +import org.keycloak.connections.jpa.entityprovider.JpaEntityProvider; + +import java.util.Arrays; + +public class ScimResourceProvider implements JpaEntityProvider { + + @Override + public List> getEntities() { + return Arrays.asList(ScimResource.class); + } + + @Override + public String getChangelogLocation() { + return "META-INF/scim-resource-changelog.xml"; + } + + @Override + public void close() { + } + + @Override + public String getFactoryId() { + return ScimResourceProviderFactory.ID; + } +} \ No newline at end of file diff --git a/src/main/java/sh/libre/scim/jpa/ScimResourceProviderFactory.java b/src/main/java/sh/libre/scim/jpa/ScimResourceProviderFactory.java new file mode 100644 index 0000000000..682ccbee04 --- /dev/null +++ b/src/main/java/sh/libre/scim/jpa/ScimResourceProviderFactory.java @@ -0,0 +1,32 @@ +package sh.libre.scim.jpa; + +import org.keycloak.Config.Scope; +import org.keycloak.connections.jpa.entityprovider.JpaEntityProvider; +import org.keycloak.connections.jpa.entityprovider.JpaEntityProviderFactory; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; + +public class ScimResourceProviderFactory implements JpaEntityProviderFactory { + final static String ID ="scim-resource"; + @Override + public void close() { + } + + @Override + public JpaEntityProvider create(KeycloakSession session) { + return new ScimResourceProvider(); + } + + @Override + public String getId() { + return ID; + } + + @Override + public void init(Scope scope) { + } + + @Override + public void postInit(KeycloakSessionFactory sessionFactory) { + } +} diff --git a/src/main/resources/META-INF/scim-resource-changelog.xml b/src/main/resources/META-INF/scim-resource-changelog.xml new file mode 100644 index 0000000000..954a44acb8 --- /dev/null +++ b/src/main/resources/META-INF/scim-resource-changelog.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/META-INF/services/org.keycloak.connections.jpa.entityprovider.JpaEntityProviderFactory b/src/main/resources/META-INF/services/org.keycloak.connections.jpa.entityprovider.JpaEntityProviderFactory new file mode 100644 index 0000000000..b3cb1a13e3 --- /dev/null +++ b/src/main/resources/META-INF/services/org.keycloak.connections.jpa.entityprovider.JpaEntityProviderFactory @@ -0,0 +1 @@ +sh.libre.scim.jpa.ScimResourceProviderFactory diff --git a/src/main/resources/META-INF/services/org.keycloak.events.EventListenerProviderFactory b/src/main/resources/META-INF/services/org.keycloak.events.EventListenerProviderFactory new file mode 100644 index 0000000000..7e2a6edd9c --- /dev/null +++ b/src/main/resources/META-INF/services/org.keycloak.events.EventListenerProviderFactory @@ -0,0 +1 @@ +sh.libre.scim.event.ScimEventListenerProviderFactory