Compare commits
2 commits
9bf20bb2cd
...
1c7f7e7e54
Author | SHA1 | Date | |
---|---|---|---|
1c7f7e7e54 | |||
|
83065b85a2 |
49 changed files with 2498 additions and 57 deletions
|
@ -114,6 +114,8 @@ public class Profile {
|
|||
|
||||
OID4VC_VCI("Support for the OID4VCI protocol as part of OID4VC.", Type.EXPERIMENTAL),
|
||||
|
||||
SCIM("Synchronise users and groups with registered SCIM endpoints", Type.EXPERIMENTAL),
|
||||
|
||||
OPENTELEMETRY("OpenTelemetry Tracing", Type.PREVIEW),
|
||||
|
||||
DECLARATIVE_UI("declarative ui spi", Type.EXPERIMENTAL),
|
||||
|
|
6
dependencies/server-all/pom.xml
vendored
6
dependencies/server-all/pom.xml
vendored
|
@ -70,6 +70,12 @@
|
|||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-kerberos-federation</artifactId>
|
||||
</dependency>
|
||||
<!-- SCIM federation -->
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-scim-federation</artifactId>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
<!-- saml -->
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 81 KiB |
Binary file not shown.
After Width: | Height: | Size: 104 KiB |
|
@ -0,0 +1,74 @@
|
|||
[[_scim]]
|
||||
|
||||
=== SCIM client capabilities
|
||||
|
||||
{project_name} includes a http://www.simplecloud.info[SCIM2] client allowing to :
|
||||
|
||||
* Declare SCIM endpoints (through the identity federation UI). Any tool implementing SCIM protocol can be wired to the
|
||||
{project_name} instance through this declaration.
|
||||
* Propagate users and groups from {project_name} to SCIM endpoints : when a user/group gets created or modified in {project_name},
|
||||
the modification is forwarded to all declared SCIM endpoints through SCIM calls within the transaction scope. If
|
||||
propagation fails, changes can be rolled back or not according to a configurable rollback strategy.
|
||||
* Synchronize users and groups from SCIM endpoints (through the {project_name} synchronization mechanism).
|
||||
|
||||
See https://datatracker.ietf.org/doc/html/rfc7643[RFC7643]
|
||||
and https://datatracker.ietf.org/doc/html/rfc7644[RFC7644] for further details
|
||||
|
||||
==== Enabling SCIM extension
|
||||
|
||||
[NOTE]
|
||||
====
|
||||
This extension is currently in experimental mode, and requires the ```SCIM``` experimental Profile to be enabled
|
||||
====
|
||||
|
||||
.Procedure
|
||||
. Click on *Admin Console > Realm Settings > Events* in the menu.
|
||||
. Add `scim` to the list of event listeners
|
||||
image:images/scim-event-listener-page.png[Enable SCIM Event listeners]
|
||||
. Save
|
||||
|
||||
==== Registering SCIM Service Providers
|
||||
|
||||
.Procedure
|
||||
. Click on *User federation > Add Scim Providers*
|
||||
image:images/scim-federation-provider-page.png[Configure SCIM service provider]
|
||||
. Fill required fields according to the SCIM endpoint you are wiring
|
||||
. If you enable import during sync then you can choose between to following import actions:
|
||||
|
||||
- Create Local - adds users to keycloak
|
||||
- Nothing
|
||||
- Delete Remote - deletes users from the remote application
|
||||
|
||||
==== Sync
|
||||
|
||||
You can set up a periodic sync for all users or just changed users - it's not mandatory. You can either do:
|
||||
|
||||
- Periodic Full Sync
|
||||
- Periodic Changed User Sync
|
||||
|
||||
|
||||
==== Technical notes
|
||||
|
||||
===== Motivation
|
||||
|
||||
We want to build a unified collaborative platform based on multiple applications. To do that, we need a way to propagate
|
||||
immediately changes made in Keycloak to all these applications. And we want to keep using OIDC or SAML as the
|
||||
authentication protocol.
|
||||
|
||||
This will allow users to collaborate seamlessly across the platform without requiring every user to have connected once
|
||||
to each application. This will also ease GDRP compliance because deleting a user in Keycloak will delete the user from
|
||||
every app. The SCIM protocol is standard, comprehensible and easy to implement. It's a perfect fit for our goal.
|
||||
|
||||
We chose to build application extensions/plugins because it's easier to deploy and thus will benefit to a larger portion
|
||||
of the FOSS community.
|
||||
|
||||
===== Keycloak specific
|
||||
|
||||
This extension uses 3 concepts in KeyCloak :
|
||||
|
||||
- Event Listener : used to listen for changes within Keycloak (e.g. User creation, Group deletion...) and propagate
|
||||
them to registered SCIM service providers through SCIM requests.
|
||||
- Federation Provider : used to set up all the SCIM service providers endpoint without creating our own UI.
|
||||
- JPA Entity Provider : used to save the mapping between the local IDs and the service providers IDs.
|
||||
|
||||
It is based on https://github.com/Captain-P-Goldfish/SCIM-SDK[Scim SDK].
|
|
@ -36,6 +36,7 @@
|
|||
<module>kerberos</module>
|
||||
<module>ldap</module>
|
||||
<module>sssd</module>
|
||||
<module>scim</module>
|
||||
</modules>
|
||||
|
||||
</project>
|
||||
|
|
83
federation/scim/pom.xml
Normal file
83
federation/scim/pom.xml
Normal file
|
@ -0,0 +1,83 @@
|
|||
<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">
|
||||
|
||||
<parent>
|
||||
<artifactId>keycloak-parent</artifactId>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<version>999.0.0-SNAPSHOT</version>
|
||||
<relativePath>../../pom.xml</relativePath>
|
||||
</parent>
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<artifactId>keycloak-scim-federation</artifactId>
|
||||
<name>Keycloak Federation from SCIM endpoints</name>
|
||||
<description>
|
||||
This extension adds SCIM2 client capabilities to Keycloak using [Scim SDK](https://github.com/Captain-P-Goldfish/SCIM-SDK).
|
||||
It allows to :
|
||||
* Declare SCIM endpoints (through the identity federation UI). Any tool implementing SCIM protocol can be wired to the
|
||||
Keycloak instance through this declaration.
|
||||
* Propagate users and groups from Keycloak to SCIM endpoints : when a user/group gets created or modified in Keycloak,
|
||||
the modification is forwarded to all declared SCIM endpoints through SCIM calls within the transaction scope. If
|
||||
propagation fails, changes can be rolled back or not according to a configurable rollback strategy.
|
||||
* Import users and groups from SCIM endpoints (through the Keycloak synchronization mechanism).
|
||||
See [RFC7643](https://datatracker.ietf.org/doc/html/rfc7643)
|
||||
and [RFC7644](https://datatracker.ietf.org/doc/html/rfc7644)) for further details
|
||||
</description>
|
||||
|
||||
<properties>
|
||||
<scim-sdk-version>1.26.0</scim-sdk-version>
|
||||
<r4j-version>2.2.0</r4j-version>
|
||||
<maven-wildfly-plugin.version>2.0.2.Final</maven-wildfly-plugin.version>
|
||||
</properties>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-core</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-server-spi</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-server-spi-private</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-services</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-model-jpa</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jboss.logging</groupId>
|
||||
<artifactId>jboss-logging</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.github.resilience4j</groupId>
|
||||
<artifactId>resilience4j-retry</artifactId>
|
||||
<version>${r4j-version}</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>de.captaingoldfish</groupId>
|
||||
<artifactId>scim-sdk-common</artifactId>
|
||||
<version>${scim-sdk-version}</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>de.captaingoldfish</groupId>
|
||||
<artifactId>scim-sdk-client</artifactId>
|
||||
<version>${scim-sdk-version}</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
|
@ -0,0 +1,179 @@
|
|||
package org.keycloak.federation.scim.core;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.component.ComponentModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.federation.scim.core.exceptions.ScimExceptionHandler;
|
||||
import org.keycloak.federation.scim.core.exceptions.ScimPropagationException;
|
||||
import org.keycloak.federation.scim.core.exceptions.SkipOrStopApproach;
|
||||
import org.keycloak.federation.scim.core.exceptions.SkipOrStopStrategy;
|
||||
import org.keycloak.federation.scim.core.service.AbstractScimService;
|
||||
import org.keycloak.federation.scim.core.service.GroupScimService;
|
||||
import org.keycloak.federation.scim.core.service.UserScimService;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* In charge of sending SCIM Request to all registered Scim endpoints.
|
||||
*/
|
||||
public class ScimDispatcher {
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(ScimDispatcher.class);
|
||||
|
||||
private final KeycloakSession session;
|
||||
private final ScimExceptionHandler exceptionHandler;
|
||||
private final SkipOrStopStrategy skipOrStopStrategy;
|
||||
private final List<UserScimService> userScimServices = new ArrayList<>();
|
||||
private final List<GroupScimService> groupScimServices = new ArrayList<>();
|
||||
private boolean clientsInitialized = false;
|
||||
|
||||
public ScimDispatcher(KeycloakSession session) {
|
||||
this.session = session;
|
||||
this.exceptionHandler = new ScimExceptionHandler(session);
|
||||
// By default, use a permissive Skip or Stop strategy
|
||||
this.skipOrStopStrategy = SkipOrStopApproach.ALWAYS_SKIP_AND_CONTINUE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all active ScimStorageProviderFactory and create new ScimClients for each of them
|
||||
*/
|
||||
public void refreshActiveScimEndpoints() {
|
||||
// Step 1: close existing clients (as configuration may have changed)
|
||||
groupScimServices.forEach(GroupScimService::close);
|
||||
groupScimServices.clear();
|
||||
userScimServices.forEach(UserScimService::close);
|
||||
userScimServices.clear();
|
||||
|
||||
// Step 2: Get All SCIM endpoints defined in Admin Console (enabled ScimStorageProviderFactory)
|
||||
session.getContext().getRealm().getComponentsStream().filter(
|
||||
m -> ScimEndpointConfigurationStorageProviderFactory.ID.equals(m.getProviderId()) && m.get("enabled", true))
|
||||
.forEach(scimEndpointConfigurationRaw -> {
|
||||
try {
|
||||
ScrimEndPointConfiguration scrimEndPointConfiguration = new ScrimEndPointConfiguration(
|
||||
scimEndpointConfigurationRaw);
|
||||
|
||||
// Step 3 : create scim clients for each endpoint
|
||||
if (scimEndpointConfigurationRaw.get(ScrimEndPointConfiguration.CONF_KEY_PROPAGATION_GROUP, false)) {
|
||||
GroupScimService groupScimService = new GroupScimService(session, scrimEndPointConfiguration,
|
||||
skipOrStopStrategy);
|
||||
groupScimServices.add(groupScimService);
|
||||
}
|
||||
if (scimEndpointConfigurationRaw.get(ScrimEndPointConfiguration.CONF_KEY_PROPAGATION_USER, false)) {
|
||||
UserScimService userScimService = new UserScimService(session, scrimEndPointConfiguration,
|
||||
skipOrStopStrategy);
|
||||
userScimServices.add(userScimService);
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
if (skipOrStopStrategy.allowInvalidEndpointConfiguration()) {
|
||||
LOGGER.warn("[SCIM] Invalid Endpoint configuration " + scimEndpointConfigurationRaw.getId(), e);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void dispatchUserModificationToAll(SCIMPropagationConsumer<UserScimService> operationToDispatch) {
|
||||
initializeClientsIfNeeded();
|
||||
Set<UserScimService> servicesCorrectlyPropagated = new LinkedHashSet<>();
|
||||
userScimServices.forEach(userScimService -> {
|
||||
try {
|
||||
operationToDispatch.acceptThrows(userScimService);
|
||||
servicesCorrectlyPropagated.add(userScimService);
|
||||
} catch (ScimPropagationException e) {
|
||||
exceptionHandler.handleException(userScimService.getConfiguration(), e);
|
||||
}
|
||||
});
|
||||
// TODO we could iterate on servicesCorrectlyPropagated to undo modification on already handled SCIM endpoints
|
||||
LOGGER.infof("[SCIM] User operation dispatched to %d SCIM server", servicesCorrectlyPropagated.size());
|
||||
}
|
||||
|
||||
public void dispatchGroupModificationToAll(SCIMPropagationConsumer<GroupScimService> operationToDispatch) {
|
||||
initializeClientsIfNeeded();
|
||||
Set<GroupScimService> servicesCorrectlyPropagated = new LinkedHashSet<>();
|
||||
groupScimServices.forEach(groupScimService -> {
|
||||
try {
|
||||
operationToDispatch.acceptThrows(groupScimService);
|
||||
servicesCorrectlyPropagated.add(groupScimService);
|
||||
} catch (ScimPropagationException e) {
|
||||
exceptionHandler.handleException(groupScimService.getConfiguration(), e);
|
||||
}
|
||||
});
|
||||
// TODO we could iterate on servicesCorrectlyPropagated to undo modification on already handled SCIM endpoints
|
||||
LOGGER.infof("[SCIM] Group operation dispatched to %d SCIM server", servicesCorrectlyPropagated.size());
|
||||
}
|
||||
|
||||
public void dispatchUserModificationToOne(ComponentModel scimServerConfiguration,
|
||||
SCIMPropagationConsumer<UserScimService> operationToDispatch) {
|
||||
initializeClientsIfNeeded();
|
||||
// Scim client should already have been created
|
||||
Optional<UserScimService> matchingClient = userScimServices.stream()
|
||||
.filter(u -> u.getConfiguration().getId().equals(scimServerConfiguration.getId())).findFirst();
|
||||
if (matchingClient.isPresent()) {
|
||||
try {
|
||||
operationToDispatch.acceptThrows(matchingClient.get());
|
||||
LOGGER.infof("[SCIM] User operation dispatched to SCIM server %s",
|
||||
matchingClient.get().getConfiguration().getName());
|
||||
} catch (ScimPropagationException e) {
|
||||
exceptionHandler.handleException(matchingClient.get().getConfiguration(), e);
|
||||
}
|
||||
} else {
|
||||
LOGGER.error("[SCIM] Could not find a Scim Client matching User endpoint configuration"
|
||||
+ scimServerConfiguration.getId());
|
||||
}
|
||||
}
|
||||
|
||||
public void dispatchGroupModificationToOne(ComponentModel scimServerConfiguration,
|
||||
SCIMPropagationConsumer<GroupScimService> operationToDispatch) {
|
||||
initializeClientsIfNeeded();
|
||||
// Scim client should already have been created
|
||||
Optional<GroupScimService> matchingClient = groupScimServices.stream()
|
||||
.filter(u -> u.getConfiguration().getId().equals(scimServerConfiguration.getId())).findFirst();
|
||||
if (matchingClient.isPresent()) {
|
||||
try {
|
||||
operationToDispatch.acceptThrows(matchingClient.get());
|
||||
LOGGER.infof("[SCIM] Group operation dispatched to SCIM server %s",
|
||||
matchingClient.get().getConfiguration().getName());
|
||||
} catch (ScimPropagationException e) {
|
||||
exceptionHandler.handleException(matchingClient.get().getConfiguration(), e);
|
||||
}
|
||||
} else {
|
||||
LOGGER.error("[SCIM] Could not find a Scim Client matching Group endpoint configuration"
|
||||
+ scimServerConfiguration.getId());
|
||||
}
|
||||
}
|
||||
|
||||
public void close() {
|
||||
for (GroupScimService c : groupScimServices) {
|
||||
c.close();
|
||||
}
|
||||
for (UserScimService c : userScimServices) {
|
||||
c.close();
|
||||
}
|
||||
groupScimServices.clear();
|
||||
userScimServices.clear();
|
||||
}
|
||||
|
||||
private void initializeClientsIfNeeded() {
|
||||
if (!clientsInitialized) {
|
||||
clientsInitialized = true;
|
||||
refreshActiveScimEndpoints();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A Consumer that throws ScimPropagationException.
|
||||
*
|
||||
* @param <T> An {@link AbstractScimService to call}
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface SCIMPropagationConsumer<T> {
|
||||
|
||||
void acceptThrows(T elem) throws ScimPropagationException;
|
||||
|
||||
}
|
||||
}
|
|
@ -0,0 +1,118 @@
|
|||
package org.keycloak.federation.scim.core;
|
||||
|
||||
import de.captaingoldfish.scim.sdk.common.constants.HttpHeader;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import org.apache.commons.lang3.BooleanUtils;
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.component.ComponentModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.utils.KeycloakModelUtils;
|
||||
import org.keycloak.provider.ProviderConfigProperty;
|
||||
import org.keycloak.provider.ProviderConfigurationBuilder;
|
||||
import org.keycloak.storage.UserStorageProvider;
|
||||
import org.keycloak.storage.UserStorageProviderFactory;
|
||||
import org.keycloak.storage.UserStorageProviderModel;
|
||||
import org.keycloak.storage.user.ImportSynchronization;
|
||||
import org.keycloak.storage.user.SynchronizationResult;
|
||||
import org.keycloak.federation.scim.event.ScimBackgroundGroupMembershipUpdater;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Allows to register and configure Scim endpoints through Admin console, using the provided config properties.
|
||||
*/
|
||||
public class ScimEndpointConfigurationStorageProviderFactory implements
|
||||
UserStorageProviderFactory<ScimEndpointConfigurationStorageProviderFactory.ScimEndpointConfigurationStorageProvider>,
|
||||
ImportSynchronization {
|
||||
public static final String ID = "scim";
|
||||
private static final Logger LOGGER = Logger.getLogger(ScimEndpointConfigurationStorageProviderFactory.class);
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SynchronizationResult sync(KeycloakSessionFactory sessionFactory, String realmId, UserStorageProviderModel model) {
|
||||
// Manually Launch a synchronization between keycloack and the SCIM endpoint described in the given model
|
||||
LOGGER.infof("[SCIM] Sync from ScimStorageProvider - Realm %s - Model %s", realmId, model.getName());
|
||||
SynchronizationResult result = new SynchronizationResult();
|
||||
KeycloakModelUtils.runJobInTransaction(sessionFactory, session -> {
|
||||
RealmModel realm = session.realms().getRealm(realmId);
|
||||
session.getContext().setRealm(realm);
|
||||
ScimDispatcher dispatcher = new ScimDispatcher(session);
|
||||
if (BooleanUtils.TRUE.equals(model.get(ScrimEndPointConfiguration.CONF_KEY_PROPAGATION_USER))) {
|
||||
dispatcher.dispatchUserModificationToOne(model, client -> client.sync(result));
|
||||
}
|
||||
if (BooleanUtils.TRUE.equals(model.get(ScrimEndPointConfiguration.CONF_KEY_PROPAGATION_GROUP))) {
|
||||
dispatcher.dispatchGroupModificationToOne(model, client -> client.sync(result));
|
||||
}
|
||||
dispatcher.close();
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SynchronizationResult syncSince(Date lastSync, KeycloakSessionFactory sessionFactory, String realmId,
|
||||
UserStorageProviderModel model) {
|
||||
return this.sync(sessionFactory, realmId, model);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void postInit(KeycloakSessionFactory factory) {
|
||||
ScimBackgroundGroupMembershipUpdater scimBackgroundGroupMembershipUpdater = new ScimBackgroundGroupMembershipUpdater(
|
||||
factory);
|
||||
scimBackgroundGroupMembershipUpdater.startBackgroundUpdates();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ProviderConfigProperty> getConfigProperties() {
|
||||
// These Config Properties will be use to generate configuration page in Admin Console
|
||||
return ProviderConfigurationBuilder.create().property().name(ScrimEndPointConfiguration.CONF_KEY_ENDPOINT)
|
||||
.type(ProviderConfigProperty.STRING_TYPE).required(true).label("SCIM 2.0 endpoint")
|
||||
.helpText("External SCIM 2.0 base "
|
||||
+ "URL (/ServiceProviderConfig /Schemas and /ResourcesTypes should be accessible)")
|
||||
.add().property().name(ScrimEndPointConfiguration.CONF_KEY_CONTENT_TYPE).type(ProviderConfigProperty.LIST_TYPE)
|
||||
.label("Endpoint content type").helpText("Only used when endpoint doesn't support application/scim+json")
|
||||
.options(MediaType.APPLICATION_JSON, HttpHeader.SCIM_CONTENT_TYPE).defaultValue(HttpHeader.SCIM_CONTENT_TYPE)
|
||||
.add().property().name(ScrimEndPointConfiguration.CONF_KEY_AUTH_MODE).type(ProviderConfigProperty.LIST_TYPE)
|
||||
.label("Auth mode").helpText("Select the authorization mode").options("NONE", "BASIC_AUTH", "BEARER")
|
||||
.defaultValue("NONE").add().property().name(ScrimEndPointConfiguration.CONF_KEY_AUTH_USER)
|
||||
.type(ProviderConfigProperty.STRING_TYPE).label("Auth username").helpText("Required for basic authentication.")
|
||||
.add().property().name(ScrimEndPointConfiguration.CONF_KEY_AUTH_PASSWORD).type(ProviderConfigProperty.PASSWORD)
|
||||
.label("Auth password/token").helpText("Password or token required for basic or bearer authentication.").add()
|
||||
.property().name(ScrimEndPointConfiguration.CONF_KEY_PROPAGATION_USER).type(ProviderConfigProperty.BOOLEAN_TYPE)
|
||||
.label("Enable user propagation").helpText("Should operation on users be propagated to this provider?")
|
||||
.defaultValue(BooleanUtils.TRUE).add().property().name(ScrimEndPointConfiguration.CONF_KEY_PROPAGATION_GROUP)
|
||||
.type(ProviderConfigProperty.BOOLEAN_TYPE).label("Enable group propagation")
|
||||
.helpText("Should operation on groups be propagated to this provider?").defaultValue(BooleanUtils.TRUE).add()
|
||||
.property().name(ScrimEndPointConfiguration.CONF_KEY_SYNC_IMPORT).type(ProviderConfigProperty.BOOLEAN_TYPE)
|
||||
.label("Enable import during sync").add().property()
|
||||
.name(ScrimEndPointConfiguration.CONF_KEY_SYNC_IMPORT_ACTION).type(ProviderConfigProperty.LIST_TYPE)
|
||||
.label("Import action").helpText("What to do when the user doesn't exists in Keycloak.")
|
||||
.options("NOTHING", "CREATE_LOCAL", "DELETE_REMOTE").defaultValue("CREATE_LOCAL").add().property()
|
||||
.name(ScrimEndPointConfiguration.CONF_KEY_SYNC_REFRESH).type(ProviderConfigProperty.BOOLEAN_TYPE)
|
||||
.label("Enable refresh during sync").name(ScrimEndPointConfiguration.CONF_KEY_LOG_ALL_SCIM_REQUESTS)
|
||||
.type(ProviderConfigProperty.BOOLEAN_TYPE).label("Log SCIM requests and responses")
|
||||
.helpText("If true, all sent SCIM requests and responses will be logged").add().build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ScimEndpointConfigurationStorageProvider create(KeycloakSession session, ComponentModel model) {
|
||||
return new ScimEndpointConfigurationStorageProvider();
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty implementation : we used this {@link ScimEndpointConfigurationStorageProviderFactory} to generate Admin Console
|
||||
* page.
|
||||
*/
|
||||
public static final class ScimEndpointConfigurationStorageProvider implements UserStorageProvider {
|
||||
@Override
|
||||
public void close() {
|
||||
// Nothing to close here
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,100 @@
|
|||
package org.keycloak.federation.scim.core;
|
||||
|
||||
import de.captaingoldfish.scim.sdk.client.http.BasicAuth;
|
||||
import org.keycloak.component.ComponentModel;
|
||||
|
||||
public class ScrimEndPointConfiguration {
|
||||
// Configuration keys : also used in Admin Console page
|
||||
public static final String CONF_KEY_AUTH_MODE = "auth-mode";
|
||||
public static final String CONF_KEY_AUTH_PASSWORD = "auth-pass";
|
||||
public static final String CONF_KEY_AUTH_USER = "auth-user";
|
||||
public static final String CONF_KEY_CONTENT_TYPE = "content-type";
|
||||
public static final String CONF_KEY_ENDPOINT = "endpoint";
|
||||
public static final String CONF_KEY_SYNC_IMPORT_ACTION = "sync-import-action";
|
||||
public static final String CONF_KEY_SYNC_IMPORT = "sync-import";
|
||||
public static final String CONF_KEY_SYNC_REFRESH = "sync-refresh";
|
||||
public static final String CONF_KEY_PROPAGATION_USER = "propagation-user";
|
||||
public static final String CONF_KEY_PROPAGATION_GROUP = "propagation-group";
|
||||
public static final String CONF_KEY_LOG_ALL_SCIM_REQUESTS = "log-all-scim-requests";
|
||||
|
||||
private final String endPoint;
|
||||
private final String id;
|
||||
private final String name;
|
||||
private final String contentType;
|
||||
private final String authorizationHeaderValue;
|
||||
private final ImportAction importAction;
|
||||
private final boolean pullFromScimSynchronisationActivated;
|
||||
private final boolean pushToScimSynchronisationActivated;
|
||||
private final boolean logAllScimRequests;
|
||||
|
||||
public ScrimEndPointConfiguration(ComponentModel scimProviderConfiguration) {
|
||||
try {
|
||||
AuthMode authMode = AuthMode.valueOf(scimProviderConfiguration.get(CONF_KEY_AUTH_MODE));
|
||||
|
||||
authorizationHeaderValue = switch (authMode) {
|
||||
case BEARER -> "Bearer " + scimProviderConfiguration.get(CONF_KEY_AUTH_PASSWORD);
|
||||
case BASIC_AUTH -> {
|
||||
BasicAuth basicAuth = BasicAuth.builder().username(scimProviderConfiguration.get(CONF_KEY_AUTH_USER))
|
||||
.password(scimProviderConfiguration.get(CONF_KEY_AUTH_PASSWORD)).build();
|
||||
yield basicAuth.getAuthorizationHeaderValue();
|
||||
}
|
||||
case NONE -> "";
|
||||
};
|
||||
contentType = scimProviderConfiguration.get(CONF_KEY_CONTENT_TYPE, "");
|
||||
endPoint = scimProviderConfiguration.get(CONF_KEY_ENDPOINT, "");
|
||||
id = scimProviderConfiguration.getId();
|
||||
name = scimProviderConfiguration.getName();
|
||||
importAction = ImportAction.valueOf(scimProviderConfiguration.get(CONF_KEY_SYNC_IMPORT_ACTION));
|
||||
pullFromScimSynchronisationActivated = scimProviderConfiguration.get(CONF_KEY_SYNC_IMPORT, false);
|
||||
pushToScimSynchronisationActivated = scimProviderConfiguration.get(CONF_KEY_SYNC_REFRESH, false);
|
||||
logAllScimRequests = scimProviderConfiguration.get(CONF_KEY_LOG_ALL_SCIM_REQUESTS, false);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new IllegalArgumentException(
|
||||
"authMode '" + scimProviderConfiguration.get(CONF_KEY_AUTH_MODE) + "' is not supported");
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isPushToScimSynchronisationActivated() {
|
||||
return pushToScimSynchronisationActivated;
|
||||
}
|
||||
|
||||
public boolean isPullFromScimSynchronisationActivated() {
|
||||
return pullFromScimSynchronisationActivated;
|
||||
}
|
||||
|
||||
public String getContentType() {
|
||||
return contentType;
|
||||
}
|
||||
|
||||
public String getAuthorizationHeaderValue() {
|
||||
return authorizationHeaderValue;
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public ImportAction getImportAction() {
|
||||
return importAction;
|
||||
}
|
||||
|
||||
public String getEndPoint() {
|
||||
return endPoint;
|
||||
}
|
||||
|
||||
public boolean isLogAllScimRequests() {
|
||||
return logAllScimRequests;
|
||||
}
|
||||
|
||||
public enum AuthMode {
|
||||
BEARER, BASIC_AUTH, NONE
|
||||
}
|
||||
|
||||
public enum ImportAction {
|
||||
CREATE_LOCAL, DELETE_REMOTE, NOTHING
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package org.keycloak.federation.scim.core.exceptions;
|
||||
|
||||
public class InconsistentScimMappingException extends ScimPropagationException {
|
||||
public InconsistentScimMappingException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
package org.keycloak.federation.scim.core.exceptions;
|
||||
|
||||
import de.captaingoldfish.scim.sdk.client.response.ServerResponse;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public class InvalidResponseFromScimEndpointException extends ScimPropagationException {
|
||||
|
||||
private final transient Optional<ServerResponse> response;
|
||||
|
||||
public InvalidResponseFromScimEndpointException(ServerResponse response, String message) {
|
||||
super(message);
|
||||
this.response = Optional.of(response);
|
||||
}
|
||||
|
||||
public InvalidResponseFromScimEndpointException(String message, Exception e) {
|
||||
super(message, e);
|
||||
this.response = Optional.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty response can occur if a major exception was thrown while retrying the request.
|
||||
*/
|
||||
public Optional<ServerResponse> getResponse() {
|
||||
return response;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
package org.keycloak.federation.scim.core.exceptions;
|
||||
|
||||
import com.google.common.collect.Lists;
|
||||
import org.keycloak.federation.scim.core.ScrimEndPointConfiguration;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
public enum RollbackApproach implements RollbackStrategy {
|
||||
ALWAYS_ROLLBACK {
|
||||
@Override
|
||||
public boolean shouldRollback(ScrimEndPointConfiguration configuration, ScimPropagationException e) {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
NEVER_ROLLBACK {
|
||||
@Override
|
||||
public boolean shouldRollback(ScrimEndPointConfiguration configuration, ScimPropagationException e) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
CRITICAL_ONLY_ROLLBACK {
|
||||
@Override
|
||||
public boolean shouldRollback(ScrimEndPointConfiguration configuration, ScimPropagationException e) {
|
||||
if (e instanceof InconsistentScimMappingException) {
|
||||
// Occurs when mapping between a SCIM resource and a keycloak user failed (missing, ambiguous..)
|
||||
// Log can be sufficient here, no rollback required
|
||||
return false;
|
||||
}
|
||||
if (e instanceof UnexpectedScimDataException) {
|
||||
// Occurs when a SCIM endpoint sends invalid date (e.g. group with empty name, user without ids...)
|
||||
// No rollback required : we cannot recover. This needs to be fixed in the SCIM endpoint data
|
||||
return false;
|
||||
}
|
||||
if (e instanceof InvalidResponseFromScimEndpointException invalidResponseFromScimEndpointException) {
|
||||
return shouldRollbackBecauseOfResponse(invalidResponseFromScimEndpointException);
|
||||
}
|
||||
// Should not occur
|
||||
throw new IllegalStateException("Unkown ScimPropagationException", e);
|
||||
}
|
||||
|
||||
private boolean shouldRollbackBecauseOfResponse(InvalidResponseFromScimEndpointException e) {
|
||||
// If we have a response
|
||||
return e.getResponse().map(r -> {
|
||||
// We consider that 404 are acceptable, otherwise rollback
|
||||
ArrayList<Integer> acceptableStatus = Lists.newArrayList(200, 204, 404);
|
||||
return !acceptableStatus.contains(r.getHttpStatus());
|
||||
}).orElse(
|
||||
// Never got an answer, server was either misconfigured or unreachable
|
||||
// No rollback in that case.
|
||||
false);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
package org.keycloak.federation.scim.core.exceptions;
|
||||
|
||||
import org.keycloak.federation.scim.core.ScrimEndPointConfiguration;
|
||||
|
||||
/**
|
||||
* In charge of deciding, when facing a SCIM-related issue during an operation (e.g User creation), whether we should : - Log
|
||||
* the issue and let the operation succeed in Keycloack database (potentially unsynchronising Keycloack with the SCIM servers) -
|
||||
* Rollback the whole operation
|
||||
*/
|
||||
public interface RollbackStrategy {
|
||||
|
||||
/**
|
||||
* Indicates whether we should rollback the whole transaction because of the given exception.
|
||||
*
|
||||
* @param configuration The SCIM Endpoint configuration for which the exception occured
|
||||
* @param e the exception that we have to handle
|
||||
* @return true if transaction should be rolled back, false if we should log and continue operation
|
||||
*/
|
||||
boolean shouldRollback(ScrimEndPointConfiguration configuration, ScimPropagationException e);
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
package org.keycloak.federation.scim.core.exceptions;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.federation.scim.core.ScrimEndPointConfiguration;
|
||||
|
||||
/**
|
||||
* In charge of dealing with SCIM exceptions by ignoring, logging or rollback transaction according to : - The context in which
|
||||
* it occurs (sync, user creation...) - The related SCIM endpoint and its configuration - The thrown exception itself
|
||||
*/
|
||||
public class ScimExceptionHandler {
|
||||
private static final Logger LOGGER = Logger.getLogger(ScimExceptionHandler.class);
|
||||
|
||||
private final KeycloakSession session;
|
||||
private final RollbackStrategy rollbackStrategy;
|
||||
|
||||
public ScimExceptionHandler(KeycloakSession session) {
|
||||
this(session, RollbackApproach.CRITICAL_ONLY_ROLLBACK);
|
||||
}
|
||||
|
||||
public ScimExceptionHandler(KeycloakSession session, RollbackStrategy rollbackStrategy) {
|
||||
this.session = session;
|
||||
this.rollbackStrategy = rollbackStrategy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the given exception by loggin and/or rollback transaction.
|
||||
*
|
||||
* @param scimProviderConfiguration the configuration of the endpoint for which the propagation exception occured
|
||||
* @param e the occuring exception
|
||||
*/
|
||||
public void handleException(ScrimEndPointConfiguration scimProviderConfiguration, ScimPropagationException e) {
|
||||
String errorMessage = "[SCIM] Error while propagating to SCIM endpoint " + scimProviderConfiguration.getName();
|
||||
if (rollbackStrategy.shouldRollback(scimProviderConfiguration, e)) {
|
||||
session.getTransactionManager().rollback();
|
||||
LOGGER.error("TRANSACTION ROLLBACK - " + errorMessage, e);
|
||||
} else {
|
||||
LOGGER.warn(errorMessage, e);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
package org.keycloak.federation.scim.core.exceptions;
|
||||
|
||||
public abstract class ScimPropagationException extends Exception {
|
||||
|
||||
protected ScimPropagationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
protected ScimPropagationException(String message, Exception e) {
|
||||
super(message, e);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
package org.keycloak.federation.scim.core.exceptions;
|
||||
|
||||
import org.keycloak.federation.scim.core.ScrimEndPointConfiguration;
|
||||
|
||||
public enum SkipOrStopApproach implements SkipOrStopStrategy {
|
||||
ALWAYS_SKIP_AND_CONTINUE {
|
||||
@Override
|
||||
public boolean allowPartialSynchronizationWhenPushingToScim(ScrimEndPointConfiguration configuration) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean allowPartialSynchronizationWhenPullingFromScim(ScrimEndPointConfiguration configuration) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean allowMissingMembersWhenPushingGroupToScim(ScrimEndPointConfiguration configuration) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean allowInvalidEndpointConfiguration() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean skipInvalidDataFromScimEndpoint(ScrimEndPointConfiguration configuration) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
ALWAYS_STOP {
|
||||
@Override
|
||||
public boolean allowPartialSynchronizationWhenPushingToScim(ScrimEndPointConfiguration configuration) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean allowPartialSynchronizationWhenPullingFromScim(ScrimEndPointConfiguration configuration) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean allowMissingMembersWhenPushingGroupToScim(ScrimEndPointConfiguration configuration) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean allowInvalidEndpointConfiguration() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean skipInvalidDataFromScimEndpoint(ScrimEndPointConfiguration configuration) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
package org.keycloak.federation.scim.core.exceptions;
|
||||
|
||||
import org.keycloak.federation.scim.core.ScrimEndPointConfiguration;
|
||||
|
||||
/**
|
||||
* In charge of deciding, when facing a SCIM-related issue, whether we should : - log a warning, skip the problematic element
|
||||
* and continue the rest of the operation - stop immediately the whole operation (typically, a synchronisation between SCIM and
|
||||
* Keycloack)
|
||||
*/
|
||||
public interface SkipOrStopStrategy {
|
||||
/**
|
||||
* Indicates if, during a synchronisation from Keycloack to a SCIM endpoint, we should : - cancel the whole synchronisation
|
||||
* if an element CRUD fail, or - keep on with synchronisation, allowing a partial synchronisation
|
||||
*
|
||||
* @param configuration the configuration of the endpoint in which the error occurred
|
||||
* @return true if a partial synchronisation is allowed, false if we should stop the whole synchronisation at first issue
|
||||
*/
|
||||
boolean allowPartialSynchronizationWhenPushingToScim(ScrimEndPointConfiguration configuration);
|
||||
|
||||
/**
|
||||
* Indicates if, during a synchronisation from a SCIM endpoint to Keycloack, we should : - cancel the whole synchronisation
|
||||
* if an element CRUD fail, or - keep on with synchronisation, allowing a partial synchronisation
|
||||
*
|
||||
* @param configuration the configuration of the endpoint in which the error occurred
|
||||
* @return true if a partial synchronisation is allowed, false if we should interrupt the whole synchronisation at first
|
||||
* issue
|
||||
*/
|
||||
boolean allowPartialSynchronizationWhenPullingFromScim(ScrimEndPointConfiguration configuration);
|
||||
|
||||
/**
|
||||
* Indicates if, when we propagate a group creation or update to a SCIM endpoint and some of its members are not mapped to
|
||||
* SCIM, we should allow partial group update or interrupt completely.
|
||||
*
|
||||
* @param configuration the configuration of the endpoint in which the error occurred
|
||||
* @return true if a partial group update is allowed, false if we should interrupt the group update in case of any unmapped
|
||||
* member
|
||||
*/
|
||||
boolean allowMissingMembersWhenPushingGroupToScim(ScrimEndPointConfiguration configuration);
|
||||
|
||||
/**
|
||||
* Indicates if, when facing an invalid SCIM endpoint configuration (resulting in a unreachable SCIM server), we should stop
|
||||
* or ignore this configuration.
|
||||
*
|
||||
* @return true the invalid endpoint should be ignored, * false if we should interrupt the rest of the synchronisation
|
||||
*/
|
||||
boolean allowInvalidEndpointConfiguration();
|
||||
|
||||
/**
|
||||
* Indicates if, when trying to pull User or Groups from a SCIM endpoint, we encounter a invalid data (e.g. group with empty
|
||||
* name), we should : - Skip the invalid element pull and continue - Cancel the whole synchronisation
|
||||
*
|
||||
* @param configuration the configuration of the endpoint in which the error occurred
|
||||
* @return true if we should skip the invalid data synchronisation and pursue, false if we should interrupt immediately the
|
||||
* whole synchronisation
|
||||
*/
|
||||
boolean skipInvalidDataFromScimEndpoint(ScrimEndPointConfiguration configuration);
|
||||
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package org.keycloak.federation.scim.core.exceptions;
|
||||
|
||||
public class UnexpectedScimDataException extends ScimPropagationException {
|
||||
public UnexpectedScimDataException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,292 @@
|
|||
package org.keycloak.federation.scim.core.service;
|
||||
|
||||
import de.captaingoldfish.scim.sdk.common.resources.ResourceNode;
|
||||
import de.captaingoldfish.scim.sdk.common.resources.complex.Meta;
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RoleMapperModel;
|
||||
import org.keycloak.storage.user.SynchronizationResult;
|
||||
import org.keycloak.federation.scim.core.ScrimEndPointConfiguration;
|
||||
import org.keycloak.federation.scim.core.exceptions.InconsistentScimMappingException;
|
||||
import org.keycloak.federation.scim.core.exceptions.InvalidResponseFromScimEndpointException;
|
||||
import org.keycloak.federation.scim.core.exceptions.SkipOrStopStrategy;
|
||||
import org.keycloak.federation.scim.core.exceptions.UnexpectedScimDataException;
|
||||
import org.keycloak.federation.scim.jpa.ScimResourceDao;
|
||||
import org.keycloak.federation.scim.jpa.ScimResourceMapping;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* A service in charge of synchronisation (CRUD) between a Keykloak Role (UserModel, GroupModel) and a SCIM Resource
|
||||
* (User,Group).
|
||||
*
|
||||
* @param <K> The Keycloack Model (e.g. UserModel, GroupModel)
|
||||
* @param <S> The SCIM Resource (e.g. User, Group)
|
||||
*/
|
||||
public abstract class AbstractScimService<K extends RoleMapperModel, S extends ResourceNode> implements AutoCloseable {
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(AbstractScimService.class);
|
||||
protected final SkipOrStopStrategy skipOrStopStrategy;
|
||||
private final KeycloakSession keycloakSession;
|
||||
private final ScrimEndPointConfiguration scimProviderConfiguration;
|
||||
private final ScimResourceType type;
|
||||
private final ScimClient<S> scimClient;
|
||||
|
||||
protected AbstractScimService(KeycloakSession keycloakSession, ScrimEndPointConfiguration scimProviderConfiguration,
|
||||
ScimResourceType type, SkipOrStopStrategy skipOrStopStrategy) {
|
||||
this.keycloakSession = keycloakSession;
|
||||
this.scimProviderConfiguration = scimProviderConfiguration;
|
||||
this.type = type;
|
||||
this.scimClient = ScimClient.open(scimProviderConfiguration, type);
|
||||
this.skipOrStopStrategy = skipOrStopStrategy;
|
||||
}
|
||||
|
||||
public void create(K roleMapperModel) throws InconsistentScimMappingException, InvalidResponseFromScimEndpointException {
|
||||
if (isMarkedToIgnore(roleMapperModel)) {
|
||||
// Silently return: resource is explicitly marked as to ignore
|
||||
return;
|
||||
}
|
||||
// If mapping, then we are trying to recreate a user that was already created by import
|
||||
KeycloakId id = getId(roleMapperModel);
|
||||
if (findMappingById(id).isPresent()) {
|
||||
throw new InconsistentScimMappingException(
|
||||
"Trying to create user with id " + id + ": id already exists in Keycloak database");
|
||||
}
|
||||
S scimForCreation = scimRequestBodyForCreate(roleMapperModel);
|
||||
EntityOnRemoteScimId externalId = scimClient.create(id, scimForCreation);
|
||||
createMapping(id, externalId);
|
||||
}
|
||||
|
||||
public void update(K roleMapperModel) throws InconsistentScimMappingException, InvalidResponseFromScimEndpointException {
|
||||
if (isMarkedToIgnore(roleMapperModel)) {
|
||||
// Silently return: resource is explicitly marked as to ignore
|
||||
return;
|
||||
}
|
||||
KeycloakId keycloakId = getId(roleMapperModel);
|
||||
EntityOnRemoteScimId entityOnRemoteScimId = findMappingById(keycloakId)
|
||||
.map(ScimResourceMapping::getExternalIdAsEntityOnRemoteScimId)
|
||||
.orElseThrow(() -> new InconsistentScimMappingException("Failed to find SCIM mapping for " + keycloakId));
|
||||
S scimForReplace = scimRequestBodyForUpdate(roleMapperModel, entityOnRemoteScimId);
|
||||
scimClient.update(entityOnRemoteScimId, scimForReplace);
|
||||
}
|
||||
|
||||
protected abstract S scimRequestBodyForUpdate(K roleMapperModel, EntityOnRemoteScimId externalId)
|
||||
throws InconsistentScimMappingException;
|
||||
|
||||
public void delete(KeycloakId id) throws InconsistentScimMappingException, InvalidResponseFromScimEndpointException {
|
||||
ScimResourceMapping resource = findMappingById(id).orElseThrow(() -> new InconsistentScimMappingException(
|
||||
"Failed to delete resource %s, scim mapping not found: ".formatted(id)));
|
||||
EntityOnRemoteScimId externalId = resource.getExternalIdAsEntityOnRemoteScimId();
|
||||
scimClient.delete(externalId);
|
||||
getScimResourceDao().delete(resource);
|
||||
}
|
||||
|
||||
public void pushAllResourcesToScim(SynchronizationResult syncRes)
|
||||
throws InvalidResponseFromScimEndpointException, InconsistentScimMappingException {
|
||||
LOGGER.info("[SCIM] Push resources to endpoint " + this.getConfiguration().getEndPoint());
|
||||
try (Stream<K> resourcesStream = getResourceStream()) {
|
||||
Set<K> resources = resourcesStream.collect(Collectors.toUnmodifiableSet());
|
||||
for (K resource : resources) {
|
||||
KeycloakId id = getId(resource);
|
||||
pushSingleResourceToScim(syncRes, resource, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void pullAllResourcesFromScim(SynchronizationResult syncRes)
|
||||
throws UnexpectedScimDataException, InconsistentScimMappingException, InvalidResponseFromScimEndpointException {
|
||||
LOGGER.info("[SCIM] Pull resources from endpoint " + this.getConfiguration().getEndPoint());
|
||||
for (S resource : scimClient.listResources()) {
|
||||
pullSingleResourceFromScim(syncRes, resource);
|
||||
}
|
||||
}
|
||||
|
||||
private void pushSingleResourceToScim(SynchronizationResult syncRes, K resource, KeycloakId id)
|
||||
throws InvalidResponseFromScimEndpointException, InconsistentScimMappingException {
|
||||
try {
|
||||
LOGGER.infof("[SCIM] Reconciling local resource %s", id);
|
||||
if (shouldIgnoreForScimSynchronization(resource)) {
|
||||
LOGGER.infof("[SCIM] Skip local resource %s", id);
|
||||
return;
|
||||
}
|
||||
if (findMappingById(id).isPresent()) {
|
||||
LOGGER.info("[SCIM] Replacing it");
|
||||
update(resource);
|
||||
} else {
|
||||
LOGGER.info("[SCIM] Creating it");
|
||||
create(resource);
|
||||
}
|
||||
syncRes.increaseUpdated();
|
||||
} catch (InvalidResponseFromScimEndpointException e) {
|
||||
if (skipOrStopStrategy.allowPartialSynchronizationWhenPushingToScim(this.getConfiguration())) {
|
||||
LOGGER.warn("Error while syncing " + id + " to endpoint " + getConfiguration().getEndPoint(), e);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
} catch (InconsistentScimMappingException e) {
|
||||
if (skipOrStopStrategy.allowPartialSynchronizationWhenPushingToScim(this.getConfiguration())) {
|
||||
LOGGER.warn("Inconsistent data for element " + id + " and endpoint " + getConfiguration().getEndPoint(), e);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void pullSingleResourceFromScim(SynchronizationResult syncRes, S resource)
|
||||
throws UnexpectedScimDataException, InconsistentScimMappingException, InvalidResponseFromScimEndpointException {
|
||||
try {
|
||||
LOGGER.infof("[SCIM] Reconciling remote resource %s", resource);
|
||||
EntityOnRemoteScimId externalId = resource.getId().map(EntityOnRemoteScimId::new)
|
||||
.orElseThrow(() -> new UnexpectedScimDataException(
|
||||
"Remote SCIM resource doesn't have an id, cannot import it in Keycloak"));
|
||||
if (validMappingAlreadyExists(externalId))
|
||||
return;
|
||||
|
||||
// Here no keycloak user/group matching the SCIM external id exists
|
||||
// Try to match existing keycloak resource by properties (username, email, name)
|
||||
Optional<KeycloakId> mapped = matchKeycloakMappingByScimProperties(resource);
|
||||
if (mapped.isPresent()) {
|
||||
// If found a mapped, update
|
||||
LOGGER.info(
|
||||
"[SCIM] Matched SCIM resource " + externalId + " from properties with keycloak entity " + mapped.get());
|
||||
createMapping(mapped.get(), externalId);
|
||||
syncRes.increaseUpdated();
|
||||
} else {
|
||||
// If not, create it locally or deleting it remotely (according to the configured Import Action)
|
||||
createLocalOrDeleteRemote(syncRes, resource, externalId);
|
||||
}
|
||||
} catch (UnexpectedScimDataException e) {
|
||||
if (skipOrStopStrategy.skipInvalidDataFromScimEndpoint(getConfiguration())) {
|
||||
LOGGER.warn("[SCIM] Skipping element synchronisation because of invalid Scim Data for element "
|
||||
+ resource.getId() + " : " + e.getMessage(), e);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
} catch (InconsistentScimMappingException e) {
|
||||
if (skipOrStopStrategy.allowPartialSynchronizationWhenPullingFromScim(getConfiguration())) {
|
||||
LOGGER.warn("[SCIM] Skipping element synchronisation because of inconsistent mapping for element "
|
||||
+ resource.getId() + " : " + e.getMessage(), e);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
} catch (InvalidResponseFromScimEndpointException e) {
|
||||
// Can only occur in case of a DELETE_REMOTE conflict action
|
||||
if (skipOrStopStrategy.allowPartialSynchronizationWhenPullingFromScim(getConfiguration())) {
|
||||
LOGGER.warn("[SCIM] Could not delete SCIM resource " + resource.getId() + " during synchronisation", e);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private boolean validMappingAlreadyExists(EntityOnRemoteScimId externalId) {
|
||||
Optional<ScimResourceMapping> optionalMapping = getScimResourceDao().findByExternalId(externalId, type);
|
||||
// If an existing mapping exists, delete potential dangling references
|
||||
if (optionalMapping.isPresent()) {
|
||||
ScimResourceMapping mapping = optionalMapping.get();
|
||||
if (entityExists(mapping.getIdAsKeycloakId())) {
|
||||
LOGGER.info("[SCIM] Valid mapping found, skipping");
|
||||
return true;
|
||||
} else {
|
||||
LOGGER.info("[SCIM] Delete a dangling mapping");
|
||||
getScimResourceDao().delete(mapping);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void createLocalOrDeleteRemote(SynchronizationResult syncRes, S resource, EntityOnRemoteScimId externalId)
|
||||
throws UnexpectedScimDataException, InconsistentScimMappingException, InvalidResponseFromScimEndpointException {
|
||||
switch (scimProviderConfiguration.getImportAction()) {
|
||||
case CREATE_LOCAL -> {
|
||||
LOGGER.info("[SCIM] Create local resource for SCIM resource " + externalId);
|
||||
KeycloakId id = createEntity(resource);
|
||||
createMapping(id, externalId);
|
||||
syncRes.increaseAdded();
|
||||
}
|
||||
case DELETE_REMOTE -> {
|
||||
LOGGER.info("[SCIM] Delete remote resource " + externalId);
|
||||
scimClient.delete(externalId);
|
||||
}
|
||||
case NOTHING -> LOGGER.info("[SCIM] Import action set to NOTHING");
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract S scimRequestBodyForCreate(K roleMapperModel) throws InconsistentScimMappingException;
|
||||
|
||||
protected abstract KeycloakId getId(K roleMapperModel);
|
||||
|
||||
protected abstract boolean isMarkedToIgnore(K roleMapperModel);
|
||||
|
||||
private void createMapping(KeycloakId keycloakId, EntityOnRemoteScimId externalId) {
|
||||
getScimResourceDao().create(keycloakId, externalId, type);
|
||||
}
|
||||
|
||||
protected ScimResourceDao getScimResourceDao() {
|
||||
return ScimResourceDao.newInstance(getKeycloakSession(), scimProviderConfiguration.getId());
|
||||
}
|
||||
|
||||
private Optional<ScimResourceMapping> findMappingById(KeycloakId keycloakId) {
|
||||
return getScimResourceDao().findById(keycloakId, type);
|
||||
}
|
||||
|
||||
private KeycloakSession getKeycloakSession() {
|
||||
return keycloakSession;
|
||||
}
|
||||
|
||||
protected abstract boolean shouldIgnoreForScimSynchronization(K resource);
|
||||
|
||||
protected abstract Stream<K> getResourceStream();
|
||||
|
||||
protected abstract KeycloakId createEntity(S resource) throws UnexpectedScimDataException, InconsistentScimMappingException;
|
||||
|
||||
protected abstract Optional<KeycloakId> matchKeycloakMappingByScimProperties(S resource)
|
||||
throws InconsistentScimMappingException;
|
||||
|
||||
protected abstract boolean entityExists(KeycloakId keycloakId);
|
||||
|
||||
public void sync(SynchronizationResult syncRes)
|
||||
throws InconsistentScimMappingException, InvalidResponseFromScimEndpointException, UnexpectedScimDataException {
|
||||
if (this.scimProviderConfiguration.isPullFromScimSynchronisationActivated()) {
|
||||
this.pullAllResourcesFromScim(syncRes);
|
||||
}
|
||||
if (this.scimProviderConfiguration.isPushToScimSynchronisationActivated()) {
|
||||
this.pushAllResourcesToScim(syncRes);
|
||||
}
|
||||
}
|
||||
|
||||
protected Meta newMetaLocation(EntityOnRemoteScimId externalId) {
|
||||
Meta meta = new Meta();
|
||||
URI uri = getUri(type, externalId);
|
||||
meta.setLocation(uri.toString());
|
||||
return meta;
|
||||
}
|
||||
|
||||
protected URI getUri(ScimResourceType type, EntityOnRemoteScimId externalId) {
|
||||
try {
|
||||
return new URI("%s/%s".formatted(type.getEndpoint(), externalId.asString()));
|
||||
} catch (URISyntaxException e) {
|
||||
throw new IllegalStateException(
|
||||
"should never occur: can not format URI for type %s and id %s".formatted(type, externalId), e);
|
||||
}
|
||||
}
|
||||
|
||||
protected KeycloakDao getKeycloakDao() {
|
||||
return new KeycloakDao(getKeycloakSession());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
scimClient.close();
|
||||
}
|
||||
|
||||
public ScrimEndPointConfiguration getConfiguration() {
|
||||
return scimProviderConfiguration;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
package org.keycloak.federation.scim.core.service;
|
||||
|
||||
public record EntityOnRemoteScimId(String asString) {
|
||||
}
|
|
@ -0,0 +1,130 @@
|
|||
package org.keycloak.federation.scim.core.service;
|
||||
|
||||
import de.captaingoldfish.scim.sdk.common.resources.Group;
|
||||
import de.captaingoldfish.scim.sdk.common.resources.complex.Meta;
|
||||
import de.captaingoldfish.scim.sdk.common.resources.multicomplex.Member;
|
||||
import org.apache.commons.collections4.CollectionUtils;
|
||||
import org.apache.commons.lang3.BooleanUtils;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.models.GroupModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.federation.scim.core.ScrimEndPointConfiguration;
|
||||
import org.keycloak.federation.scim.core.exceptions.InconsistentScimMappingException;
|
||||
import org.keycloak.federation.scim.core.exceptions.SkipOrStopStrategy;
|
||||
import org.keycloak.federation.scim.core.exceptions.UnexpectedScimDataException;
|
||||
import org.keycloak.federation.scim.jpa.ScimResourceMapping;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public class GroupScimService extends AbstractScimService<GroupModel, Group> {
|
||||
private static final Logger LOGGER = Logger.getLogger(GroupScimService.class);
|
||||
|
||||
public GroupScimService(KeycloakSession keycloakSession, ScrimEndPointConfiguration scimProviderConfiguration,
|
||||
SkipOrStopStrategy skipOrStopStrategy) {
|
||||
super(keycloakSession, scimProviderConfiguration, ScimResourceType.GROUP, skipOrStopStrategy);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Stream<GroupModel> getResourceStream() {
|
||||
return getKeycloakDao().getGroupsStream();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean entityExists(KeycloakId keycloakId) {
|
||||
return getKeycloakDao().groupExists(keycloakId);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Optional<KeycloakId> matchKeycloakMappingByScimProperties(Group resource) {
|
||||
Set<String> names = new TreeSet<>();
|
||||
resource.getId().ifPresent(names::add);
|
||||
resource.getDisplayName().ifPresent(names::add);
|
||||
try (Stream<GroupModel> groupsStream = getKeycloakDao().getGroupsStream()) {
|
||||
Optional<GroupModel> group = groupsStream.filter(groupModel -> names.contains(groupModel.getName())).findFirst();
|
||||
return group.map(GroupModel::getId).map(KeycloakId::new);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected KeycloakId createEntity(Group resource) throws UnexpectedScimDataException, InconsistentScimMappingException {
|
||||
String displayName = resource.getDisplayName().filter(StringUtils::isNotBlank)
|
||||
.orElseThrow(() -> new UnexpectedScimDataException(
|
||||
"Remote Scim group has empty name, can't create. Resource id = %s".formatted(resource.getId())));
|
||||
GroupModel group = getKeycloakDao().createGroup(displayName);
|
||||
List<Member> groupMembers = resource.getMembers();
|
||||
if (CollectionUtils.isNotEmpty(groupMembers)) {
|
||||
for (Member groupMember : groupMembers) {
|
||||
EntityOnRemoteScimId externalId = groupMember.getValue().map(EntityOnRemoteScimId::new)
|
||||
.orElseThrow(() -> new UnexpectedScimDataException(
|
||||
"can't create group member for group '%s' without id: ".formatted(displayName) + resource));
|
||||
KeycloakId userId = getScimResourceDao().findUserByExternalId(externalId)
|
||||
.map(ScimResourceMapping::getIdAsKeycloakId).orElseThrow(() -> new InconsistentScimMappingException(
|
||||
"can't find mapping for group member %s".formatted(externalId)));
|
||||
UserModel userModel = getKeycloakDao().getUserById(userId);
|
||||
userModel.joinGroup(group);
|
||||
}
|
||||
}
|
||||
return new KeycloakId(group.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean isMarkedToIgnore(GroupModel groupModel) {
|
||||
return BooleanUtils.TRUE.equals(groupModel.getFirstAttribute("scim-skip"));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected KeycloakId getId(GroupModel groupModel) {
|
||||
return new KeycloakId(groupModel.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Group scimRequestBodyForCreate(GroupModel groupModel) throws InconsistentScimMappingException {
|
||||
Set<KeycloakId> members = getKeycloakDao().getGroupMembers(groupModel);
|
||||
Group group = new Group();
|
||||
group.setExternalId(groupModel.getId());
|
||||
group.setDisplayName(groupModel.getName());
|
||||
for (KeycloakId member : members) {
|
||||
Member groupMember = new Member();
|
||||
Optional<ScimResourceMapping> optionalGroupMemberMapping = getScimResourceDao().findUserById(member);
|
||||
if (optionalGroupMemberMapping.isPresent()) {
|
||||
ScimResourceMapping groupMemberMapping = optionalGroupMemberMapping.get();
|
||||
EntityOnRemoteScimId externalIdAsEntityOnRemoteScimId = groupMemberMapping
|
||||
.getExternalIdAsEntityOnRemoteScimId();
|
||||
groupMember.setValue(externalIdAsEntityOnRemoteScimId.asString());
|
||||
URI ref = getUri(ScimResourceType.USER, externalIdAsEntityOnRemoteScimId);
|
||||
groupMember.setRef(ref.toString());
|
||||
group.addMember(groupMember);
|
||||
} else {
|
||||
String message = "Unmapped member " + member + " for group " + groupModel.getId();
|
||||
if (skipOrStopStrategy.allowMissingMembersWhenPushingGroupToScim(this.getConfiguration())) {
|
||||
LOGGER.warn(message);
|
||||
} else {
|
||||
throw new InconsistentScimMappingException(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Group scimRequestBodyForUpdate(GroupModel groupModel, EntityOnRemoteScimId externalId)
|
||||
throws InconsistentScimMappingException {
|
||||
Group group = scimRequestBodyForCreate(groupModel);
|
||||
group.setId(externalId.asString());
|
||||
Meta meta = newMetaLocation(externalId);
|
||||
group.setMeta(meta);
|
||||
return group;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean shouldIgnoreForScimSynchronization(GroupModel resource) {
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
package org.keycloak.federation.scim.core.service;
|
||||
|
||||
import org.keycloak.models.GroupModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public class KeycloakDao {
|
||||
|
||||
private final KeycloakSession keycloakSession;
|
||||
|
||||
public KeycloakDao(KeycloakSession keycloakSession) {
|
||||
this.keycloakSession = keycloakSession;
|
||||
}
|
||||
|
||||
private KeycloakSession getKeycloakSession() {
|
||||
return keycloakSession;
|
||||
}
|
||||
|
||||
private RealmModel getRealm() {
|
||||
return getKeycloakSession().getContext().getRealm();
|
||||
}
|
||||
|
||||
public boolean groupExists(KeycloakId groupId) {
|
||||
GroupModel group = getKeycloakSession().groups().getGroupById(getRealm(), groupId.asString());
|
||||
return group != null;
|
||||
}
|
||||
|
||||
public boolean userExists(KeycloakId userId) {
|
||||
UserModel user = getUserById(userId);
|
||||
return user != null;
|
||||
}
|
||||
|
||||
public UserModel getUserById(KeycloakId userId) {
|
||||
return getKeycloakSession().users().getUserById(getRealm(), userId.asString());
|
||||
}
|
||||
|
||||
public GroupModel getGroupById(KeycloakId groupId) {
|
||||
return getKeycloakSession().groups().getGroupById(getRealm(), groupId.asString());
|
||||
}
|
||||
|
||||
public Stream<GroupModel> getGroupsStream() {
|
||||
return getKeycloakSession().groups().getGroupsStream(getRealm());
|
||||
}
|
||||
|
||||
public GroupModel createGroup(String displayName) {
|
||||
return getKeycloakSession().groups().createGroup(getRealm(), displayName);
|
||||
}
|
||||
|
||||
public Set<KeycloakId> getGroupMembers(GroupModel groupModel) {
|
||||
return getKeycloakSession().users().getGroupMembersStream(getRealm(), groupModel).map(UserModel::getId)
|
||||
.map(KeycloakId::new).collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
public Stream<UserModel> getUsersStream() {
|
||||
return getKeycloakSession().users().searchForUserStream(getRealm(), Collections.emptyMap());
|
||||
}
|
||||
|
||||
public UserModel getUserByUsername(String username) {
|
||||
return getKeycloakSession().users().getUserByUsername(getRealm(), username);
|
||||
}
|
||||
|
||||
public UserModel getUserByEmail(String email) {
|
||||
return getKeycloakSession().users().getUserByEmail(getRealm(), email);
|
||||
}
|
||||
|
||||
public UserModel addUser(String username) {
|
||||
return getKeycloakSession().users().addUser(getRealm(), username);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
package org.keycloak.federation.scim.core.service;
|
||||
|
||||
public record KeycloakId(String asString) {
|
||||
|
||||
}
|
|
@ -0,0 +1,138 @@
|
|||
package org.keycloak.federation.scim.core.service;
|
||||
|
||||
import com.google.common.net.HttpHeaders;
|
||||
import de.captaingoldfish.scim.sdk.client.ScimClientConfig;
|
||||
import de.captaingoldfish.scim.sdk.client.ScimRequestBuilder;
|
||||
import de.captaingoldfish.scim.sdk.client.response.ServerResponse;
|
||||
import de.captaingoldfish.scim.sdk.common.resources.ResourceNode;
|
||||
import de.captaingoldfish.scim.sdk.common.response.ListResponse;
|
||||
import io.github.resilience4j.core.IntervalFunction;
|
||||
import io.github.resilience4j.retry.Retry;
|
||||
import io.github.resilience4j.retry.RetryConfig;
|
||||
import io.github.resilience4j.retry.RetryRegistry;
|
||||
import jakarta.ws.rs.ProcessingException;
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.federation.scim.core.ScrimEndPointConfiguration;
|
||||
import org.keycloak.federation.scim.core.exceptions.InvalidResponseFromScimEndpointException;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
public class ScimClient<S extends ResourceNode> implements AutoCloseable {
|
||||
private static final Logger LOGGER = Logger.getLogger(ScimClient.class);
|
||||
|
||||
private final RetryRegistry retryRegistry;
|
||||
|
||||
private final ScimRequestBuilder scimRequestBuilder;
|
||||
|
||||
private final ScimResourceType scimResourceType;
|
||||
private final boolean logAllRequests;
|
||||
|
||||
private ScimClient(ScimRequestBuilder scimRequestBuilder, ScimResourceType scimResourceType, boolean detailedLogs) {
|
||||
this.scimRequestBuilder = scimRequestBuilder;
|
||||
this.scimResourceType = scimResourceType;
|
||||
RetryConfig retryConfig = RetryConfig.custom().maxAttempts(10).intervalFunction(IntervalFunction.ofExponentialBackoff())
|
||||
.retryExceptions(ProcessingException.class).build();
|
||||
retryRegistry = RetryRegistry.of(retryConfig);
|
||||
this.logAllRequests = detailedLogs;
|
||||
}
|
||||
|
||||
public static <T extends ResourceNode> ScimClient<T> open(ScrimEndPointConfiguration scimProviderConfiguration,
|
||||
ScimResourceType scimResourceType) {
|
||||
String scimApplicationBaseUrl = scimProviderConfiguration.getEndPoint();
|
||||
Map<String, String> httpHeaders = new HashMap<>();
|
||||
httpHeaders.put(HttpHeaders.AUTHORIZATION, scimProviderConfiguration.getAuthorizationHeaderValue());
|
||||
httpHeaders.put(HttpHeaders.CONTENT_TYPE, scimProviderConfiguration.getContentType());
|
||||
ScimClientConfig scimClientConfig = ScimClientConfig.builder().httpHeaders(httpHeaders).connectTimeout(5)
|
||||
.requestTimeout(5).socketTimeout(5).build();
|
||||
ScimRequestBuilder scimRequestBuilder = new ScimRequestBuilder(scimApplicationBaseUrl, scimClientConfig);
|
||||
return new ScimClient<>(scimRequestBuilder, scimResourceType, scimProviderConfiguration.isLogAllScimRequests());
|
||||
}
|
||||
|
||||
public EntityOnRemoteScimId create(KeycloakId id, S scimForCreation) throws InvalidResponseFromScimEndpointException {
|
||||
Optional<String> scimForCreationId = scimForCreation.getId();
|
||||
if (scimForCreationId.isPresent()) {
|
||||
throw new IllegalArgumentException(
|
||||
"User to create should never have an existing id: %s %s".formatted(id, scimForCreationId.get()));
|
||||
}
|
||||
try {
|
||||
Retry retry = retryRegistry.retry("create-%s".formatted(id.asString()));
|
||||
if (logAllRequests) {
|
||||
LOGGER.info("[SCIM] Sending CREATE " + scimForCreation.toPrettyString() + "\n to " + getScimEndpoint());
|
||||
}
|
||||
ServerResponse<S> response = retry.executeSupplier(() -> scimRequestBuilder
|
||||
.create(getResourceClass(), getScimEndpoint()).setResource(scimForCreation).sendRequest());
|
||||
checkResponseIsSuccess(response);
|
||||
S resource = response.getResource();
|
||||
return resource.getId().map(EntityOnRemoteScimId::new).orElseThrow(
|
||||
() -> new InvalidResponseFromScimEndpointException(response, "Created SCIM resource does not have id"));
|
||||
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn(e);
|
||||
throw new InvalidResponseFromScimEndpointException("Exception while retrying create " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private void checkResponseIsSuccess(ServerResponse<S> response) throws InvalidResponseFromScimEndpointException {
|
||||
if (logAllRequests) {
|
||||
LOGGER.info("[SCIM] Server response " + response.getHttpStatus() + "\n" + response.getResponseBody());
|
||||
}
|
||||
if (!response.isSuccess()) {
|
||||
throw new InvalidResponseFromScimEndpointException(response,
|
||||
"Server answered with status " + response.getResponseBody() + ": " + response.getResponseBody());
|
||||
}
|
||||
}
|
||||
|
||||
private String getScimEndpoint() {
|
||||
return scimResourceType.getEndpoint();
|
||||
}
|
||||
|
||||
private Class<S> getResourceClass() {
|
||||
return scimResourceType.getResourceClass();
|
||||
}
|
||||
|
||||
public void update(EntityOnRemoteScimId externalId, S scimForReplace) throws InvalidResponseFromScimEndpointException {
|
||||
Retry retry = retryRegistry.retry("replace-%s".formatted(externalId.asString()));
|
||||
try {
|
||||
if (logAllRequests) {
|
||||
LOGGER.info("[SCIM] Sending UPDATE " + scimForReplace.toPrettyString() + "\n to " + getScimEndpoint());
|
||||
}
|
||||
ServerResponse<S> response = retry.executeSupplier(
|
||||
() -> scimRequestBuilder.update(getResourceClass(), getScimEndpoint(), externalId.asString())
|
||||
.setResource(scimForReplace).sendRequest());
|
||||
checkResponseIsSuccess(response);
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn(e);
|
||||
throw new InvalidResponseFromScimEndpointException("Exception while retrying update " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
public void delete(EntityOnRemoteScimId externalId) throws InvalidResponseFromScimEndpointException {
|
||||
Retry retry = retryRegistry.retry("delete-%s".formatted(externalId.asString()));
|
||||
if (logAllRequests) {
|
||||
LOGGER.info("[SCIM] Sending DELETE to " + getScimEndpoint());
|
||||
}
|
||||
try {
|
||||
ServerResponse<S> response = retry.executeSupplier(() -> scimRequestBuilder
|
||||
.delete(getResourceClass(), getScimEndpoint(), externalId.asString()).sendRequest());
|
||||
checkResponseIsSuccess(response);
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn(e);
|
||||
throw new InvalidResponseFromScimEndpointException("Exception while retrying delete " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
scimRequestBuilder.close();
|
||||
}
|
||||
|
||||
public List<S> listResources() {
|
||||
ServerResponse<ListResponse<S>> response = scimRequestBuilder.list(getResourceClass(), getScimEndpoint()).get()
|
||||
.sendRequest();
|
||||
ListResponse<S> resourceTypeListResponse = response.getResource();
|
||||
return resourceTypeListResponse.getListedResources();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
package org.keycloak.federation.scim.core.service;
|
||||
|
||||
import de.captaingoldfish.scim.sdk.common.resources.Group;
|
||||
import de.captaingoldfish.scim.sdk.common.resources.ResourceNode;
|
||||
import de.captaingoldfish.scim.sdk.common.resources.User;
|
||||
|
||||
public enum ScimResourceType {
|
||||
|
||||
USER("/Users", User.class),
|
||||
|
||||
GROUP("/Groups", Group.class);
|
||||
|
||||
private final String endpoint;
|
||||
|
||||
private final Class<? extends ResourceNode> resourceClass;
|
||||
|
||||
ScimResourceType(String endpoint, Class<? extends ResourceNode> resourceClass) {
|
||||
this.endpoint = endpoint;
|
||||
this.resourceClass = resourceClass;
|
||||
}
|
||||
|
||||
public String getEndpoint() {
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
public <T extends ResourceNode> Class<T> getResourceClass() {
|
||||
return (Class<T>) resourceClass;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,130 @@
|
|||
package org.keycloak.federation.scim.core.service;
|
||||
|
||||
import de.captaingoldfish.scim.sdk.common.resources.User;
|
||||
import de.captaingoldfish.scim.sdk.common.resources.complex.Meta;
|
||||
import de.captaingoldfish.scim.sdk.common.resources.complex.Name;
|
||||
import de.captaingoldfish.scim.sdk.common.resources.multicomplex.Email;
|
||||
import de.captaingoldfish.scim.sdk.common.resources.multicomplex.MultiComplexNode;
|
||||
import de.captaingoldfish.scim.sdk.common.resources.multicomplex.PersonRole;
|
||||
import org.apache.commons.lang3.BooleanUtils;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RoleMapperModel;
|
||||
import org.keycloak.models.RoleModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.federation.scim.core.ScrimEndPointConfiguration;
|
||||
import org.keycloak.federation.scim.core.exceptions.InconsistentScimMappingException;
|
||||
import org.keycloak.federation.scim.core.exceptions.SkipOrStopStrategy;
|
||||
import org.keycloak.federation.scim.core.exceptions.UnexpectedScimDataException;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public class UserScimService extends AbstractScimService<UserModel, User> {
|
||||
private static final Logger LOGGER = Logger.getLogger(UserScimService.class);
|
||||
|
||||
public UserScimService(KeycloakSession keycloakSession, ScrimEndPointConfiguration scimProviderConfiguration,
|
||||
SkipOrStopStrategy skipOrStopStrategy) {
|
||||
super(keycloakSession, scimProviderConfiguration, ScimResourceType.USER, skipOrStopStrategy);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Stream<UserModel> getResourceStream() {
|
||||
return getKeycloakDao().getUsersStream();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean entityExists(KeycloakId keycloakId) {
|
||||
return getKeycloakDao().userExists(keycloakId);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Optional<KeycloakId> matchKeycloakMappingByScimProperties(User resource) throws InconsistentScimMappingException {
|
||||
Optional<KeycloakId> matchedByUsername = resource.getUserName().map(getKeycloakDao()::getUserByUsername)
|
||||
.map(this::getId);
|
||||
Optional<KeycloakId> matchedByEmail = resource.getEmails().stream().findFirst().flatMap(MultiComplexNode::getValue)
|
||||
.map(getKeycloakDao()::getUserByEmail).map(this::getId);
|
||||
if (matchedByUsername.isPresent() && matchedByEmail.isPresent() && !matchedByUsername.equals(matchedByEmail)) {
|
||||
String inconstencyErrorMessage = "Found 2 possible users for remote user " + matchedByUsername.get() + " - "
|
||||
+ matchedByEmail.get();
|
||||
LOGGER.warn(inconstencyErrorMessage);
|
||||
throw new InconsistentScimMappingException(inconstencyErrorMessage);
|
||||
}
|
||||
if (matchedByUsername.isPresent()) {
|
||||
return matchedByUsername;
|
||||
}
|
||||
return matchedByEmail;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected KeycloakId createEntity(User resource) throws UnexpectedScimDataException {
|
||||
String username = resource.getUserName().filter(StringUtils::isNotBlank)
|
||||
.orElseThrow(() -> new UnexpectedScimDataException(
|
||||
"Remote Scim user has empty username, can't create. Resource id = %s".formatted(resource.getId())));
|
||||
UserModel user = getKeycloakDao().addUser(username);
|
||||
resource.getEmails().stream().findFirst().flatMap(MultiComplexNode::getValue).ifPresent(user::setEmail);
|
||||
boolean userEnabled = resource.isActive().orElse(false);
|
||||
user.setEnabled(userEnabled);
|
||||
return new KeycloakId(user.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean isMarkedToIgnore(UserModel userModel) {
|
||||
return BooleanUtils.TRUE.equals(userModel.getFirstAttribute("scim-skip"));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected KeycloakId getId(UserModel userModel) {
|
||||
return new KeycloakId(userModel.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected User scimRequestBodyForCreate(UserModel roleMapperModel) {
|
||||
String firstAndLastName = String.format("%s %s", StringUtils.defaultString(roleMapperModel.getFirstName()),
|
||||
StringUtils.defaultString(roleMapperModel.getLastName())).trim();
|
||||
String displayName = Objects.toString(firstAndLastName, roleMapperModel.getUsername());
|
||||
Stream<RoleModel> groupRoleModels = roleMapperModel.getGroupsStream().flatMap(RoleMapperModel::getRoleMappingsStream);
|
||||
Stream<RoleModel> roleModels = roleMapperModel.getRoleMappingsStream();
|
||||
Stream<RoleModel> allRoleModels = Stream.concat(groupRoleModels, roleModels);
|
||||
List<PersonRole> roles = allRoleModels.filter(r -> BooleanUtils.TRUE.equals(r.getFirstAttribute("scim")))
|
||||
.map(RoleModel::getName).map(roleName -> {
|
||||
PersonRole personRole = new PersonRole();
|
||||
personRole.setValue(roleName);
|
||||
return personRole;
|
||||
}).toList();
|
||||
User user = new User();
|
||||
user.setRoles(roles);
|
||||
user.setExternalId(roleMapperModel.getId());
|
||||
user.setUserName(roleMapperModel.getUsername());
|
||||
user.setDisplayName(displayName);
|
||||
Name name = new Name();
|
||||
name.setFamilyName(roleMapperModel.getLastName());
|
||||
name.setGivenName(roleMapperModel.getFirstName());
|
||||
user.setName(name);
|
||||
List<Email> emails = new ArrayList<>();
|
||||
if (roleMapperModel.getEmail() != null) {
|
||||
emails.add(Email.builder().value(roleMapperModel.getEmail()).build());
|
||||
}
|
||||
user.setEmails(emails);
|
||||
user.setActive(roleMapperModel.isEnabled());
|
||||
return user;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected User scimRequestBodyForUpdate(UserModel userModel, EntityOnRemoteScimId externalId) {
|
||||
User user = scimRequestBodyForCreate(userModel);
|
||||
user.setId(externalId.asString());
|
||||
Meta meta = newMetaLocation(externalId);
|
||||
user.setMeta(meta);
|
||||
return user;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean shouldIgnoreForScimSynchronization(UserModel userModel) {
|
||||
return "admin".equals(userModel.getUsername());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
package org.keycloak.federation.scim.event;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.models.GroupModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.utils.KeycloakModelUtils;
|
||||
import org.keycloak.timer.TimerProvider;
|
||||
import org.keycloak.federation.scim.core.ScimDispatcher;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* In charge of making background checks and sent UPDATE requests from group for which membership information has changed.
|
||||
* <p>
|
||||
* This is required to avoid immediate group membership updates which could cause to incorrect group members list in case of
|
||||
* concurrent group membership changes.
|
||||
*/
|
||||
public class ScimBackgroundGroupMembershipUpdater {
|
||||
public static final String GROUP_DIRTY_SINCE_ATTRIBUTE_NAME = "scim-dirty-since";
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(ScimBackgroundGroupMembershipUpdater.class);
|
||||
// Update check loop will run every time this delay has passed
|
||||
private static final long UPDATE_CHECK_DELAY_MS = 2000;
|
||||
// If a group is marked dirty since less that this debounce delay, wait for the next update check loop
|
||||
private static final long DEBOUNCE_DELAY_MS = 1200;
|
||||
private final KeycloakSessionFactory sessionFactory;
|
||||
|
||||
public ScimBackgroundGroupMembershipUpdater(KeycloakSessionFactory sessionFactory) {
|
||||
this.sessionFactory = sessionFactory;
|
||||
}
|
||||
|
||||
public void startBackgroundUpdates() {
|
||||
// Every UPDATE_CHECK_DELAY_MS, check for dirty groups and send updates if required
|
||||
try (KeycloakSession keycloakSession = sessionFactory.create()) {
|
||||
TimerProvider timer = keycloakSession.getProvider(TimerProvider.class);
|
||||
timer.scheduleTask(taskSession -> {
|
||||
for (RealmModel realm : taskSession.realms().getRealmsStream().toList()) {
|
||||
dispatchDirtyGroupsUpdates(realm);
|
||||
}
|
||||
}, Duration.ofMillis(UPDATE_CHECK_DELAY_MS).toMillis(), "scim-background");
|
||||
}
|
||||
}
|
||||
|
||||
private void dispatchDirtyGroupsUpdates(RealmModel realm) {
|
||||
KeycloakModelUtils.runJobInTransaction(sessionFactory, session -> {
|
||||
session.getContext().setRealm(realm);
|
||||
ScimDispatcher dispatcher = new ScimDispatcher(session);
|
||||
// Identify groups marked as dirty by the ScimEventListenerProvider
|
||||
for (GroupModel group : session.groups().getGroupsStream(realm).filter(this::isDirtyGroup).toList()) {
|
||||
LOGGER.infof("[SCIM] Group %s is dirty, dispatch an update", group.getName());
|
||||
// If dirty : dispatch a group update to all clients and mark it clean
|
||||
dispatcher.dispatchGroupModificationToAll(client -> client.update(group));
|
||||
group.removeAttribute(GROUP_DIRTY_SINCE_ATTRIBUTE_NAME);
|
||||
}
|
||||
dispatcher.close();
|
||||
});
|
||||
}
|
||||
|
||||
private boolean isDirtyGroup(GroupModel g) {
|
||||
String groupDirtySinceAttribute = g.getFirstAttribute(GROUP_DIRTY_SINCE_ATTRIBUTE_NAME);
|
||||
try {
|
||||
long groupDirtySince = Long.parseLong(groupDirtySinceAttribute);
|
||||
// Must be dirty for more than DEBOUNCE_DELAY_MS
|
||||
// (otherwise update will be dispatched in next scheduled loop)
|
||||
return System.currentTimeMillis() - groupDirtySince > DEBOUNCE_DELAY_MS;
|
||||
} catch (NumberFormatException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,242 @@
|
|||
package org.keycloak.federation.scim.event;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.common.Profile;
|
||||
import org.keycloak.component.ComponentModel;
|
||||
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.GroupModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.federation.scim.core.ScimDispatcher;
|
||||
import org.keycloak.federation.scim.core.ScimEndpointConfigurationStorageProviderFactory;
|
||||
import org.keycloak.federation.scim.core.service.KeycloakDao;
|
||||
import org.keycloak.federation.scim.core.service.KeycloakId;
|
||||
import org.keycloak.federation.scim.core.service.ScimResourceType;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* An Event listener reacting to Keycloak models modification (e.g. User creation, Group deletion, membership modifications,
|
||||
* endpoint configuration change...) by propagating it to all registered Scim endpoints.
|
||||
*/
|
||||
public class ScimEventListenerProvider implements EventListenerProvider {
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(ScimEventListenerProvider.class);
|
||||
|
||||
private final ScimDispatcher dispatcher;
|
||||
|
||||
private final KeycloakSession session;
|
||||
|
||||
private final KeycloakDao keycloakDao;
|
||||
|
||||
private final Map<ResourceType, Pattern> listenedEventPathPatterns = Map.of(ResourceType.USER,
|
||||
Pattern.compile("users/(.+)"), ResourceType.GROUP, Pattern.compile("groups/([\\w-]+)(/children)?"),
|
||||
ResourceType.GROUP_MEMBERSHIP, Pattern.compile("users/(.+)/groups/(.+)"), ResourceType.REALM_ROLE_MAPPING,
|
||||
Pattern.compile("^(.+)/(.+)/role-mappings"), ResourceType.COMPONENT, Pattern.compile("components/(.+)"));
|
||||
|
||||
public ScimEventListenerProvider(KeycloakSession session) {
|
||||
this.session = session;
|
||||
this.keycloakDao = new KeycloakDao(session);
|
||||
this.dispatcher = new ScimDispatcher(session);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEvent(Event event) {
|
||||
if (Profile.isFeatureEnabled(Profile.Feature.SCIM)) {
|
||||
// React to User-related event : creation, deletion, update
|
||||
EventType eventType = event.getType();
|
||||
KeycloakId eventUserId = new KeycloakId(event.getUserId());
|
||||
switch (eventType) {
|
||||
case REGISTER -> {
|
||||
LOGGER.infof("[SCIM] Propagate User Registration - %s", eventUserId);
|
||||
UserModel user = getUser(eventUserId);
|
||||
dispatcher.dispatchUserModificationToAll(client -> client.create(user));
|
||||
}
|
||||
case UPDATE_EMAIL, UPDATE_PROFILE -> {
|
||||
LOGGER.infof("[SCIM] Propagate User %s - %s", eventType, eventUserId);
|
||||
UserModel user = getUser(eventUserId);
|
||||
dispatcher.dispatchUserModificationToAll(client -> client.update(user));
|
||||
}
|
||||
case DELETE_ACCOUNT -> {
|
||||
LOGGER.infof("[SCIM] Propagate User deletion - %s", eventUserId);
|
||||
dispatcher.dispatchUserModificationToAll(client -> client.delete(eventUserId));
|
||||
}
|
||||
default -> {
|
||||
// No other event has to be propagated to Scim endpoints
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEvent(AdminEvent event, boolean includeRepresentation) {
|
||||
if (Profile.isFeatureEnabled(Profile.Feature.SCIM)) {
|
||||
// Step 1: check if event is relevant for propagation through SCIM
|
||||
Pattern pattern = listenedEventPathPatterns.get(event.getResourceType());
|
||||
if (pattern == null)
|
||||
return;
|
||||
Matcher matcher = pattern.matcher(event.getResourcePath());
|
||||
if (!matcher.find())
|
||||
return;
|
||||
|
||||
// Step 2: propagate event (if needed) according to its resource type
|
||||
switch (event.getResourceType()) {
|
||||
case USER -> {
|
||||
KeycloakId userId = new KeycloakId(matcher.group(1));
|
||||
handleUserEvent(event, userId);
|
||||
}
|
||||
case GROUP -> {
|
||||
KeycloakId groupId = new KeycloakId(matcher.group(1));
|
||||
handleGroupEvent(event, groupId);
|
||||
}
|
||||
case GROUP_MEMBERSHIP -> {
|
||||
KeycloakId userId = new KeycloakId(matcher.group(1));
|
||||
KeycloakId groupId = new KeycloakId(matcher.group(2));
|
||||
handleGroupMemberShipEvent(event, userId, groupId);
|
||||
}
|
||||
case REALM_ROLE_MAPPING -> {
|
||||
String rawResourceType = matcher.group(1);
|
||||
ScimResourceType type = switch (rawResourceType) {
|
||||
case "users" -> ScimResourceType.USER;
|
||||
case "groups" -> ScimResourceType.GROUP;
|
||||
default -> throw new IllegalArgumentException("Unsupported resource type: " + rawResourceType);
|
||||
};
|
||||
KeycloakId id = new KeycloakId(matcher.group(2));
|
||||
handleRoleMappingEvent(event, type, id);
|
||||
}
|
||||
case COMPONENT -> {
|
||||
String id = matcher.group(1);
|
||||
handleScimEndpointConfigurationEvent(event, id);
|
||||
|
||||
}
|
||||
default -> {
|
||||
// No other resource modification has to be propagated to Scim endpoints
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void handleUserEvent(AdminEvent userEvent, KeycloakId userId) {
|
||||
LOGGER.infof("[SCIM] Propagate User %s - %s", userEvent.getOperationType(), userId);
|
||||
switch (userEvent.getOperationType()) {
|
||||
case CREATE -> {
|
||||
UserModel user = getUser(userId);
|
||||
dispatcher.dispatchUserModificationToAll(client -> client.create(user));
|
||||
user.getGroupsStream()
|
||||
.forEach(group -> dispatcher.dispatchGroupModificationToAll(client -> client.update(group)));
|
||||
}
|
||||
case UPDATE -> {
|
||||
UserModel user = getUser(userId);
|
||||
dispatcher.dispatchUserModificationToAll(client -> client.update(user));
|
||||
}
|
||||
case DELETE -> dispatcher.dispatchUserModificationToAll(client -> client.delete(userId));
|
||||
default -> {
|
||||
// ACTION userEvent are not relevant, nothing to do
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Propagating the given group-related event to Scim endpoints.
|
||||
*
|
||||
* @param event the event to propagate
|
||||
* @param groupId event target's id
|
||||
*/
|
||||
private void handleGroupEvent(AdminEvent event, KeycloakId groupId) {
|
||||
LOGGER.infof("[SCIM] Propagate Group %s - %s", event.getOperationType(), groupId);
|
||||
switch (event.getOperationType()) {
|
||||
case CREATE -> {
|
||||
GroupModel group = getGroup(groupId);
|
||||
dispatcher.dispatchGroupModificationToAll(client -> client.create(group));
|
||||
}
|
||||
case UPDATE -> {
|
||||
GroupModel group = getGroup(groupId);
|
||||
dispatcher.dispatchGroupModificationToAll(client -> client.update(group));
|
||||
}
|
||||
case DELETE -> dispatcher.dispatchGroupModificationToAll(client -> client.delete(groupId));
|
||||
default -> {
|
||||
// ACTION event are not relevant, nothing to do
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void handleGroupMemberShipEvent(AdminEvent groupMemberShipEvent, KeycloakId userId, KeycloakId groupId) {
|
||||
LOGGER.infof("[SCIM] Propagate GroupMemberShip %s - User %s Group %s", groupMemberShipEvent.getOperationType(), userId,
|
||||
groupId);
|
||||
// Step 1: update USER immediately
|
||||
GroupModel group = getGroup(groupId);
|
||||
UserModel user = getUser(userId);
|
||||
dispatcher.dispatchUserModificationToAll(client -> client.update(user));
|
||||
|
||||
// Step 2: delayed GROUP update :
|
||||
// if several users are added to the group simultaneously in different Keycloack sessions
|
||||
// update the group in the context of the current session may not reflect those other changes
|
||||
// We trigger a delayed update by setting an attribute on the group (that will be handled by
|
||||
// ScimBackgroundGroupMembershipUpdaters)
|
||||
group.setSingleAttribute(ScimBackgroundGroupMembershipUpdater.GROUP_DIRTY_SINCE_ATTRIBUTE_NAME,
|
||||
"" + System.currentTimeMillis());
|
||||
}
|
||||
|
||||
private void handleRoleMappingEvent(AdminEvent roleMappingEvent, ScimResourceType type, KeycloakId id) {
|
||||
LOGGER.infof("[SCIM] Propagate RoleMapping %s - %s %s", roleMappingEvent.getOperationType(), type, id);
|
||||
switch (type) {
|
||||
case USER -> {
|
||||
UserModel user = getUser(id);
|
||||
dispatcher.dispatchUserModificationToAll(client -> client.update(user));
|
||||
}
|
||||
case GROUP -> {
|
||||
GroupModel group = getGroup(id);
|
||||
session.users().getGroupMembersStream(session.getContext().getRealm(), group)
|
||||
.forEach(user -> dispatcher.dispatchUserModificationToAll(client -> client.update(user)));
|
||||
}
|
||||
default -> {
|
||||
// No other type is relevant for propagation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void handleScimEndpointConfigurationEvent(AdminEvent event, String id) {
|
||||
// In case of a component deletion
|
||||
if (event.getOperationType() == OperationType.DELETE) {
|
||||
// Check if it was a Scim endpoint configuration, and forward deletion if so
|
||||
Stream<ComponentModel> scimEndpointConfigurationsWithDeletedId = session.getContext().getRealm()
|
||||
.getComponentsStream()
|
||||
.filter(m -> ScimEndpointConfigurationStorageProviderFactory.ID.equals(m.getProviderId())
|
||||
&& id.equals(m.getId()));
|
||||
if (scimEndpointConfigurationsWithDeletedId.iterator().hasNext()) {
|
||||
LOGGER.infof("[SCIM] SCIM Endpoint configuration DELETE - %s ", id);
|
||||
dispatcher.refreshActiveScimEndpoints();
|
||||
}
|
||||
} else {
|
||||
// In case of CREATE or UPDATE, we can directly use the string representation
|
||||
// to check if it defines a SCIM endpoint (faster)
|
||||
if (event.getRepresentation() != null && event.getRepresentation().contains("\"providerId\":\"scim\"")) {
|
||||
LOGGER.infof("[SCIM] SCIM Endpoint configuration CREATE - %s ", id);
|
||||
dispatcher.refreshActiveScimEndpoints();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private UserModel getUser(KeycloakId id) {
|
||||
return keycloakDao.getUserById(id);
|
||||
}
|
||||
|
||||
private GroupModel getGroup(KeycloakId id) {
|
||||
return keycloakDao.getGroupById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
dispatcher.close();
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
package org.keycloak.federation.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 String getId() {
|
||||
return "scim";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void init(Scope config) {
|
||||
// Nothing to initialize
|
||||
}
|
||||
|
||||
@Override
|
||||
public void postInit(KeycloakSessionFactory factory) {
|
||||
// Nothing to initialize
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
// Nothing to close
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
package org.keycloak.federation.scim.jpa;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.NoResultException;
|
||||
import jakarta.persistence.TypedQuery;
|
||||
import org.keycloak.connections.jpa.JpaConnectionProvider;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.federation.scim.core.service.EntityOnRemoteScimId;
|
||||
import org.keycloak.federation.scim.core.service.KeycloakId;
|
||||
import org.keycloak.federation.scim.core.service.ScimResourceType;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public class ScimResourceDao {
|
||||
|
||||
private final String realmId;
|
||||
|
||||
private final String componentId;
|
||||
|
||||
private final EntityManager entityManager;
|
||||
|
||||
private ScimResourceDao(String realmId, String componentId, EntityManager entityManager) {
|
||||
this.realmId = realmId;
|
||||
this.componentId = componentId;
|
||||
this.entityManager = entityManager;
|
||||
}
|
||||
|
||||
public static ScimResourceDao newInstance(KeycloakSession keycloakSession, String componentId) {
|
||||
String realmId = keycloakSession.getContext().getRealm().getId();
|
||||
EntityManager entityManager = keycloakSession.getProvider(JpaConnectionProvider.class).getEntityManager();
|
||||
return new ScimResourceDao(realmId, componentId, entityManager);
|
||||
}
|
||||
|
||||
private EntityManager getEntityManager() {
|
||||
return entityManager;
|
||||
}
|
||||
|
||||
private String getRealmId() {
|
||||
return realmId;
|
||||
}
|
||||
|
||||
private String getComponentId() {
|
||||
return componentId;
|
||||
}
|
||||
|
||||
public void create(KeycloakId id, EntityOnRemoteScimId externalId, ScimResourceType type) {
|
||||
ScimResourceMapping entity = new ScimResourceMapping();
|
||||
entity.setType(type.name());
|
||||
entity.setExternalId(externalId.asString());
|
||||
entity.setComponentId(componentId);
|
||||
entity.setRealmId(realmId);
|
||||
entity.setId(id.asString());
|
||||
entityManager.persist(entity);
|
||||
}
|
||||
|
||||
private TypedQuery<ScimResourceMapping> getScimResourceTypedQuery(String queryName, String id, ScimResourceType type) {
|
||||
return getEntityManager().createNamedQuery(queryName, ScimResourceMapping.class).setParameter("type", type.name())
|
||||
.setParameter("realmId", getRealmId()).setParameter("componentId", getComponentId()).setParameter("id", id);
|
||||
}
|
||||
|
||||
public Optional<ScimResourceMapping> findByExternalId(EntityOnRemoteScimId externalId, ScimResourceType type) {
|
||||
try {
|
||||
return Optional.of(getScimResourceTypedQuery("findByExternalId", externalId.asString(), type).getSingleResult());
|
||||
} catch (NoResultException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<ScimResourceMapping> findById(KeycloakId keycloakId, ScimResourceType type) {
|
||||
try {
|
||||
return Optional.of(getScimResourceTypedQuery("findById", keycloakId.asString(), type).getSingleResult());
|
||||
} catch (NoResultException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<ScimResourceMapping> findUserById(KeycloakId id) {
|
||||
return findById(id, ScimResourceType.USER);
|
||||
}
|
||||
|
||||
public Optional<ScimResourceMapping> findUserByExternalId(EntityOnRemoteScimId externalId) {
|
||||
return findByExternalId(externalId, ScimResourceType.USER);
|
||||
}
|
||||
|
||||
public void delete(ScimResourceMapping resource) {
|
||||
entityManager.remove(resource);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,81 @@
|
|||
package org.keycloak.federation.scim.jpa;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Objects;
|
||||
|
||||
public class ScimResourceId implements Serializable {
|
||||
private String id;
|
||||
private String realmId;
|
||||
private String componentId;
|
||||
private String type;
|
||||
private String externalId;
|
||||
|
||||
public ScimResourceId() {
|
||||
}
|
||||
|
||||
public ScimResourceId(String id, String realmId, String componentId, String type, String externalId) {
|
||||
this.setId(id);
|
||||
this.setRealmId(realmId);
|
||||
this.setComponentId(componentId);
|
||||
this.setType(type);
|
||||
this.setExternalId(externalId);
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getRealmId() {
|
||||
return realmId;
|
||||
}
|
||||
|
||||
public void setRealmId(String realmId) {
|
||||
this.realmId = realmId;
|
||||
}
|
||||
|
||||
public String getComponentId() {
|
||||
return componentId;
|
||||
}
|
||||
|
||||
public void setComponentId(String componentId) {
|
||||
this.componentId = componentId;
|
||||
}
|
||||
|
||||
public String getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public void setType(String type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public String getExternalId() {
|
||||
return externalId;
|
||||
}
|
||||
|
||||
public void setExternalId(String externalId) {
|
||||
this.externalId = externalId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (this == other)
|
||||
return true;
|
||||
if (!(other instanceof ScimResourceId o))
|
||||
return false;
|
||||
return (StringUtils.equals(o.id, id) && StringUtils.equals(o.realmId, realmId)
|
||||
&& StringUtils.equals(o.componentId, componentId) && StringUtils.equals(o.type, type)
|
||||
&& StringUtils.equals(o.externalId, externalId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(realmId, componentId, type, id, externalId);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
package org.keycloak.federation.scim.jpa;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.IdClass;
|
||||
import jakarta.persistence.NamedQueries;
|
||||
import jakarta.persistence.NamedQuery;
|
||||
import jakarta.persistence.Table;
|
||||
import org.keycloak.federation.scim.core.service.EntityOnRemoteScimId;
|
||||
import org.keycloak.federation.scim.core.service.KeycloakId;
|
||||
|
||||
@Entity
|
||||
@IdClass(ScimResourceId.class)
|
||||
@Table(name = "SCIM_RESOURCE_MAPPING")
|
||||
@NamedQueries({
|
||||
@NamedQuery(name = "findById", query = "from ScimResourceMapping where realmId = :realmId and componentId = :componentId and type = :type and id = :id"),
|
||||
@NamedQuery(name = "findByExternalId", query = "from ScimResourceMapping where realmId = :realmId and componentId = :componentId and type = :type and externalId = :id") })
|
||||
public class ScimResourceMapping {
|
||||
|
||||
@Id
|
||||
@Column(name = "ID", nullable = false)
|
||||
private String id;
|
||||
|
||||
@Id
|
||||
@Column(name = "REALM_ID", nullable = false)
|
||||
private String realmId;
|
||||
|
||||
@Id
|
||||
@Column(name = "COMPONENT_ID", nullable = false)
|
||||
private String componentId;
|
||||
|
||||
@Id
|
||||
@Column(name = "TYPE", nullable = false)
|
||||
private String type;
|
||||
|
||||
@Id
|
||||
@Column(name = "EXTERNAL_ID", nullable = false)
|
||||
private String externalId;
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getRealmId() {
|
||||
return realmId;
|
||||
}
|
||||
|
||||
public void setRealmId(String realmId) {
|
||||
this.realmId = realmId;
|
||||
}
|
||||
|
||||
public String getComponentId() {
|
||||
return componentId;
|
||||
}
|
||||
|
||||
public void setComponentId(String componentId) {
|
||||
this.componentId = componentId;
|
||||
}
|
||||
|
||||
public String getExternalId() {
|
||||
return externalId;
|
||||
}
|
||||
|
||||
public void setExternalId(String externalId) {
|
||||
this.externalId = externalId;
|
||||
}
|
||||
|
||||
public String getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public void setType(String type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public KeycloakId getIdAsKeycloakId() {
|
||||
return new KeycloakId(id);
|
||||
}
|
||||
|
||||
public EntityOnRemoteScimId getExternalIdAsEntityOnRemoteScimId() {
|
||||
return new EntityOnRemoteScimId(externalId);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
package org.keycloak.federation.scim.jpa;
|
||||
|
||||
import org.keycloak.connections.jpa.entityprovider.JpaEntityProvider;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public class ScimResourceProvider implements JpaEntityProvider {
|
||||
|
||||
@Override
|
||||
public List<Class<?>> getEntities() {
|
||||
return Collections.singletonList(ScimResourceMapping.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getChangelogLocation() {
|
||||
return "META-INF/scim-resource-changelog.xml";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
// Nothing to close
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getFactoryId() {
|
||||
return ScimResourceProviderFactory.ID;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
package org.keycloak.federation.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 {
|
||||
|
||||
static final String ID = "scim-resource";
|
||||
|
||||
@Override
|
||||
public JpaEntityProvider create(KeycloakSession session) {
|
||||
return new ScimResourceProvider();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void init(Scope scope) {
|
||||
// Nothing to initialise
|
||||
}
|
||||
|
||||
@Override
|
||||
public void postInit(KeycloakSessionFactory sessionFactory) {
|
||||
// Nothing to do
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
// Nothing to close
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<jboss-deployment-structure>
|
||||
<deployment>
|
||||
<dependencies>
|
||||
<module name="org.keycloak.keycloak-services" />
|
||||
<module name="org.keycloak.keycloak-model-jpa" />
|
||||
<module name="org.hibernate" />
|
||||
</dependencies>
|
||||
</deployment>
|
||||
</jboss-deployment-structure>
|
|
@ -0,0 +1,35 @@
|
|||
<?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_MAPPING">
|
||||
<column name="ID" type="VARCHAR(36)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="REALM_ID" type="VARCHAR(36)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="TYPE" type="VARCHAR(36)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="COMPONENT_ID" type="VARCHAR(36)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="EXTERNAL_ID" type="VARCHAR(36)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</createTable>
|
||||
|
||||
<addPrimaryKey constraintName="PK_SCIM_RESOURCE_MAPPING" tableName="SCIM_RESOURCE_MAPPING"
|
||||
columnNames="ID,REALM_ID,TYPE,COMPONENT_ID,EXTERNAL_ID"/>
|
||||
<addForeignKeyConstraint baseTableName="SCIM_RESOURCE_MAPPING" baseColumnNames="REALM_ID"
|
||||
constraintName="FK_SCIM_RESOURCE_MAPPING_REALM" referencedTableName="REALM"
|
||||
referencedColumnNames="ID" onDelete="CASCADE" onUpdate="CASCADE"/>
|
||||
<addForeignKeyConstraint baseTableName="SCIM_RESOURCE_MAPPING" baseColumnNames="COMPONENT_ID"
|
||||
constraintName="FK_SCIM_RESOURCE_MAPPING_COMPONENT" referencedTableName="COMPONENT"
|
||||
referencedColumnNames="ID" onDelete="CASCADE" onUpdate="CASCADE"/>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
|
@ -0,0 +1 @@
|
|||
org.keycloak.federation.scim.jpa.ScimResourceProviderFactory
|
|
@ -0,0 +1 @@
|
|||
org.keycloak.federation.scim.event.ScimEventListenerProviderFactory
|
|
@ -0,0 +1 @@
|
|||
org.keycloak.federation.scim.core.ScimEndpointConfigurationStorageProviderFactory
|
5
pom.xml
5
pom.xml
|
@ -927,6 +927,11 @@
|
|||
<artifactId>keycloak-ldap-federation</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-scim-federation</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-dependencies-server-min</artifactId>
|
||||
|
|
|
@ -324,6 +324,11 @@
|
|||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-scim-federation</artifactId>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-config-api</artifactId>
|
||||
|
|
|
@ -58,6 +58,12 @@ public class EventBean {
|
|||
return details;
|
||||
}
|
||||
|
||||
public String getDetail(String name) {
|
||||
return event.getDetails() != null
|
||||
? event.getDetails().get(name)
|
||||
: null;
|
||||
}
|
||||
|
||||
public static class DetailBean {
|
||||
|
||||
private Map.Entry<String, String> entry;
|
||||
|
|
|
@ -19,17 +19,21 @@
|
|||
|
||||
package org.keycloak.testsuite.actions;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.util.List;
|
||||
import org.hamcrest.MatcherAssert;
|
||||
import org.hamcrest.Matchers;
|
||||
import org.jboss.arquillian.graphene.page.Page;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Before;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.keycloak.authentication.requiredactions.DeleteCredentialAction;
|
||||
import org.keycloak.events.Details;
|
||||
import org.keycloak.events.Errors;
|
||||
import org.keycloak.events.EventType;
|
||||
import org.keycloak.events.email.EmailEventListenerProviderFactory;
|
||||
import org.keycloak.models.credential.OTPCredentialModel;
|
||||
import org.keycloak.models.credential.PasswordCredentialModel;
|
||||
import org.keycloak.models.utils.TimeBasedOTP;
|
||||
|
@ -41,6 +45,9 @@ import org.keycloak.testsuite.pages.DeleteCredentialPage;
|
|||
import org.keycloak.testsuite.pages.ErrorPage;
|
||||
import org.keycloak.testsuite.pages.LoginConfigTotpPage;
|
||||
import org.keycloak.testsuite.pages.LoginTotpPage;
|
||||
import org.keycloak.testsuite.updaters.RealmAttributeUpdater;
|
||||
import org.keycloak.testsuite.util.GreenMailRule;
|
||||
import org.keycloak.testsuite.util.MailUtils;
|
||||
import org.keycloak.testsuite.util.UserBuilder;
|
||||
|
||||
/**
|
||||
|
@ -48,6 +55,9 @@ import org.keycloak.testsuite.util.UserBuilder;
|
|||
*/
|
||||
public class AppInitiatedActionDeleteCredentialTest extends AbstractAppInitiatedActionTest {
|
||||
|
||||
@Rule
|
||||
public GreenMailRule greenMail = new GreenMailRule();
|
||||
|
||||
@Override
|
||||
protected String getAiaAction() {
|
||||
return DeleteCredentialAction.PROVIDER_ID;
|
||||
|
@ -76,9 +86,11 @@ public class AppInitiatedActionDeleteCredentialTest extends AbstractAppInitiated
|
|||
|
||||
@Before
|
||||
public void beforeTest() {
|
||||
ApiUtil.removeUserByUsername(testRealm(), "test-user@localhost");
|
||||
UserRepresentation user = UserBuilder.create()
|
||||
.username("john")
|
||||
.email("john@email.cz")
|
||||
.email("test-user@localhost")
|
||||
.emailVerified(true)
|
||||
.firstName("John")
|
||||
.lastName("Bar")
|
||||
.enabled(true)
|
||||
|
@ -92,6 +104,10 @@ public class AppInitiatedActionDeleteCredentialTest extends AbstractAppInitiated
|
|||
|
||||
@Test
|
||||
public void removeOtpSuccess() throws Exception {
|
||||
try (RealmAttributeUpdater updater = new RealmAttributeUpdater(testRealm())
|
||||
.addEventsListener(EmailEventListenerProviderFactory.ID)
|
||||
.update()) {
|
||||
|
||||
String credentialId = getCredentialIdByType(OTPCredentialModel.TYPE);
|
||||
oauth.kcAction(getKcActionParamForDeleteCredential(credentialId));
|
||||
|
||||
|
@ -119,6 +135,17 @@ public class AppInitiatedActionDeleteCredentialTest extends AbstractAppInitiated
|
|||
.detail(Details.CREDENTIAL_ID, credentialId)
|
||||
.detail(Details.CUSTOM_REQUIRED_ACTION, DeleteCredentialAction.PROVIDER_ID)
|
||||
.assertEvent();
|
||||
|
||||
MimeMessage[] receivedMessages = greenMail.getReceivedMessages();
|
||||
Assert.assertEquals(2, receivedMessages.length);
|
||||
|
||||
Assert.assertEquals("Remove OTP", receivedMessages[0].getSubject());
|
||||
Assert.assertEquals("Remove credential", receivedMessages[1].getSubject());
|
||||
MatcherAssert.assertThat(MailUtils.getBody(receivedMessages[1]).getText(),
|
||||
Matchers.startsWith("Credential otp was removed from your account"));
|
||||
MatcherAssert.assertThat(MailUtils.getBody(receivedMessages[1]).getHtml(),
|
||||
Matchers.containsString("Credential otp was removed from your account"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
@ -16,6 +16,9 @@
|
|||
*/
|
||||
package org.keycloak.testsuite.actions;
|
||||
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import org.hamcrest.MatcherAssert;
|
||||
import org.hamcrest.Matchers;
|
||||
import org.jboss.arquillian.drone.api.annotation.Drone;
|
||||
import org.jboss.arquillian.graphene.page.Page;
|
||||
import org.junit.After;
|
||||
|
@ -26,6 +29,7 @@ import org.keycloak.admin.client.resource.UserResource;
|
|||
import org.keycloak.cookie.CookieType;
|
||||
import org.keycloak.events.Details;
|
||||
import org.keycloak.events.EventType;
|
||||
import org.keycloak.events.email.EmailEventListenerProviderFactory;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.models.credential.PasswordCredentialModel;
|
||||
|
@ -35,7 +39,10 @@ import org.keycloak.representations.idm.UserRepresentation;
|
|||
import org.keycloak.representations.idm.UserSessionRepresentation;
|
||||
import org.keycloak.testsuite.admin.ApiUtil;
|
||||
import org.keycloak.testsuite.pages.LoginPasswordUpdatePage;
|
||||
import org.keycloak.testsuite.updaters.RealmAttributeUpdater;
|
||||
import org.keycloak.testsuite.updaters.UserAttributeUpdater;
|
||||
import org.keycloak.testsuite.util.GreenMailRule;
|
||||
import org.keycloak.testsuite.util.MailUtils;
|
||||
import org.keycloak.testsuite.util.OAuthClient;
|
||||
import org.keycloak.testsuite.util.SecondBrowser;
|
||||
import org.openqa.selenium.Cookie;
|
||||
|
@ -81,6 +88,13 @@ public class AppInitiatedActionResetPasswordTest extends AbstractAppInitiatedAct
|
|||
|
||||
@Test
|
||||
public void resetPassword() throws Exception {
|
||||
try (RealmAttributeUpdater realmUpdater = new RealmAttributeUpdater(testRealm())
|
||||
.addEventsListener(EmailEventListenerProviderFactory.ID)
|
||||
.update();
|
||||
UserAttributeUpdater userUpdater = new UserAttributeUpdater(ApiUtil.findUserByUsernameId(testRealm(), "test-user@localhost"))
|
||||
.setEmailVerified(true)
|
||||
.update()) {
|
||||
|
||||
loginPage.open();
|
||||
loginPage.login("test-user@localhost", "password");
|
||||
|
||||
|
@ -110,6 +124,16 @@ public class AppInitiatedActionResetPasswordTest extends AbstractAppInitiatedAct
|
|||
events.expectRequiredAction(EventType.UPDATE_PASSWORD).assertEvent();
|
||||
events.expectRequiredAction(EventType.UPDATE_CREDENTIAL).detail(Details.CREDENTIAL_TYPE, PasswordCredentialModel.TYPE).assertEvent();
|
||||
|
||||
MimeMessage[] receivedMessages = greenMail.getReceivedMessages();
|
||||
Assert.assertEquals(2, receivedMessages.length);
|
||||
|
||||
Assert.assertEquals("Update password", receivedMessages[0].getSubject());
|
||||
Assert.assertEquals("Update credential", receivedMessages[1].getSubject());
|
||||
MatcherAssert.assertThat(MailUtils.getBody(receivedMessages[1]).getText(),
|
||||
Matchers.startsWith("Your password credential was changed"));
|
||||
MatcherAssert.assertThat(MailUtils.getBody(receivedMessages[1]).getHtml(),
|
||||
Matchers.containsString("Your password credential was changed"));
|
||||
|
||||
assertKcActionStatus(SUCCESS);
|
||||
|
||||
EventRepresentation loginEvent = events.expectLogin().assertEvent();
|
||||
|
@ -124,6 +148,7 @@ public class AppInitiatedActionResetPasswordTest extends AbstractAppInitiatedAct
|
|||
|
||||
events.expectLogin().assertEvent();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resetPasswordRequiresReAuth() throws Exception {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<#import "template.ftl" as layout>
|
||||
<@layout.emailLayout>
|
||||
${kcSanitize(msg("eventRemoveCredentialBodyHtml", event.details.credential_type!"unknown", event.date, event.ipAddress))?no_esc}
|
||||
${kcSanitize(msg("eventRemoveCredentialBodyHtml", event.getDetail("credential_type")!"unknown", event.date, event.ipAddress))?no_esc}
|
||||
</@layout.emailLayout>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<#import "template.ftl" as layout>
|
||||
<@layout.emailLayout>
|
||||
${kcSanitize(msg("eventUpdateCredentialBodyHtml", event.details.credential_type!"unknown", event.date, event.ipAddress))?no_esc}
|
||||
${kcSanitize(msg("eventUpdateCredentialBodyHtml", event.getDetail("credential_type")!"unknown", event.date, event.ipAddress))?no_esc}
|
||||
</@layout.emailLayout>
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
<#ftl output_format="plainText">
|
||||
${msg("eventRemoveCredentialBody", event.details.credential_type!"unknown", event.date, event.ipAddress)}
|
||||
${msg("eventRemoveCredentialBody", event.getDetail("credential_type")!"unknown", event.date, event.ipAddress)}
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
<#ftl output_format="plainText">
|
||||
${msg("eventUpdateCredentialBody", event.details.credential_type!"unknown", event.date, event.ipAddress)}
|
||||
${msg("eventUpdateCredentialBody", event.getDetail("credential_type")!"unknown", event.date, event.ipAddress)}
|
||||
|
|
Loading…
Reference in a new issue