This commit is contained in:
Hugo Renard 2022-02-07 09:38:46 +01:00
parent e0e7060a30
commit f2dbb59e0a
Signed by: hougo
GPG key ID: 3A285FD470209C59
15 changed files with 801 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

3
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"java.configuration.updateBuildConfiguration": "automatic"
}

32
docker-compose.yml Normal file
View file

@ -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:

198
pom.xml Normal file
View file

@ -0,0 +1,198 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>sh.libre.scim</groupId>
<artifactId>keycloak-scim</artifactId>
<version>1.0-SNAPSHOT</version>
<name>keycloak-scim</name>
<!-- FIXME change it to the project's website -->
<url>http://www.example.com</url>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<keycloak.version>16.1.0</keycloak.version>
<kotlin.version>1.6.10</kotlin.version>
<jackson.version>2.12.1</jackson.version>
<resteasy.version>3.15.1.Final</resteasy.version>
<resilience4jVersion>1.7.0</resilience4jVersion>
</properties>
<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-core</artifactId>
<scope>provided</scope>
<version>${keycloak.version}</version>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi</artifactId>
<scope>provided</scope>
<version>${keycloak.version}</version>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi-private</artifactId>
<scope>provided</scope>
<version>${keycloak.version}</version>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-services</artifactId>
<scope>provided</scope>
<version>${keycloak.version}</version>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-model-jpa</artifactId>
<scope>provided</scope>
<version>${keycloak.version}</version>
</dependency>
<dependency>
<groupId>com.unboundid.product.scim2</groupId>
<artifactId>scim2-sdk-client</artifactId>
<version>2.3.7</version>
</dependency>
<dependency>
<groupId>javax.ws.rs</groupId>
<artifactId>javax.ws.rs-api</artifactId>
<version>2.1.1</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.3.1</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>${jackson.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
<version>${jackson.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.module</groupId>
<artifactId>jackson-module-jaxb-annotations</artifactId>
<version>${jackson.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jdk8</artifactId>
<version>${jackson.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.jaxrs</groupId>
<artifactId>jackson-jaxrs-json-provider</artifactId>
<version>${jackson.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.jaxrs</groupId>
<artifactId>jackson-jaxrs-base</artifactId>
<version>${jackson.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.jboss.resteasy</groupId>
<artifactId>resteasy-jaxrs</artifactId>
<version>${resteasy.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.jboss.resteasy</groupId>
<artifactId>resteasy-multipart-provider</artifactId>
<version>${resteasy.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.jboss.resteasy</groupId>
<artifactId>resteasy-jackson2-provider</artifactId>
<version>${resteasy.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.jboss.resteasy</groupId>
<artifactId>resteasy-jaxb-provider</artifactId>
<version>${resteasy.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.jboss.resteasy</groupId>
<artifactId>resteasy-client</artifactId>
<version>${resteasy.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-retry</artifactId>
<version>${resilience4jVersion}</version>
</dependency>
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-core</artifactId>
<version>5.6.5.Final</version>
</dependency>
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-validator</artifactId>
<version>7.0.2.Final</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.wildfly.plugins</groupId>
<artifactId>wildfly-maven-plugin</artifactId>
<version>2.1.0</version>
<configuration>
<skip>false</skip>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<version>2.6</version>
<executions>
<execution>
<id>make-assembly</id>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
<configuration>
<archive>
<manifest>
<mainClass>${main.class}</mainClass>
</manifest>
</archive>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View file

@ -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<ScimResource> 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<Email>();
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();
}
}

View file

@ -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<ScimClient> clients = new ArrayList<ScimClient>();
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<ScimClient>();
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<ScimClient> f) {
for (var client : clients) {
f.accept(client);
}
}
}

View file

@ -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);
}
}

View file

@ -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";
}
}

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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<Class<?>> 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;
}
}

View file

@ -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) {
}
}

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd">
<changeSet author="contact@indiehosters.net" id="scim-resource-1.0">
<createTable tableName="SCIM_RESOURCE">
<column name="REALM_ID" type="VARCHAR(36)">
<constraints nullable="false" />
</column>
<column name="SERVICE_PROVIDER" type="VARCHAR(36)">
<constraints nullable="false" />
</column>
<column name="TYPE" type="VARCHAR(36)">
<constraints nullable="false" />
</column>
<column name="REMOTE_ID" type="VARCHAR(36)">
<constraints nullable="false" />
</column>
<column name="LOCAL_ID" type="VARCHAR(36)">
<constraints nullable="false" />
</column>
</createTable>
<addPrimaryKey constraintName="PK_SCIM_RESOURCE" tableName="SCIM_RESOURCE" columnNames="REALM_ID,SERVICE_PROVIDER,TYPE,REMOTE_ID" />
</changeSet>
</databaseChangeLog>

View file

@ -0,0 +1 @@
sh.libre.scim.jpa.ScimResourceProviderFactory

View file

@ -0,0 +1 @@
sh.libre.scim.event.ScimEventListenerProviderFactory