From da4595f70b704e7ea6c6f5de676362b8b8177760 Mon Sep 17 00:00:00 2001 From: Garth <244253+xgp@users.noreply.github.com> Date: Fri, 5 Sep 2025 16:18:16 +0200 Subject: [PATCH 01/35] reorganized orgs into 2 implementations for keycloak and phasetwo orgs. refactored the code to simplify/share a bit. --- build.gradle.kts | 1 + .../server/ScimRealmResourceProvider.java | 11 +- .../ScimRealmResourceProviderFactory.java | 11 +- .../keycloak/scim/server/ScimResources.java | 5 +- .../organization/OrganizationController.java | 33 ----- .../organization/OrganizationScimConfig.java | 29 +--- .../organization/OrganizationScimContext.java | 106 +++++++++++---- .../organization/OrganizationScimServer.java | 52 +------ .../OrganizationScimServerProvider.java | 11 ++ ...OrganizationScimServerProviderFactory.java | 5 + .../OrganizationScimServerSpi.java | 29 ++++ .../OrganizationUserController.java | 128 +++--------------- .../KeycloakOrganizationScimContext.java | 105 ++++++++++++++ ...eycloakOrganizationScimServerProvider.java | 83 ++++++++++++ ...OrganizationScimServerProviderFactory.java | 30 ++++ .../PhasetwoOrganizationScimContext.java | 119 ++++++++++++++++ ...hasetwoOrganizationScimServerProvider.java | 70 ++++++++++ ...OrganizationScimServerProviderFactory.java | 31 +++++ .../scim/server/users/UsersController.java | 2 +- ...tion.OrganizationScimServerProviderFactory | 2 + .../services/org.keycloak.provider.Spi | 1 + 21 files changed, 617 insertions(+), 247 deletions(-) delete mode 100644 src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationController.java create mode 100644 src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServerProvider.java create mode 100644 src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServerProviderFactory.java create mode 100644 src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServerSpi.java create mode 100644 src/main/java/fi/metatavu/keycloak/scim/server/organization/keycloak/KeycloakOrganizationScimContext.java create mode 100644 src/main/java/fi/metatavu/keycloak/scim/server/organization/keycloak/KeycloakOrganizationScimServerProvider.java create mode 100644 src/main/java/fi/metatavu/keycloak/scim/server/organization/keycloak/KeycloakOrganizationScimServerProviderFactory.java create mode 100644 src/main/java/fi/metatavu/keycloak/scim/server/organization/phasetwo/PhasetwoOrganizationScimContext.java create mode 100644 src/main/java/fi/metatavu/keycloak/scim/server/organization/phasetwo/PhasetwoOrganizationScimServerProvider.java create mode 100644 src/main/java/fi/metatavu/keycloak/scim/server/organization/phasetwo/PhasetwoOrganizationScimServerProviderFactory.java create mode 100644 src/main/resources/META-INF/services/fi.metatavu.keycloak.scim.server.organization.OrganizationScimServerProviderFactory create mode 100644 src/main/resources/META-INF/services/org.keycloak.provider.Spi diff --git a/build.gradle.kts b/build.gradle.kts index 39a7cec..0ecef35 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -46,6 +46,7 @@ val jacocoVersion: String by project val jacocoRuntime: Configuration by configurations.creating dependencies { + implementation("io.phasetwo.keycloak:keycloak-orgs:0.114") implementation(enforcedPlatform("org.keycloak.bom:keycloak-bom-parent:$keycloakVersion")) compileOnly("org.keycloak:keycloak-services:$keycloakVersion") diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/ScimRealmResourceProvider.java b/src/main/java/fi/metatavu/keycloak/scim/server/ScimRealmResourceProvider.java index 26f9193..97b6180 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/ScimRealmResourceProvider.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/ScimRealmResourceProvider.java @@ -1,15 +1,24 @@ package fi.metatavu.keycloak.scim.server; import org.keycloak.services.resource.RealmResourceProvider; +import org.keycloak.models.KeycloakSession; /** * SCIM realm resource provider */ public class ScimRealmResourceProvider implements RealmResourceProvider { + private final KeycloakSession session; + private final String organizationType; + + public ScimRealmResourceProvider(KeycloakSession session, String organizationType) { + this.session = session; + this.organizationType = organizationType; + } + @Override public Object getResource() { - return new ScimResources(); + return new ScimResources(session, organizationType); } @Override diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/ScimRealmResourceProviderFactory.java b/src/main/java/fi/metatavu/keycloak/scim/server/ScimRealmResourceProviderFactory.java index a80d658..c64bf45 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/ScimRealmResourceProviderFactory.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/ScimRealmResourceProviderFactory.java @@ -1,5 +1,6 @@ package fi.metatavu.keycloak.scim.server; +import com.google.common.base.Strings; import org.keycloak.Config; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; @@ -13,13 +14,19 @@ */ public class ScimRealmResourceProviderFactory implements RealmResourceProviderFactory { + private String organizationType = "default"; + @Override public RealmResourceProvider create(KeycloakSession session) { - return new ScimRealmResourceProvider(); + return new ScimRealmResourceProvider(session, organizationType); } @Override - public void init(Config.Scope config) {} + public void init(Config.Scope config) { + // allows overriding the default organization type with 'phasetwo' + String orgTypeConfig = config.get("organizationType"); + if (!Strings.isNullOrEmpty(orgTypeConfig)) organizationType = orgTypeConfig; + } @Override public void postInit(KeycloakSessionFactory factory) {} diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/ScimResources.java b/src/main/java/fi/metatavu/keycloak/scim/server/ScimResources.java index 4d8a577..4f33139 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/ScimResources.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/ScimResources.java @@ -6,6 +6,7 @@ import fi.metatavu.keycloak.scim.server.model.Group; import fi.metatavu.keycloak.scim.server.organization.OrganizationScimContext; import fi.metatavu.keycloak.scim.server.organization.OrganizationScimServer; +import fi.metatavu.keycloak.scim.server.organization.OrganizationScimServerProvider; import fi.metatavu.keycloak.scim.server.realm.RealmScimContext; import fi.metatavu.keycloak.scim.server.realm.RealmScimServer; import jakarta.ws.rs.*; @@ -23,10 +24,10 @@ public class ScimResources { private final RealmScimServer realmScimServer; private final OrganizationScimServer organizationScimServer; - ScimResources() { + ScimResources(KeycloakSession session, String organizationType) { scimFilterParser = new ScimFilterParser(); realmScimServer = new RealmScimServer(); - organizationScimServer = new OrganizationScimServer(); + organizationScimServer = session.getProvider(OrganizationScimServerProvider.class, organizationType).getScimServer(session); } // Realm Server endpoints diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationController.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationController.java deleted file mode 100644 index 2277d7a..0000000 --- a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationController.java +++ /dev/null @@ -1,33 +0,0 @@ -package fi.metatavu.keycloak.scim.server.organization; - -import fi.metatavu.keycloak.scim.server.AbstractController; -import org.keycloak.models.KeycloakContext; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.OrganizationModel; -import org.keycloak.organization.OrganizationProvider; - -public class OrganizationController extends AbstractController { - - public OrganizationModel findOrganizationById( - KeycloakSession session, - String organizationId - ) { - return getOrganizationProvider(session).getById(organizationId); - } - - /** - * Returns the organization provider - * - * @param session Keycloak session - * @return Organization provider - */ - private OrganizationProvider getOrganizationProvider(KeycloakSession session) { - KeycloakContext context = session.getContext(); - if (context == null) { - throw new IllegalStateException("Keycloak context is not set"); - } - - return session.getProvider(OrganizationProvider.class); - } - -} diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimConfig.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimConfig.java index e950043..ecff099 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimConfig.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimConfig.java @@ -2,7 +2,6 @@ import fi.metatavu.keycloak.scim.server.config.ConfigurationError; import fi.metatavu.keycloak.scim.server.config.ScimConfig; -import org.keycloak.models.OrganizationModel; import java.util.List; import java.util.Map; @@ -10,13 +9,7 @@ /** * SCIM configuration for organizations */ -public class OrganizationScimConfig implements ScimConfig { - - private final OrganizationModel organization; - - public OrganizationScimConfig(OrganizationModel organization) { - this.organization = organization; - } +public abstract class OrganizationScimConfig implements ScimConfig { @Override public void validateConfig() throws ConfigurationError { @@ -76,24 +69,6 @@ public boolean getEmailAsUsername() { return "true".equalsIgnoreCase(getAttribute("SCIM_EMAIL_AS_USERNAME")); } - /** - * Gets the organization attribute - * - * @return organization attribute value - */ - private String getAttribute(String attributeName) { - Map> attributes = organization.getAttributes(); - if (attributes == null) { - return null; - } - - List values = attributes.get(attributeName); - if (values == null || values.isEmpty()) { - return null; - } - - return values.getFirst(); - } - + public abstract String getAttribute(String attributeName); } diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimContext.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimContext.java index 1ba6862..ac7d8a3 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimContext.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimContext.java @@ -2,38 +2,90 @@ import fi.metatavu.keycloak.scim.server.ScimContext; import org.keycloak.models.KeycloakSession; -import org.keycloak.models.OrganizationModel; import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; import java.net.URI; +import java.util.stream.Stream; /** - * SCIM context for organizations + * SCIM context for organizations. Extended to allow hiding of which organization + * implementation is being used. */ -public class OrganizationScimContext extends ScimContext { - - private final OrganizationModel organization; - - /** - * Constructor - * - * @param baseUri base URI - * @param session keycloak session - * @param realm realm - * @param organization organization - */ - public OrganizationScimContext(URI baseUri, KeycloakSession session, RealmModel realm, OrganizationModel organization, OrganizationScimConfig config) { - super(baseUri, session, realm, config); - this.organization = organization; - } - - /** - * Gets the organization - * - * @return organization - */ - public OrganizationModel getOrganization() { - return organization; - } +public abstract class OrganizationScimContext extends ScimContext { + protected final String organizationId; + + /** + * Constructor + * + * @param baseUri base URI + * @param session keycloak session + * @param realm realm + * @param organizationId organizationId + * @param config organization scim config + */ + public OrganizationScimContext(URI baseUri, KeycloakSession session, RealmModel realm, String organizationId, OrganizationScimConfig config) { + super(baseUri, session, realm, config); + this.organizationId = organizationId; + } + + /** + * Gets the organizationId + * + * @return organizationId + */ + public String getOrganizationId() { + return organizationId; + } + + /** + * Get a paginated stream of this organization's users + * + * @return Stream of UserModel + */ + public abstract Stream getMembersStream(Integer first, Integer max); + + /** + * Find the user that is a member of this organization given the userId + * + * @return UserModel found user, or null + */ + public abstract UserModel findUser(String userId); + + /** + * Add a member to this organization + * + * @return true if the user was successfully added as a member + */ + public abstract boolean addMember(UserModel user); + + /** + * Checks if the user is a member of this organization + * + * @return true if the user is a member + */ + public abstract boolean isMember(UserModel user); + + /** + * Remove a member from this organization + * + * @return true if the user was successfully removed as a member + */ + public abstract boolean removeMember(UserModel user); + + /** + * Link the user to this organization's first IdP + * + * @return true if the user was successfully linked + */ + public abstract boolean linkUserIdp(UserModel user, String scimUserEmail, String scimUserName, String scimExternalId); + + /** + * Gets the organization representation suitable for serializaing to JSON + * for admin events or REST responses + * + * @return organization + */ + public abstract Object toRepresentation(); } diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServer.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServer.java index ed7bb32..8be9328 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServer.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServer.java @@ -21,14 +21,14 @@ /** * SCIM server implementation for organizations */ -public class OrganizationScimServer extends AbstractScimServer { +public abstract class OrganizationScimServer extends AbstractScimServer { private static final Logger logger = Logger.getLogger(OrganizationScimServer.class); - private final OrganizationController organizationController; private final OrganizationUserController organizationUserController; - - public OrganizationScimServer() { - this.organizationController = new OrganizationController(); + protected final KeycloakSession session; + + public OrganizationScimServer(KeycloakSession session) { + this.session = session; this.organizationUserController = new OrganizationUserController(); } @@ -225,46 +225,6 @@ public Response deleteGroup(OrganizationScimContext scimContext, String id) { return Response.status(Response.Status.NOT_IMPLEMENTED).build(); } - /** - * Returns SCIM context - * - * @param session Keycloak session - * @return SCIM context - */ - public OrganizationScimContext getScimContext(KeycloakSession session, String organizationId) { - RealmModel realm = session.getContext().getRealm(); - if (realm == null) { - throw new NotFoundException("Realm not found"); - } - - OrganizationModel organization = organizationController.findOrganizationById( - session, - organizationId - ); - - if (organization == null) { - throw new NotFoundException("Organization not found"); - } - - KeycloakContext context = session.getContext(); - context.setOrganization(organization); - - URI baseUri = session.getContext().getUri().getBaseUri().resolve(String.format("realms/%s/scim/v2/organizations/%s/", realm.getName(), organization.getId())); - OrganizationScimConfig config = new OrganizationScimConfig(organization); - - try { - config.validateConfig(); - } catch (ConfigurationError e) { - throw new InternalServerErrorException("Invalid SCIM configuration", e); - } - - return new OrganizationScimContext( - baseUri, - session, - realm, - organization, - config - ); - } + public abstract OrganizationScimContext getScimContext(KeycloakSession session, String organizationId); } diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServerProvider.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServerProvider.java new file mode 100644 index 0000000..5a703b4 --- /dev/null +++ b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServerProvider.java @@ -0,0 +1,11 @@ +package fi.metatavu.keycloak.scim.server.organization; + +import org.keycloak.provider.Provider; +import org.keycloak.models.KeycloakSession; + +public interface OrganizationScimServerProvider extends Provider { + + public OrganizationScimServer getScimServer(KeycloakSession session); + + default void close() {} +} diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServerProviderFactory.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServerProviderFactory.java new file mode 100644 index 0000000..1f77c4b --- /dev/null +++ b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServerProviderFactory.java @@ -0,0 +1,5 @@ +package fi.metatavu.keycloak.scim.server.organization; + +import org.keycloak.provider.ProviderFactory; + +public interface OrganizationScimServerProviderFactory extends ProviderFactory {} diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServerSpi.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServerSpi.java new file mode 100644 index 0000000..c4a71e3 --- /dev/null +++ b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServerSpi.java @@ -0,0 +1,29 @@ +package fi.metatavu.keycloak.scim.server.organization; + +import org.keycloak.provider.Provider; +import org.keycloak.provider.ProviderFactory; +import org.keycloak.provider.Spi; + +public class OrganizationScimServerSpi implements Spi { + + @Override + public boolean isInternal() { + return false; + } + + @Override + public String getName() { + return "organizationScimServerProvider"; + } + + @Override + public Class getProviderClass() { + return OrganizationScimServerProvider.class; + } + + @Override + @SuppressWarnings("rawtypes") + public Class getProviderFactoryClass() { + return OrganizationScimServerProviderFactory.class; + } +} diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationUserController.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationUserController.java index c494673..00b4a08 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationUserController.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationUserController.java @@ -16,9 +16,11 @@ import org.jboss.logging.Logger; import org.keycloak.events.admin.OperationType; import org.keycloak.events.admin.ResourceType; -import org.keycloak.models.*; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RoleModel; +import org.keycloak.models.UserModel; import org.keycloak.models.utils.ModelToRepresentation; -import org.keycloak.organization.OrganizationProvider; import java.util.Collections; import java.util.HashMap; @@ -43,7 +45,6 @@ public fi.metatavu.keycloak.scim.server.model.User createOrganizationUser( ) { KeycloakSession session = scimContext.getSession(); RealmModel realm = scimContext.getRealm(); - OrganizationModel organization = scimContext.getOrganization(); ScimConfig config = scimContext.getConfig(); UserModel user = session.users().addUser(realm, scimUser.getUserName()); @@ -90,8 +91,7 @@ public fi.metatavu.keycloak.scim.server.model.User createOrganizationUser( }); } - OrganizationProvider organizationProvider = getOrganizationProvider(scimContext.getSession()); - organizationProvider.addManagedMember(organization, user); + scimContext.addMember(user); User createdUser = translateUser( scimContext, @@ -100,9 +100,10 @@ public fi.metatavu.keycloak.scim.server.model.User createOrganizationUser( ); if (config.getLinkIdp()) { - String scimUsername = createdUser.getUserName(); + scimUserEmail = getScimUserEmail(createdUser, config); + String scimUserName = createdUser.getUserName(); String externalId = getExternalId(createdUser); - linkUserIdp(organizationProvider, organization, session, realm, user, scimUserEmail, scimUsername, externalId); + scimContext.linkUserIdp(user, scimUserEmail, scimUserName, externalId); } dispatchUserCreateEvent(scimContext, user); @@ -128,7 +129,6 @@ public fi.metatavu.keycloak.scim.server.model.User updateOrganizationUser( ) { KeycloakSession session = scimContext.getSession(); RealmModel realm = scimContext.getRealm(); - OrganizationModel organization = scimContext.getOrganization(); ScimConfig config = scimContext.getConfig(); ((StringUserAttribute) userAttributes.findByScimPath("userName")).write(existing, scimUser.getUserName()); @@ -174,11 +174,10 @@ public fi.metatavu.keycloak.scim.server.model.User updateOrganizationUser( ); if (config.getLinkIdp()) { - OrganizationProvider organizationProvider = getOrganizationProvider(scimContext.getSession()); String scimUserEmail = getScimUserEmail(updatedUser, config); - String scimUsername = updatedUser.getUserName(); + String scimUserName = updatedUser.getUserName(); String externalId = getExternalId(updatedUser); - linkUserIdp(organizationProvider, organization, session, realm, existing, scimUserEmail, scimUsername, externalId); + scimContext.linkUserIdp(existing, scimUserEmail, scimUserName, externalId); } dispatchUserUpdateEvent(scimContext, existing); @@ -203,7 +202,6 @@ public fi.metatavu.keycloak.scim.server.model.User patchOrganizationUser( ) throws UnsupportedPatchOperation { KeycloakSession session = scimContext.getSession(); RealmModel realm = scimContext.getRealm(); - OrganizationModel organization = scimContext.getOrganization(); ScimConfig config = scimContext.getConfig(); for (var operation : patchRequest.getOperations()) { @@ -252,11 +250,10 @@ public fi.metatavu.keycloak.scim.server.model.User patchOrganizationUser( ); if (config.getLinkIdp()) { - OrganizationProvider organizationProvider = getOrganizationProvider(scimContext.getSession()); String scimUserEmail = getScimUserEmail(patchedUser, config); - String scimUsername = patchedUser.getUserName(); + String scimUserName = patchedUser.getUserName(); String externalId = getExternalId(patchedUser); - linkUserIdp(organizationProvider, organization, session, realm, existing, scimUserEmail, scimUsername, externalId); + scimContext.linkUserIdp(existing, scimUserEmail, scimUserName, externalId); } dispatchUserUpdateEvent(scimContext, existing); @@ -278,10 +275,7 @@ public fi.metatavu.keycloak.scim.server.model.User findOrganizationUser( String userId ) { try { - UserModel organizationUser = getOrganizationProvider(scimContext.getSession()).getMemberById( - scimContext.getOrganization(), - userId - ); + UserModel organizationUser = scimContext.findUser(userId); return translateUser( scimContext, @@ -318,7 +312,7 @@ public fi.metatavu.keycloak.scim.server.model.UsersList listOrganizationUsers( throw new IllegalStateException("SCIM managed role not found"); } - List filteredUsers = getOrganizationProvider(session).getMembersStream(scimContext.getOrganization(), Collections.emptyMap(), true, null, null) + List filteredUsers = scimContext.getMembersStream(null, null) .filter(user -> matchScimFilter(user, userAttributes, scimFilter)) .filter(user -> user.hasRole(scimManagedRole)) .toList(); @@ -345,10 +339,9 @@ public fi.metatavu.keycloak.scim.server.model.UsersList listOrganizationUsers( */ public void deleteOrganizationUser(OrganizationScimContext scimContext, UserModel user) { KeycloakSession session = scimContext.getSession(); - OrganizationProvider organizationProvider = getOrganizationProvider(session); - if (organizationProvider.isManagedMember(scimContext.getOrganization(), user)) { - organizationProvider.removeMember(scimContext.getOrganization(), user); + if (scimContext.isMember(user)) { + scimContext.removeMember(user); dispatchOrganizationMemberDeleteEvent(scimContext, user); dispatchUserDeleteEvent(scimContext, user); } else { @@ -356,21 +349,6 @@ public void deleteOrganizationUser(OrganizationScimContext scimContext, UserMode } } - /** - * Returns the organization provider - * - * @param session Keycloak session - * @return Organization provider - */ - private OrganizationProvider getOrganizationProvider(KeycloakSession session) { - KeycloakContext context = session.getContext(); - if (context == null) { - throw new IllegalStateException("Keycloak context is not set"); - } - - return session.getProvider(OrganizationProvider.class); - } - /** * Gets the email from SCIM user * @@ -405,70 +383,6 @@ private String getExternalId(fi.metatavu.keycloak.scim.server.model.User scimUse return externalId; } - /** - * Links user to identity provider - * - * @param organizationProvider organization provider - * @param organization organization - * @param session Keycloak session - * @param realm Keycloak realm - * @param user Keycloak user - * @param scimUserEmail SCIM user email - * @param scimUserName SCIM username - * @param scimExternalId SCIM user external ID - */ - private void linkUserIdp( - OrganizationProvider organizationProvider, - OrganizationModel organization, - KeycloakSession session, - RealmModel realm, - UserModel user, - String scimUserEmail, - String scimUserName, - String scimExternalId - ) { - if (scimUserEmail == null) { - logger.warn("User email is not set. Cannot link user to identity provider"); - return; - } - - if (scimExternalId == null) { - logger.warn("User externalId is not set. Cannot link user to identity provider"); - return; - } - - String emailDomain = getEmailDomain(scimUserEmail); - if (emailDomain == null) { - logger.warn("User email domain is not set. Cannot link user to identity provider"); - return; - } - - IdentityProviderModel identityProvider = organizationProvider.getIdentityProviders(organization) - .filter(identityProviderModel -> { - String identityProviderDomain = identityProviderModel.getConfig().get("kc.org.domain"); - return identityProviderDomain != null && identityProviderDomain.equals(emailDomain); - }) - .findFirst() - .orElse(null); - - if (identityProvider == null) { - logger.warn("No identity provider found for email domain: " + emailDomain + ". Cannot link user to identity provider"); - return; - } - - if (session.users().getFederatedIdentity(realm, user, identityProvider.getAlias()) == null) { - logger.info("Linking user to identity provider: " + identityProvider.getAlias()); - - FederatedIdentityModel identityModel = new FederatedIdentityModel( - identityProvider.getAlias(), - scimExternalId, - scimUserName - ); - - session.users().addFederatedIdentity(realm, user, identityModel); - } - } - /** * Dispatches an event when a user is added to the organization * @@ -479,7 +393,6 @@ private void dispatchOrganizationMemberAddEvent( OrganizationScimContext scimContext, UserModel member ) { - OrganizationModel organization = scimContext.getOrganization(); Map eventDetails = new HashMap<>(); if (member.getUsername() != null) { @@ -494,8 +407,8 @@ private void dispatchOrganizationMemberAddEvent( scimContext, OperationType.CREATE, ResourceType.ORGANIZATION_MEMBERSHIP, - "organizations/" + organization.getId() + "/members", - ModelToRepresentation.toRepresentation(organization), + "organizations/" + scimContext.getOrganizationId() + "/members", + scimContext.toRepresentation(), eventDetails ); } @@ -510,7 +423,6 @@ private void dispatchOrganizationMemberDeleteEvent( OrganizationScimContext scimContext, UserModel member ) { - OrganizationModel organization = scimContext.getOrganization(); Map eventDetails = new HashMap<>(); if (member.getUsername() != null) { @@ -525,8 +437,8 @@ private void dispatchOrganizationMemberDeleteEvent( scimContext, OperationType.DELETE, ResourceType.ORGANIZATION_MEMBERSHIP, - "organizations/" + organization.getId() + "/members/" + member.getId(), - ModelToRepresentation.toRepresentation(organization), + "organizations/" + scimContext.getOrganizationId() + "/members/" + member.getId(), + scimContext.toRepresentation(), eventDetails ); } diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/keycloak/KeycloakOrganizationScimContext.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/keycloak/KeycloakOrganizationScimContext.java new file mode 100644 index 0000000..9636970 --- /dev/null +++ b/src/main/java/fi/metatavu/keycloak/scim/server/organization/keycloak/KeycloakOrganizationScimContext.java @@ -0,0 +1,105 @@ +package fi.metatavu.keycloak.scim.server.organization.keycloak; + +import static fi.metatavu.keycloak.scim.server.users.UsersController.getEmailDomain; + +import fi.metatavu.keycloak.scim.server.ScimContext; +import fi.metatavu.keycloak.scim.server.organization.*; +import org.keycloak.models.FederatedIdentityModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.OrganizationModel; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.organization.OrganizationProvider; +import org.keycloak.models.utils.ModelToRepresentation; +import java.net.URI; +import java.util.Collections; +import java.util.stream.Stream; +import org.jboss.logging.Logger; + +/** + * SCIM context for Keycloak organizations. + */ +public class KeycloakOrganizationScimContext extends OrganizationScimContext { + + private static final Logger logger = Logger.getLogger(KeycloakOrganizationScimContext.class); + + protected final OrganizationModel organization; + + public KeycloakOrganizationScimContext(URI baseUri, KeycloakSession session, RealmModel realm, OrganizationScimConfig config, OrganizationModel organization) { + super(baseUri, session, realm, organization.getId(), config); + this.organization = organization; + } + + @Override + public Stream getMembersStream(Integer first, Integer max) { + return getSession().getProvider(OrganizationProvider.class).getMembersStream(organization, Collections.emptyMap(), true, null, null); + } + + @Override + public UserModel findUser(String userId) { + return getSession().getProvider(OrganizationProvider.class).getMemberById(organization, userId); + } + + @Override + public boolean addMember(UserModel user) { + return getSession().getProvider(OrganizationProvider.class).addManagedMember(organization, user); + } + + @Override + public boolean isMember(UserModel user) { + return getSession().getProvider(OrganizationProvider.class).isManagedMember(organization, user); + } + + @Override + public boolean removeMember(UserModel user) { + return getSession().getProvider(OrganizationProvider.class).removeMember(organization, user); + } + + @Override + public boolean linkUserIdp(UserModel user, String scimUserEmail, String scimUserName, String scimExternalId) { + if (scimUserEmail == null) { + logger.warn("User email is not set. Cannot link user to identity provider"); + return false; + } + + if (scimExternalId == null) { + logger.warn("User externalId is not set. Cannot link user to identity provider"); + return false; + } + + String emailDomain = getEmailDomain(scimUserEmail); + if (emailDomain == null) { + logger.warn("User email domain is not set. Cannot link user to identity provider"); + return false; + } + + IdentityProviderModel identityProvider = + getSession().getProvider(OrganizationProvider.class).getIdentityProviders(organization) + .filter(identityProviderModel -> { + String identityProviderDomain = identityProviderModel.getConfig().get("kc.org.domain"); + return identityProviderDomain != null && identityProviderDomain.equals(emailDomain); + }) + .findFirst() + .orElse(null); + + if (identityProvider == null) { + logger.warn("No identity provider found for email domain: " + emailDomain + ". Cannot link user to identity provider"); + return false; + } + + if (getSession().users().getFederatedIdentity(getRealm(), user, identityProvider.getAlias()) == null) { + logger.info("Linking user to identity provider: " + identityProvider.getAlias()); + FederatedIdentityModel identityModel = new FederatedIdentityModel(identityProvider.getAlias(), scimExternalId, scimUserName); + getSession().users().addFederatedIdentity(getRealm(), user, identityModel); + return true; + } + + return false; + } + + @Override + public Object toRepresentation() { + return ModelToRepresentation.toRepresentation(organization); + } +} diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/keycloak/KeycloakOrganizationScimServerProvider.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/keycloak/KeycloakOrganizationScimServerProvider.java new file mode 100644 index 0000000..1718c0a --- /dev/null +++ b/src/main/java/fi/metatavu/keycloak/scim/server/organization/keycloak/KeycloakOrganizationScimServerProvider.java @@ -0,0 +1,83 @@ +package fi.metatavu.keycloak.scim.server.organization.keycloak; + +import org.keycloak.models.RealmModel; +import org.keycloak.models.KeycloakContext; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.OrganizationModel; +import org.keycloak.organization.OrganizationProvider; +import fi.metatavu.keycloak.scim.server.organization.*; +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import jakarta.ws.rs.InternalServerErrorException; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.core.Response; +import fi.metatavu.keycloak.scim.server.config.ConfigurationError; + +public class KeycloakOrganizationScimServerProvider implements OrganizationScimServerProvider { + + protected final KeycloakSession session; + + public KeycloakOrganizationScimServerProvider(KeycloakSession session) { + this.session = session; + } + + @Override + public OrganizationScimServer getScimServer(KeycloakSession session) { + return new OrganizationScimServer(session) { + @Override + public OrganizationScimContext getScimContext(KeycloakSession session, String organizationId) { + return createScimContext(session, organizationId); + } + }; + } + + private static OrganizationScimContext createScimContext(KeycloakSession session, String organizationId) { + RealmModel realm = session.getContext().getRealm(); + if (realm == null) { + throw new NotFoundException("Realm not found"); + } + + final OrganizationModel organization = session.getProvider(OrganizationProvider.class).getById(organizationId); + + if (organization == null) { + throw new NotFoundException("Organization not found"); + } + + KeycloakContext context = session.getContext(); + context.setOrganization(organization); + + URI baseUri = session.getContext().getUri().getBaseUri().resolve(String.format("realms/%s/scim/v2/organizations/%s/", realm.getName(), organization.getId())); + OrganizationScimConfig config = new OrganizationScimConfig() { + @Override + public String getAttribute(String attributeName) { + Map> attributes = organization.getAttributes(); + if (attributes == null) { + return null; + } + + List values = attributes.get(attributeName); + if (values == null || values.isEmpty()) { + return null; + } + + return values.getFirst(); + } + }; + + try { + config.validateConfig(); + } catch (ConfigurationError e) { + throw new InternalServerErrorException("Invalid SCIM configuration", e); + } + + return new KeycloakOrganizationScimContext( + baseUri, + session, + realm, + config, + organization); + } + +} diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/keycloak/KeycloakOrganizationScimServerProviderFactory.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/keycloak/KeycloakOrganizationScimServerProviderFactory.java new file mode 100644 index 0000000..892880e --- /dev/null +++ b/src/main/java/fi/metatavu/keycloak/scim/server/organization/keycloak/KeycloakOrganizationScimServerProviderFactory.java @@ -0,0 +1,30 @@ +package fi.metatavu.keycloak.scim.server.organization.keycloak; + +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.Config.Scope; +import fi.metatavu.keycloak.scim.server.organization.*; + +public class KeycloakOrganizationScimServerProviderFactory implements OrganizationScimServerProviderFactory { + + public static final String PROVIDER_ID = "default"; + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public OrganizationScimServerProvider create(KeycloakSession session) { + return new KeycloakOrganizationScimServerProvider(session); + } + + @Override + public void init(Scope config) {} + + @Override + public void postInit(KeycloakSessionFactory factory) {} + + @Override + public void close() {} +} diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/phasetwo/PhasetwoOrganizationScimContext.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/phasetwo/PhasetwoOrganizationScimContext.java new file mode 100644 index 0000000..028f81a --- /dev/null +++ b/src/main/java/fi/metatavu/keycloak/scim/server/organization/phasetwo/PhasetwoOrganizationScimContext.java @@ -0,0 +1,119 @@ +package fi.metatavu.keycloak.scim.server.organization.phasetwo; + +import static fi.metatavu.keycloak.scim.server.users.UsersController.getEmailDomain; + +import fi.metatavu.keycloak.scim.server.ScimContext; +import fi.metatavu.keycloak.scim.server.organization.*; +import org.keycloak.models.FederatedIdentityModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import java.net.URI; +import java.util.Collections; +import java.util.stream.Stream; +import org.jboss.logging.Logger; +import io.phasetwo.service.model.OrganizationModel; +import io.phasetwo.service.model.OrganizationProvider; +import io.phasetwo.service.resource.Converters; + +/** + * SCIM context for Phase Two organizations. https://github.com/p2-inc/keycloak-orgs + */ +public class PhasetwoOrganizationScimContext extends OrganizationScimContext { + + private static final Logger logger = Logger.getLogger(PhasetwoOrganizationScimContext.class); + + protected final OrganizationModel organization; + + public PhasetwoOrganizationScimContext(URI baseUri, KeycloakSession session, RealmModel realm, OrganizationScimConfig config, OrganizationModel organization) { + super(baseUri, session, realm, organization.getId(), config); + this.organization = organization; + } + + @Override + public Stream getMembersStream(Integer first, Integer max) { + return organization.searchForMembersStream(null, null, null); + } + + @Override + public UserModel findUser(String userId) { + UserModel organizationUser = getSession().users().getUserById(getRealm(), userId); + if (organizationUser == null) return null; + if (!organization.hasMembership(organizationUser)) return null; + return organizationUser; + } + + @Override + public boolean addMember(UserModel user) { + try { + organization.grantMembership(user); + } catch (Exception ignore) { + return false; + } + return true; + } + + @Override + public boolean isMember(UserModel user) { + return organization.hasMembership(user); + } + + @Override + public boolean removeMember(UserModel user) { + try { + organization.revokeMembership(user); + } catch (Exception ignore) { + return false; + } + return true; + } + + @Override + public boolean linkUserIdp(UserModel user, String scimUserEmail, String scimUserName, String scimExternalId) { + if (scimUserEmail == null) { + logger.warn("User email is not set. Cannot link user to identity provider"); + return false; + } + + if (scimExternalId == null) { + logger.warn("User externalId is not set. Cannot link user to identity provider"); + return false; + } + + String emailDomain = getEmailDomain(scimUserEmail); + if (emailDomain == null) { + logger.warn("User email domain is not set. Cannot link user to identity provider"); + return false; + } + + IdentityProviderModel identityProvider = null; + if (organization.getDomains() != null && organization.getDomains().contains(emailDomain)) { + identityProvider = organization.getIdentityProvidersStream() + .filter(identityProviderModel -> { + return identityProviderModel.isEnabled(); + }) + .findFirst() + .orElse(null); + } + + if (identityProvider == null) { + logger.warn("No identity provider found for email domain: " + emailDomain + ". Cannot link user to identity provider"); + return false; + } + + if (getSession().users().getFederatedIdentity(getRealm(), user, identityProvider.getAlias()) == null) { + logger.info("Linking user to identity provider: " + identityProvider.getAlias()); + FederatedIdentityModel identityModel = new FederatedIdentityModel(identityProvider.getAlias(), scimExternalId, scimUserName); + getSession().users().addFederatedIdentity(getRealm(), user, identityModel); + return true; + } + + return false; + } + + @Override + public Object toRepresentation() { + return Converters.convertOrganizationModelToOrganization(organization); + } +} diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/phasetwo/PhasetwoOrganizationScimServerProvider.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/phasetwo/PhasetwoOrganizationScimServerProvider.java new file mode 100644 index 0000000..5d21887 --- /dev/null +++ b/src/main/java/fi/metatavu/keycloak/scim/server/organization/phasetwo/PhasetwoOrganizationScimServerProvider.java @@ -0,0 +1,70 @@ +package fi.metatavu.keycloak.scim.server.organization.phasetwo; + +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import fi.metatavu.keycloak.scim.server.ScimServer; +import fi.metatavu.keycloak.scim.server.organization.*; +import io.phasetwo.service.model.OrganizationModel; +import io.phasetwo.service.model.OrganizationProvider; +import jakarta.ws.rs.InternalServerErrorException; +import jakarta.ws.rs.NotFoundException; +import java.net.URI; +import fi.metatavu.keycloak.scim.server.config.ConfigurationError; + +public class PhasetwoOrganizationScimServerProvider implements OrganizationScimServerProvider { + + protected final KeycloakSession session; + + public PhasetwoOrganizationScimServerProvider(KeycloakSession session) { + this.session = session; + } + + @Override + public OrganizationScimServer getScimServer(KeycloakSession session) { + return new OrganizationScimServer(session) { + @Override + public OrganizationScimContext getScimContext(KeycloakSession session, String organizationId) { + return createScimContext(session, organizationId); + } + }; + } + + public static final String PHASETWO_ORGANIZATION_SESSION_ATTRIBUTE = "ext-phasetwo-organization"; + + private static OrganizationScimContext createScimContext(KeycloakSession session, String organizationId) { + RealmModel realm = session.getContext().getRealm(); + if (realm == null) { + throw new NotFoundException("Realm not found"); + } + + final OrganizationModel organization = session.getProvider(OrganizationProvider.class).getOrganizationById(realm, organizationId); + + if (organization == null) { + throw new NotFoundException("Organization not found"); + } + + session.setAttribute(PHASETWO_ORGANIZATION_SESSION_ATTRIBUTE, organization); + + URI baseUri = session.getContext().getUri().getBaseUri().resolve(String.format("realms/%s/scim/v2/organizations/%s/", realm.getName(), organization.getId())); + OrganizationScimConfig config = new OrganizationScimConfig() { + @Override + public String getAttribute(String attributeName) { + return organization.getFirstAttribute(attributeName); + } + }; + + try { + config.validateConfig(); + } catch (ConfigurationError e) { + throw new InternalServerErrorException("Invalid SCIM configuration", e); + } + + return new PhasetwoOrganizationScimContext( + baseUri, + session, + realm, + config, + organization); + } + +} diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/phasetwo/PhasetwoOrganizationScimServerProviderFactory.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/phasetwo/PhasetwoOrganizationScimServerProviderFactory.java new file mode 100644 index 0000000..1c60ae1 --- /dev/null +++ b/src/main/java/fi/metatavu/keycloak/scim/server/organization/phasetwo/PhasetwoOrganizationScimServerProviderFactory.java @@ -0,0 +1,31 @@ +package fi.metatavu.keycloak.scim.server.organization.phasetwo; + +import fi.metatavu.keycloak.scim.server.organization.OrganizationScimServerProvider; +import fi.metatavu.keycloak.scim.server.organization.OrganizationScimServerProviderFactory; +import org.keycloak.Config.Scope; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; + +public class PhasetwoOrganizationScimServerProviderFactory implements OrganizationScimServerProviderFactory { + + public static final String PROVIDER_ID = "phasetwo"; + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public OrganizationScimServerProvider create(KeycloakSession session) { + return new PhasetwoOrganizationScimServerProvider(session); + } + + @Override + public void init(Scope config) {} + + @Override + public void postInit(KeycloakSessionFactory factory) {} + + @Override + public void close() {} +} diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/users/UsersController.java b/src/main/java/fi/metatavu/keycloak/scim/server/users/UsersController.java index f9c0804..483ea90 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/users/UsersController.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/users/UsersController.java @@ -590,7 +590,7 @@ public void deleteUser( * @param email email address * @return email domain */ - protected String getEmailDomain(String email) { + public static String getEmailDomain(String email) { if (email != null && email.contains("@")) { return email.substring(email.indexOf('@') + 1); } diff --git a/src/main/resources/META-INF/services/fi.metatavu.keycloak.scim.server.organization.OrganizationScimServerProviderFactory b/src/main/resources/META-INF/services/fi.metatavu.keycloak.scim.server.organization.OrganizationScimServerProviderFactory new file mode 100644 index 0000000..135aaa4 --- /dev/null +++ b/src/main/resources/META-INF/services/fi.metatavu.keycloak.scim.server.organization.OrganizationScimServerProviderFactory @@ -0,0 +1,2 @@ +fi.metatavu.keycloak.scim.server.organization.KeycloakOrganizationScimServerProviderFactory +fi.metatavu.keycloak.scim.server.organization.phasetwo.PhasetwoOrganizationScimServerProviderFactory diff --git a/src/main/resources/META-INF/services/org.keycloak.provider.Spi b/src/main/resources/META-INF/services/org.keycloak.provider.Spi new file mode 100644 index 0000000..d77eb55 --- /dev/null +++ b/src/main/resources/META-INF/services/org.keycloak.provider.Spi @@ -0,0 +1 @@ +fi.metatavu.keycloak.scim.server.organization.OrganizationScimServerSpi From db961f4a31bf64f708d711ba1d934271f75a2b0e Mon Sep 17 00:00:00 2001 From: Garth <244253+xgp@users.noreply.github.com> Date: Sat, 6 Sep 2025 13:07:24 +0200 Subject: [PATCH 02/35] updated services file with correct class name --- ...im.server.organization.OrganizationScimServerProviderFactory | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/META-INF/services/fi.metatavu.keycloak.scim.server.organization.OrganizationScimServerProviderFactory b/src/main/resources/META-INF/services/fi.metatavu.keycloak.scim.server.organization.OrganizationScimServerProviderFactory index 135aaa4..a7bb49c 100644 --- a/src/main/resources/META-INF/services/fi.metatavu.keycloak.scim.server.organization.OrganizationScimServerProviderFactory +++ b/src/main/resources/META-INF/services/fi.metatavu.keycloak.scim.server.organization.OrganizationScimServerProviderFactory @@ -1,2 +1,2 @@ -fi.metatavu.keycloak.scim.server.organization.KeycloakOrganizationScimServerProviderFactory +fi.metatavu.keycloak.scim.server.organization.keycloak.KeycloakOrganizationScimServerProviderFactory fi.metatavu.keycloak.scim.server.organization.phasetwo.PhasetwoOrganizationScimServerProviderFactory From 1fd4eda819539063c9d95273a19e75c084fd8f33 Mon Sep 17 00:00:00 2001 From: Nicola Date: Wed, 17 Dec 2025 12:06:46 +0100 Subject: [PATCH 03/35] fix: Reduce logging during external token validation to minimize noise in logs --- .../scim/server/authentication/ExternalTokenVerifier.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/authentication/ExternalTokenVerifier.java b/src/main/java/fi/metatavu/keycloak/scim/server/authentication/ExternalTokenVerifier.java index f22fbe5..d5cd17b 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/authentication/ExternalTokenVerifier.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/authentication/ExternalTokenVerifier.java @@ -45,13 +45,11 @@ public ExternalTokenVerifier(String expectedIssuer, String jwksUrl, String expec public boolean verify(String tokenString) throws URISyntaxException, IOException, InterruptedException, JWSInputException { for (JwkKey jwkKey : JwksUtils.getPublicKeysFromJwks(jwksUrl)) { if (verify(tokenString, jwkKey.getPublicKey())) { - logger.info("Token verification succeeded with key: " + jwkKey.getKid()); return true; } - logger.warn("Token verification failed with key: " + jwkKey.getKid()); } - + logger.warn("Token verification failed "); return false; } @@ -67,7 +65,6 @@ private boolean verify(String tokenString, PublicKey publicKey) throws JWSInputE boolean validSignature = RSAProvider.verify(jwsInput, publicKey); if (!validSignature) { - logger.warn("Token signature verification failed"); return false; } From fe65d8e0a383140c83089ab4177075fcfe59464b Mon Sep 17 00:00:00 2001 From: Garth <244253+xgp@users.noreply.github.com> Date: Wed, 4 Feb 2026 15:27:08 +0100 Subject: [PATCH 04/35] removing phasetwo specific implementation --- .../PhasetwoOrganizationScimContext.java | 119 ------------------ ...hasetwoOrganizationScimServerProvider.java | 70 ----------- ...OrganizationScimServerProviderFactory.java | 31 ----- 3 files changed, 220 deletions(-) delete mode 100644 src/main/java/fi/metatavu/keycloak/scim/server/organization/phasetwo/PhasetwoOrganizationScimContext.java delete mode 100644 src/main/java/fi/metatavu/keycloak/scim/server/organization/phasetwo/PhasetwoOrganizationScimServerProvider.java delete mode 100644 src/main/java/fi/metatavu/keycloak/scim/server/organization/phasetwo/PhasetwoOrganizationScimServerProviderFactory.java diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/phasetwo/PhasetwoOrganizationScimContext.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/phasetwo/PhasetwoOrganizationScimContext.java deleted file mode 100644 index 028f81a..0000000 --- a/src/main/java/fi/metatavu/keycloak/scim/server/organization/phasetwo/PhasetwoOrganizationScimContext.java +++ /dev/null @@ -1,119 +0,0 @@ -package fi.metatavu.keycloak.scim.server.organization.phasetwo; - -import static fi.metatavu.keycloak.scim.server.users.UsersController.getEmailDomain; - -import fi.metatavu.keycloak.scim.server.ScimContext; -import fi.metatavu.keycloak.scim.server.organization.*; -import org.keycloak.models.FederatedIdentityModel; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.IdentityProviderModel; -import org.keycloak.models.RealmModel; -import org.keycloak.models.UserModel; -import java.net.URI; -import java.util.Collections; -import java.util.stream.Stream; -import org.jboss.logging.Logger; -import io.phasetwo.service.model.OrganizationModel; -import io.phasetwo.service.model.OrganizationProvider; -import io.phasetwo.service.resource.Converters; - -/** - * SCIM context for Phase Two organizations. https://github.com/p2-inc/keycloak-orgs - */ -public class PhasetwoOrganizationScimContext extends OrganizationScimContext { - - private static final Logger logger = Logger.getLogger(PhasetwoOrganizationScimContext.class); - - protected final OrganizationModel organization; - - public PhasetwoOrganizationScimContext(URI baseUri, KeycloakSession session, RealmModel realm, OrganizationScimConfig config, OrganizationModel organization) { - super(baseUri, session, realm, organization.getId(), config); - this.organization = organization; - } - - @Override - public Stream getMembersStream(Integer first, Integer max) { - return organization.searchForMembersStream(null, null, null); - } - - @Override - public UserModel findUser(String userId) { - UserModel organizationUser = getSession().users().getUserById(getRealm(), userId); - if (organizationUser == null) return null; - if (!organization.hasMembership(organizationUser)) return null; - return organizationUser; - } - - @Override - public boolean addMember(UserModel user) { - try { - organization.grantMembership(user); - } catch (Exception ignore) { - return false; - } - return true; - } - - @Override - public boolean isMember(UserModel user) { - return organization.hasMembership(user); - } - - @Override - public boolean removeMember(UserModel user) { - try { - organization.revokeMembership(user); - } catch (Exception ignore) { - return false; - } - return true; - } - - @Override - public boolean linkUserIdp(UserModel user, String scimUserEmail, String scimUserName, String scimExternalId) { - if (scimUserEmail == null) { - logger.warn("User email is not set. Cannot link user to identity provider"); - return false; - } - - if (scimExternalId == null) { - logger.warn("User externalId is not set. Cannot link user to identity provider"); - return false; - } - - String emailDomain = getEmailDomain(scimUserEmail); - if (emailDomain == null) { - logger.warn("User email domain is not set. Cannot link user to identity provider"); - return false; - } - - IdentityProviderModel identityProvider = null; - if (organization.getDomains() != null && organization.getDomains().contains(emailDomain)) { - identityProvider = organization.getIdentityProvidersStream() - .filter(identityProviderModel -> { - return identityProviderModel.isEnabled(); - }) - .findFirst() - .orElse(null); - } - - if (identityProvider == null) { - logger.warn("No identity provider found for email domain: " + emailDomain + ". Cannot link user to identity provider"); - return false; - } - - if (getSession().users().getFederatedIdentity(getRealm(), user, identityProvider.getAlias()) == null) { - logger.info("Linking user to identity provider: " + identityProvider.getAlias()); - FederatedIdentityModel identityModel = new FederatedIdentityModel(identityProvider.getAlias(), scimExternalId, scimUserName); - getSession().users().addFederatedIdentity(getRealm(), user, identityModel); - return true; - } - - return false; - } - - @Override - public Object toRepresentation() { - return Converters.convertOrganizationModelToOrganization(organization); - } -} diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/phasetwo/PhasetwoOrganizationScimServerProvider.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/phasetwo/PhasetwoOrganizationScimServerProvider.java deleted file mode 100644 index 5d21887..0000000 --- a/src/main/java/fi/metatavu/keycloak/scim/server/organization/phasetwo/PhasetwoOrganizationScimServerProvider.java +++ /dev/null @@ -1,70 +0,0 @@ -package fi.metatavu.keycloak.scim.server.organization.phasetwo; - -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.RealmModel; -import fi.metatavu.keycloak.scim.server.ScimServer; -import fi.metatavu.keycloak.scim.server.organization.*; -import io.phasetwo.service.model.OrganizationModel; -import io.phasetwo.service.model.OrganizationProvider; -import jakarta.ws.rs.InternalServerErrorException; -import jakarta.ws.rs.NotFoundException; -import java.net.URI; -import fi.metatavu.keycloak.scim.server.config.ConfigurationError; - -public class PhasetwoOrganizationScimServerProvider implements OrganizationScimServerProvider { - - protected final KeycloakSession session; - - public PhasetwoOrganizationScimServerProvider(KeycloakSession session) { - this.session = session; - } - - @Override - public OrganizationScimServer getScimServer(KeycloakSession session) { - return new OrganizationScimServer(session) { - @Override - public OrganizationScimContext getScimContext(KeycloakSession session, String organizationId) { - return createScimContext(session, organizationId); - } - }; - } - - public static final String PHASETWO_ORGANIZATION_SESSION_ATTRIBUTE = "ext-phasetwo-organization"; - - private static OrganizationScimContext createScimContext(KeycloakSession session, String organizationId) { - RealmModel realm = session.getContext().getRealm(); - if (realm == null) { - throw new NotFoundException("Realm not found"); - } - - final OrganizationModel organization = session.getProvider(OrganizationProvider.class).getOrganizationById(realm, organizationId); - - if (organization == null) { - throw new NotFoundException("Organization not found"); - } - - session.setAttribute(PHASETWO_ORGANIZATION_SESSION_ATTRIBUTE, organization); - - URI baseUri = session.getContext().getUri().getBaseUri().resolve(String.format("realms/%s/scim/v2/organizations/%s/", realm.getName(), organization.getId())); - OrganizationScimConfig config = new OrganizationScimConfig() { - @Override - public String getAttribute(String attributeName) { - return organization.getFirstAttribute(attributeName); - } - }; - - try { - config.validateConfig(); - } catch (ConfigurationError e) { - throw new InternalServerErrorException("Invalid SCIM configuration", e); - } - - return new PhasetwoOrganizationScimContext( - baseUri, - session, - realm, - config, - organization); - } - -} diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/phasetwo/PhasetwoOrganizationScimServerProviderFactory.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/phasetwo/PhasetwoOrganizationScimServerProviderFactory.java deleted file mode 100644 index 1c60ae1..0000000 --- a/src/main/java/fi/metatavu/keycloak/scim/server/organization/phasetwo/PhasetwoOrganizationScimServerProviderFactory.java +++ /dev/null @@ -1,31 +0,0 @@ -package fi.metatavu.keycloak.scim.server.organization.phasetwo; - -import fi.metatavu.keycloak.scim.server.organization.OrganizationScimServerProvider; -import fi.metatavu.keycloak.scim.server.organization.OrganizationScimServerProviderFactory; -import org.keycloak.Config.Scope; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.KeycloakSessionFactory; - -public class PhasetwoOrganizationScimServerProviderFactory implements OrganizationScimServerProviderFactory { - - public static final String PROVIDER_ID = "phasetwo"; - - @Override - public String getId() { - return PROVIDER_ID; - } - - @Override - public OrganizationScimServerProvider create(KeycloakSession session) { - return new PhasetwoOrganizationScimServerProvider(session); - } - - @Override - public void init(Scope config) {} - - @Override - public void postInit(KeycloakSessionFactory factory) {} - - @Override - public void close() {} -} From ee2cf1d3d887089b81d26d2941fe1cd3c432505e Mon Sep 17 00:00:00 2001 From: Garth <244253+xgp@users.noreply.github.com> Date: Wed, 4 Feb 2026 15:43:57 +0100 Subject: [PATCH 05/35] removing keycloak-orgs and phasetwo references to prepare for external implementaiton --- build.gradle.kts | 1 - .../keycloak/scim/server/ScimRealmResourceProviderFactory.java | 2 +- ...im.server.organization.OrganizationScimServerProviderFactory | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 0ecef35..39a7cec 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -46,7 +46,6 @@ val jacocoVersion: String by project val jacocoRuntime: Configuration by configurations.creating dependencies { - implementation("io.phasetwo.keycloak:keycloak-orgs:0.114") implementation(enforcedPlatform("org.keycloak.bom:keycloak-bom-parent:$keycloakVersion")) compileOnly("org.keycloak:keycloak-services:$keycloakVersion") diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/ScimRealmResourceProviderFactory.java b/src/main/java/fi/metatavu/keycloak/scim/server/ScimRealmResourceProviderFactory.java index c64bf45..c7091dc 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/ScimRealmResourceProviderFactory.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/ScimRealmResourceProviderFactory.java @@ -23,7 +23,7 @@ public RealmResourceProvider create(KeycloakSession session) { @Override public void init(Config.Scope config) { - // allows overriding the default organization type with 'phasetwo' + // allows overriding the default organization type with a custom implementation (e.g. `phasetwo`) String orgTypeConfig = config.get("organizationType"); if (!Strings.isNullOrEmpty(orgTypeConfig)) organizationType = orgTypeConfig; } diff --git a/src/main/resources/META-INF/services/fi.metatavu.keycloak.scim.server.organization.OrganizationScimServerProviderFactory b/src/main/resources/META-INF/services/fi.metatavu.keycloak.scim.server.organization.OrganizationScimServerProviderFactory index a7bb49c..df1d858 100644 --- a/src/main/resources/META-INF/services/fi.metatavu.keycloak.scim.server.organization.OrganizationScimServerProviderFactory +++ b/src/main/resources/META-INF/services/fi.metatavu.keycloak.scim.server.organization.OrganizationScimServerProviderFactory @@ -1,2 +1 @@ fi.metatavu.keycloak.scim.server.organization.keycloak.KeycloakOrganizationScimServerProviderFactory -fi.metatavu.keycloak.scim.server.organization.phasetwo.PhasetwoOrganizationScimServerProviderFactory From b6d836c20a2a23439ef80ee3ae45aa684b1d48ac Mon Sep 17 00:00:00 2001 From: Garth <244253+xgp@users.noreply.github.com> Date: Thu, 5 Feb 2026 21:18:07 +0100 Subject: [PATCH 06/35] abstracted out the scim config from org attributes into a full config interface. --- gradle.properties | 2 +- .../ScimRealmResourceProviderFactory.java | 4 + .../organization/OrganizationScimConfig.java | 50 ++--------- .../KeycloakOrganizationScimConfig.java | 84 +++++++++++++++++++ ...eycloakOrganizationScimServerProvider.java | 17 +--- 5 files changed, 96 insertions(+), 61 deletions(-) create mode 100644 src/main/java/fi/metatavu/keycloak/scim/server/organization/keycloak/KeycloakOrganizationScimConfig.java diff --git a/gradle.properties b/gradle.properties index 593022b..aa9a971 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,7 +3,7 @@ junitVersion=5.11.3 keycloakVersion=26.1.4 org.gradle.configuration-cache=true testContainersKeycloakVersion=3.6.0 -testContainersVersion=1.20.4 +testContainersVersion=1.21.4 awaitilityVersion=4.3.0 seleniumRemoteDriverVersion=4.26.0 seleniumVersion=4.26.0 diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/ScimRealmResourceProviderFactory.java b/src/main/java/fi/metatavu/keycloak/scim/server/ScimRealmResourceProviderFactory.java index c7091dc..1ff5625 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/ScimRealmResourceProviderFactory.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/ScimRealmResourceProviderFactory.java @@ -6,6 +6,7 @@ import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.services.resource.RealmResourceProvider; import org.keycloak.services.resource.RealmResourceProviderFactory; +import org.jboss.logging.Logger; /** * SCIM realm resource provider factory @@ -14,6 +15,8 @@ */ public class ScimRealmResourceProviderFactory implements RealmResourceProviderFactory { + private static final Logger logger = Logger.getLogger(ScimRealmResourceProviderFactory.class); + private String organizationType = "default"; @Override @@ -26,6 +29,7 @@ public void init(Config.Scope config) { // allows overriding the default organization type with a custom implementation (e.g. `phasetwo`) String orgTypeConfig = config.get("organizationType"); if (!Strings.isNullOrEmpty(orgTypeConfig)) organizationType = orgTypeConfig; + logger.infof("Initializing SCIM resource with **%s** org type.", organizationType); } @Override diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimConfig.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimConfig.java index b98355a..d224c3d 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimConfig.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimConfig.java @@ -8,7 +8,7 @@ /** * SCIM configuration for organizations */ -public abstract class OrganizationScimConfig implements ScimConfig { +public interface OrganizationScimConfig extends ScimConfig { public static final String SCIM_EXTERNAL_SHARED_SECRET = "SCIM_EXTERNAL_SHARED_SECRET"; public static final String SCIM_EXTERNAL_JWKS_URI = "SCIM_EXTERNAL_JWKS_URI"; @@ -18,8 +18,7 @@ public abstract class OrganizationScimConfig implements ScimConfig { public static final String SCIM_AUTHENTICATION_MODE = "SCIM_AUTHENTICATION_MODE"; public static final String SCIM_EMAIL_AS_USERNAME = "SCIM_EMAIL_AS_USERNAME"; - @Override - public void validateConfig() throws ConfigurationError { + default void validateConfig() throws ConfigurationError { AuthenticationMode mode = getAuthenticationMode(); if (mode == null) { throw new ConfigurationError(SCIM_AUTHENTICATION_MODE + " is not set"); @@ -51,46 +50,9 @@ public void validateConfig() throws ConfigurationError { } } - @Override - public AuthenticationMode getAuthenticationMode() { - String value = getAttribute(SCIM_AUTHENTICATION_MODE); - if (value == null || value.isEmpty()) { - return null; - } - - return AuthenticationMode.valueOf(value); - } - - @Override - public String getExternalIssuer() { - return getAttribute(SCIM_EXTERNAL_ISSUER); - } - - @Override - public String getExternalJwksUri() { - return getAttribute(SCIM_EXTERNAL_JWKS_URI); - } - - @Override - public String getExternalAudience() { - return getAttribute(SCIM_EXTERNAL_AUDIENCE); - } - - @Override - public String getSharedSecret() { - return getAttribute(SCIM_EXTERNAL_SHARED_SECRET); - } - - @Override - public boolean getLinkIdp() { - return "true".equalsIgnoreCase(getAttribute(SCIM_LINK_IDP)); - } - - @Override - public boolean getEmailAsUsername() { - return "true".equalsIgnoreCase(getAttribute(SCIM_EMAIL_AS_USERNAME)); - } - - public abstract String getAttribute(String attributeName); + /** + * Is the organization enabled for SCIM + */ + public boolean isEnabled(); } diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/keycloak/KeycloakOrganizationScimConfig.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/keycloak/KeycloakOrganizationScimConfig.java new file mode 100644 index 0000000..b71f1f8 --- /dev/null +++ b/src/main/java/fi/metatavu/keycloak/scim/server/organization/keycloak/KeycloakOrganizationScimConfig.java @@ -0,0 +1,84 @@ +package fi.metatavu.keycloak.scim.server.organization.keycloak; + +import fi.metatavu.keycloak.scim.server.config.ConfigurationError; +import fi.metatavu.keycloak.scim.server.organization.OrganizationScimConfig; +import org.keycloak.models.OrganizationModel; +import java.util.List; +import java.util.Map; + +/** + * SCIM configuration for organizations + */ +public class KeycloakOrganizationScimConfig implements OrganizationScimConfig { + + private final OrganizationModel organization; + + public KeycloakOrganizationScimConfig(OrganizationModel organization) { + this.organization = organization; + } + + @Override + public boolean isEnabled() { + try { + validateConfig(); + return true; + } catch (ConfigurationError e) { + return false; + } + } + + @Override + public AuthenticationMode getAuthenticationMode() { + String value = getAttribute(SCIM_AUTHENTICATION_MODE); + if (value == null || value.isEmpty()) { + return null; + } + + return AuthenticationMode.valueOf(value); + } + + @Override + public String getExternalIssuer() { + return getAttribute(SCIM_EXTERNAL_ISSUER); + } + + @Override + public String getExternalJwksUri() { + return getAttribute(SCIM_EXTERNAL_JWKS_URI); + } + + @Override + public String getExternalAudience() { + return getAttribute(SCIM_EXTERNAL_AUDIENCE); + } + + @Override + public String getSharedSecret() { + return getAttribute(SCIM_EXTERNAL_SHARED_SECRET); + } + + @Override + public boolean getLinkIdp() { + return "true".equalsIgnoreCase(getAttribute(SCIM_LINK_IDP)); + } + + @Override + public boolean getEmailAsUsername() { + return "true".equalsIgnoreCase(getAttribute(SCIM_EMAIL_AS_USERNAME)); + } + + + private String getAttribute(String attributeName) { + Map> attributes = organization.getAttributes(); + if (attributes == null) { + return null; + } + List values = attributes.get(attributeName); + if (values == null || values.isEmpty()) { + return null; + } + + return values.getFirst(); + } + +} diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/keycloak/KeycloakOrganizationScimServerProvider.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/keycloak/KeycloakOrganizationScimServerProvider.java index 1718c0a..42b48ed 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/organization/keycloak/KeycloakOrganizationScimServerProvider.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/organization/keycloak/KeycloakOrganizationScimServerProvider.java @@ -49,22 +49,7 @@ private static OrganizationScimContext createScimContext(KeycloakSession session context.setOrganization(organization); URI baseUri = session.getContext().getUri().getBaseUri().resolve(String.format("realms/%s/scim/v2/organizations/%s/", realm.getName(), organization.getId())); - OrganizationScimConfig config = new OrganizationScimConfig() { - @Override - public String getAttribute(String attributeName) { - Map> attributes = organization.getAttributes(); - if (attributes == null) { - return null; - } - - List values = attributes.get(attributeName); - if (values == null || values.isEmpty()) { - return null; - } - - return values.getFirst(); - } - }; + OrganizationScimConfig config = new KeycloakOrganizationScimConfig(organization); try { config.validateConfig(); From f78138e19f6abf2d2cc82e2c7b8ebe02a50b8fda Mon Sep 17 00:00:00 2001 From: "Mercedes.Segura" Date: Tue, 24 Feb 2026 15:33:03 +0100 Subject: [PATCH 07/35] feat: add support for custom user attributes via SCIM - Introduced SCIM_IDENTITY_PROVIDER_ALIAS. - Updated kc-test.json to include unmanagedAttributePolicy and custom attributes. - Enhanced MetadataController to handle custom attributes based on identity provider mappers. - Updated tests to validate provisioning and patching of custom attributes. --- README.md | 138 +++++++++++++++++- .../scim/server/config/ScimConfig.java | 7 + .../server/metadata/MetadataController.java | 30 ++++ .../organization/OrganizationScimConfig.java | 6 + .../scim/server/realm/RealmScimConfig.java | 13 ++ .../functional/RealmUserCreateTestsIT.java | 10 +- .../functional/RealmUserPatchTestsIT.java | 25 +++- .../functional/RealmUserUpdateTestsIT.java | 13 +- .../server/test/utils/KeycloakTestUtils.java | 1 + src/test/resources/kc-test.json | 2 +- 10 files changed, 232 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 26069d2..dbf8650 100644 --- a/README.md +++ b/README.md @@ -52,8 +52,8 @@ The following environment variables are available: | SCIM_EXTERNAL_AUDIENCE | JWKS URI for the external authentication. This is used to validate the JWT token. | | SCIM_EXTERNAL_JWKS_URI | Audience for the external authentication. This is used to validate the JWT token. | | SCIM_EXTERNAL_SHARED_SECRET | Shared secret value used for request authentication/validation. | -| SCIM_EXTERNAL_SHARED_SECRET_HASH_ALGORITHM | PHC String Format representing hash algorithms and its parameters, used for request authentication/validation ([must be on of the following](https://www.keycloak.org/docs/26.1.5/server_admin/index.html#hashalgorithm)). | - +| SCIM_EXTERNAL_SHARED_SECRET_HASH_ALGORITHM | PHC String Format representing hash algorithms and its parameters, used for request authentication/validation ([must be on of the following](https://www.keycloak.org/docs/26.1.5/server_admin/index.html#hashalgorithm)). | +| SCIM_IDENTITY_PROVIDER_ALIAS | Alias of Identity Provider to be linked to the user. | ### Configuration on Realm level The following REST call can be called through the Keycloak Admin API to store the settings under realm attributes. @@ -67,7 +67,8 @@ PUT `/admin/realms/{realm}` "scim.external.jwks.uri": "string", "scim.external.audience": "string", "scim.external.shared.secret": "string", - "scim.external.shared.secret.hash.algorithm": "string" + "scim.external.shared.secret.hash.algorithm": "string", + "scim.identity.provider.alias": "string" } } ``` @@ -246,6 +247,137 @@ This filtering mechanism is designed to improve safety, especially in complex de This design does mean that provisioning a user through SCIM who previously existed without the role may cause conflicts or provisioning failures if role assignment isn’t handled correctly. However, this is a deliberate design choice to provide fine-grained control over which users are SCIM-visible. + +## User attributes for SCIM provisioning + +This section explains how to provision custom user attributes (e.g., `job`, `department`, `employeeId`) from an external +identity provider (such as Azure Entra ID) into Keycloak via SCIM. + +By default, the SCIM server only exposes built-in user attributes (`userName`, `email`, `name.givenName`, +`name.familyName`, `active`). To provision additional custom attributes, you need to configure Keycloak to accept +unmanaged attributes and define identity provider mappers that tell the SCIM server which attributes to expose. + +### Prerequisites + +Before custom attributes can be provisioned, ensure the following conditions are met: + +1. **Unmanaged Attribute Policy**: The realm's User Profile must have `UnmanagedAttributePolicy` set to `ENABLED`. This + allows Keycloak to store attributes that are not explicitly defined in the User Profile schema. + +2. **Identity Provider Alias**: The `SCIM_IDENTITY_PROVIDER_ALIAS` environment variable (or realm/organization + attribute) must be configured with the alias of your identity provider. + +3. **Identity Provider Mappers**: User attribute mappers must be configured on the identity provider to define which + attributes should be provisioned. + +### How It Works + +When a SCIM provisioning request is received, the SCIM server: + +1. Checks if `UnmanagedAttributePolicy` is set to `ENABLED` in the realm +2. Looks up the identity provider specified by `SCIM_IDENTITY_PROVIDER_ALIAS` +3. Reads all user attribute mappers configured on that identity provider +4. Exposes those mapped attributes as valid SCIM attributes that can be provisioned + +This means the identity provider mappers serve as the **source of truth** for which custom attributes are available via +SCIM. + +### Step-by-Step Configuration + +#### Step 1: Enable Unmanaged Attributes in Keycloak + +1. Navigate to **Realm Settings** > **User Profile** +2. Click **JSON Editor** +3. Add or update the `unmanagedAttributePolicy` field: + +```json +{ + "unmanagedAttributePolicy": "ENABLED", + "attributes": [ + ] +} +``` + +4. Click **Save** + +#### Step 2: Configure the Identity Provider Alias + +Add the following environment variable to your Keycloak server: + +```bash +SCIM_IDENTITY_PROVIDER_ALIAS= +``` + +Or set it as a realm attribute via the Admin API: + +```json +{ + "attributes": { + "scim.identity.provider.alias": "" + } +} +``` + +The alias must match the alias of your configured identity provider (e.g., `entra-id`, `keycloak-oidc`). + +#### Step 3: Create User Attribute Mappers on the Identity Provider + +For each custom attribute you want to provision via SCIM: + +1. Navigate to **Identity Providers** > select your provider (e.g., Entra ID) +2. Go to the **Mappers** tab +3. Click **Add Mapper** +4. Configure the mapper: + +| Field | Value | +|--------------------|---------------------------------------------------------| +| **Name** | A descriptive name (e.g., `map-job-attribute`) | +| **Sync Mode** | `INHERIT` or `FORCE` | +| **Mapper Type** | `Attribute Importer` | +| **Claim** (OIDC) | The claim name from the external IdP (e.g., `jobTitle`) | +| **User Attribute** | The Keycloak attribute name (e.g., `job`) | + +5. Click **Save** +6. Repeat for each attribute you want to provision (e.g., `department`, `employeeId`) + +#### Step 4: Map Attributes in Your SCIM Client (e.g., Entra ID) + +In your SCIM client (e.g., Azure Entra ID Enterprise Application): + +1. Navigate to **Provisioning** > **Attribute Mapping (Preview)** > **Provision Microsoft Entra ID Users** +2. Click **Add New Mapping** +3. Map the source attribute to the custom SCIM attribute: + +| Source Attribute | Target Attribute | +|------------------|------------------| +| `jobTitle` | `job` | +| `department` | `department` | + +4. Click **Save** + +The target attribute name must match the `User Attribute` value configured in the Keycloak identity provider mapper. + +### Example: Provisioning a "job" Attribute + +This example shows how to provision the `jobTitle` attribute from Azure Entra ID to Keycloak as a `job` attribute. + +**Keycloak Configuration:** + +1. Enable `UnmanagedAttributePolicy` in the realm's User Profile +2. Set `SCIM_IDENTITY_PROVIDER_ALIAS=entra-id` +3. Create an identity provider mapper: + - **Mapper Type**: Attribute Importer + - **Claim**: `jobTitle` + - **User Attribute**: `job` + +**Azure Entra ID Configuration:** + +1. In the Enterprise Application provisioning settings, add a mapping: + - **Source attribute**: `jobTitle` + - **Target attribute**: `job` + +When Entra ID provisions a user, the `job` attribute will be stored in Keycloak and available on the user's attributes. + ## License [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0) diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/config/ScimConfig.java b/src/main/java/fi/metatavu/keycloak/scim/server/config/ScimConfig.java index ccbb9d2..4be98ce 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/config/ScimConfig.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/config/ScimConfig.java @@ -68,4 +68,11 @@ enum AuthenticationMode { * @return true if email should be used as username */ boolean getEmailAsUsername(); + + /** + * Returns the identity provider alias + * + * @return identity provider alias or null if not configured + */ + String getIdentityProviderAlias(); } diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/metadata/MetadataController.java b/src/main/java/fi/metatavu/keycloak/scim/server/metadata/MetadataController.java index 6ee21bf..17a4691 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/metadata/MetadataController.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/metadata/MetadataController.java @@ -16,11 +16,15 @@ import fi.metatavu.keycloak.scim.server.model.AuthenticationScheme; import fi.metatavu.keycloak.scim.server.model.ResourceTypeListResponse; import fi.metatavu.keycloak.scim.server.model.SchemaAttribute; +import org.keycloak.models.IdentityProviderStorageProvider; import org.keycloak.models.KeycloakSession; import org.keycloak.models.UserModel; import org.keycloak.representations.userprofile.config.UPAttribute; import org.keycloak.representations.userprofile.config.UPConfig; import org.keycloak.userprofile.UserProfileProvider; +import org.keycloak.utils.StringUtil; + +import static org.keycloak.broker.oidc.mappers.UserAttributeMapper.USER_ATTRIBUTE; /** * Controller for metadata @@ -306,6 +310,32 @@ private List> getUserAttributeMappingList(ScimContext scimConte )); } } + + if (UPConfig.UnmanagedAttributePolicy.ENABLED.equals(userProfileProvider.getConfiguration().getUnmanagedAttributePolicy())) { + String identityProviderAlias = scimContext.getConfig().getIdentityProviderAlias(); + if (!StringUtil.isNullOrEmpty(identityProviderAlias)) { + IdentityProviderStorageProvider identityProviderStorageProvider = session.getProvider(IdentityProviderStorageProvider.class); + identityProviderStorageProvider.getMappersByAliasStream(identityProviderAlias).forEach(mapper -> { + String attribute = mapper.getConfig().get(USER_ATTRIBUTE); + if (StringUtil.isNullOrEmpty(attribute)) { + return; + } + if (!builtInAttributeNames.contains(attribute)) { + customAttributes.add(new StringUserAttribute( + UserAttribute.Source.USER_MODEL, + attribute, + attribute, + attribute, + SchemaAttribute.TypeEnum.STRING, + SchemaAttribute.MutabilityEnum.READWRITE, + SchemaAttribute.UniquenessEnum.NONE, + user -> user.getFirstAttribute(attribute), + (user, value) -> user.setAttribute(attribute, List.of(value)) + )); + } + }); + } + } } List> result = new ArrayList<>(builtIn); diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimConfig.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimConfig.java index cbc622c..0ba38ec 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimConfig.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimConfig.java @@ -98,6 +98,12 @@ public boolean getEmailAsUsername() { return "true".equalsIgnoreCase(getAttribute(SCIM_EMAIL_AS_USERNAME)); } + // Organization SCIM configuration does not support identity provider alias, so we return empty string + @Override + public String getIdentityProviderAlias() { + return ""; + } + /** * Gets the organization attribute * diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimConfig.java b/src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimConfig.java index 345a490..c64d120 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimConfig.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimConfig.java @@ -18,6 +18,8 @@ public class RealmScimConfig implements ScimConfig { public static final String SCIM_EXTERNAL_SHARED_SECRET = "scim.external.shared.secret"; public static final String SCIM_AUTHENTICATION_MODE = "scim.authentication.mode"; public static final String SCIM_EXTERNAL_ISSUER = "scim.external.issuer"; + public static final String SCIM_IDENTITY_PROVIDER_ALIAS = "scim.identity.provider.alias"; + private final Config config; private final RealmModel realm; @@ -122,6 +124,17 @@ public boolean getEmailAsUsername() { return false; } + /** + * Returns the configured identity provider alias . + */ + @Override + public String getIdentityProviderAlias() { + return readRealmAttribute(SCIM_IDENTITY_PROVIDER_ALIAS) + .or(() -> config.getOptionalValue(SCIM_IDENTITY_PROVIDER_ALIAS, String.class)) + .orElse(null); + } + + /** * Helper method to read the first string from a realm attribute. */ diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserCreateTestsIT.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserCreateTestsIT.java index 5d9c2e5..4cc59f1 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserCreateTestsIT.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserCreateTestsIT.java @@ -1,10 +1,10 @@ package fi.metatavu.keycloak.scim.server.test.tests.functional; -import fi.metatavu.keycloak.scim.server.test.tests.AbstractInternalAuthRealmScimTest; import fi.metatavu.keycloak.scim.server.test.ScimClient; import fi.metatavu.keycloak.scim.server.test.TestConsts; import fi.metatavu.keycloak.scim.server.test.client.ApiException; import fi.metatavu.keycloak.scim.server.test.client.model.User; +import fi.metatavu.keycloak.scim.server.test.tests.AbstractInternalAuthRealmScimTest; import org.junit.jupiter.api.Test; import org.keycloak.events.admin.AdminEvent; import org.keycloak.events.admin.OperationType; @@ -16,7 +16,11 @@ import java.io.IOException; import java.util.List; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.fail; /** * Tests for SCIM 2.0 User create endpoint @@ -37,6 +41,7 @@ void testCreateUser() throws ApiException { user.putAdditionalProperty("externalId", "my-external-id"); user.putAdditionalProperty("preferredLanguage", "fi-FI"); user.putAdditionalProperty("displayName", "The New User"); + user.putAdditionalProperty("job", "farmer"); User created = scimClient.createUser(user); @@ -62,6 +67,7 @@ void testCreateUser() throws ApiException { assertEquals("my-external-id", realmUser.getAttributes().get("externalId").getFirst()); assertEquals("fi-FI", realmUser.getAttributes().get("preferredLanguage").getFirst()); assertEquals("The New User", realmUser.getAttributes().get("displayName").getFirst()); + assertEquals("farmer", realmUser.getAttributes().get("job").getFirst()); // Assert that user has correct roles diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserPatchTestsIT.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserPatchTestsIT.java index a4d4b8f..9397129 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserPatchTestsIT.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserPatchTestsIT.java @@ -1,12 +1,12 @@ package fi.metatavu.keycloak.scim.server.test.tests.functional; -import fi.metatavu.keycloak.scim.server.test.tests.AbstractInternalAuthRealmScimTest; import fi.metatavu.keycloak.scim.server.test.ScimClient; import fi.metatavu.keycloak.scim.server.test.TestConsts; import fi.metatavu.keycloak.scim.server.test.client.ApiException; import fi.metatavu.keycloak.scim.server.test.client.model.PatchRequest; import fi.metatavu.keycloak.scim.server.test.client.model.PatchRequestOperationsInner; import fi.metatavu.keycloak.scim.server.test.client.model.User; +import fi.metatavu.keycloak.scim.server.test.tests.AbstractInternalAuthRealmScimTest; import org.junit.jupiter.api.Test; import org.keycloak.events.admin.AdminEvent; import org.keycloak.events.admin.OperationType; @@ -16,7 +16,11 @@ import java.io.IOException; import java.util.List; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * Tests for SCIM 2.0 User create endpoint @@ -97,8 +101,9 @@ void testPatchAttributes() throws ApiException { assertNull(created.getAdditionalProperty("externalId")); assertNull(created.getAdditionalProperty("displayName")); assertNull(created.getAdditionalProperty("preferredLanguage")); + assertNull(created.getAdditionalProperty("job")); - // Patch externalId, displayName, preferredLanguage + // Patch externalId, displayName, preferredLanguage from user profile and job from user attribute mapper User patched = scimClient.patchUser(created.getId(), new PatchRequest() .schemas(List.of("urn:ietf:params:scim:api:messages:2.0:PatchOp")) .operations(List.of( @@ -137,7 +142,11 @@ void testPatchAttributes() throws ApiException { new PatchRequestOperationsInner() .op("replace") .path("preferredLanguage") - .value("en_US") + .value("en_US"), + new PatchRequestOperationsInner() + .op("replace") + .path("job") + .value("pilot") )) ); @@ -145,6 +154,14 @@ void testPatchAttributes() throws ApiException { assertEquals("Updated Display", patchedAgain.getAdditionalProperty("displayName")); assertEquals("en_US", patchedAgain.getAdditionalProperty("preferredLanguage")); + // Also verify state in Keycloak + UserRepresentation realmUser = findRealmUser(TestConsts.TEST_REALM, created.getId()); + assertNotNull(realmUser); + assertEquals("external-5678", realmUser.getAttributes().get("externalId").getFirst()); + assertEquals("Updated Display", realmUser.getAttributes().get("displayName").getFirst()); + assertEquals("en_US", realmUser.getAttributes().get("preferredLanguage").getFirst()); + assertEquals("pilot", realmUser.getAttributes().get("job").getFirst()); + // Cleanup deleteRealmUser(TestConsts.TEST_REALM, created.getId()); } diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserUpdateTestsIT.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserUpdateTestsIT.java index 881f4f2..404e5c9 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserUpdateTestsIT.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserUpdateTestsIT.java @@ -1,10 +1,10 @@ package fi.metatavu.keycloak.scim.server.test.tests.functional; -import fi.metatavu.keycloak.scim.server.test.tests.AbstractInternalAuthRealmScimTest; import fi.metatavu.keycloak.scim.server.test.ScimClient; import fi.metatavu.keycloak.scim.server.test.TestConsts; import fi.metatavu.keycloak.scim.server.test.client.ApiException; import fi.metatavu.keycloak.scim.server.test.client.model.User; +import fi.metatavu.keycloak.scim.server.test.tests.AbstractInternalAuthRealmScimTest; import org.junit.jupiter.api.Test; import org.keycloak.events.admin.AdminEvent; import org.keycloak.events.admin.OperationType; @@ -14,7 +14,11 @@ import java.io.IOException; import java.util.List; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.fail; /** * Tests for SCIM 2.0 User update (PUT) endpoint @@ -36,7 +40,8 @@ void testReplaceUser() throws ApiException { user.putAdditionalProperty("externalId", "replace-external-id"); user.putAdditionalProperty("preferredLanguage", "en_US"); user.putAdditionalProperty("displayName", "Replace User"); - + user.putAdditionalProperty("job", "farmer"); + User created = scimClient.createUser(user); assertNotNull(created); String userId = created.getId(); @@ -51,6 +56,7 @@ void testReplaceUser() throws ApiException { replacement.putAdditionalProperty("displayName", "Replaced User"); replacement.putAdditionalProperty("externalId", "replaced-external-id"); replacement.putAdditionalProperty("preferredLanguage", "fi_FI"); + replacement.putAdditionalProperty("job", "chef"); User updated = scimClient.updateUser(userId, replacement); @@ -78,6 +84,7 @@ void testReplaceUser() throws ApiException { assertEquals("Replaced User", realmUser.getAttributes().get("displayName").getFirst()); assertEquals("replaced-external-id", realmUser.getAttributes().get("externalId").getFirst()); assertEquals("fi_FI", realmUser.getAttributes().get("preferredLanguage").getFirst()); + assertEquals("chef", realmUser.getAttributes().get("job").getFirst()); assertFalse(realmUser.isEnabled()); // Clean up diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/utils/KeycloakTestUtils.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/utils/KeycloakTestUtils.java index 8ba0190..51be5f8 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/utils/KeycloakTestUtils.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/utils/KeycloakTestUtils.java @@ -84,6 +84,7 @@ public static KeycloakContainer createInternalAuthRealmKeycloakContainer(Network .withNetwork(network) .withNetworkAliases("scim-keycloak") .withEnv("SCIM_AUTHENTICATION_MODE", "KEYCLOAK") + .withEnv("SCIM_IDENTITY_PROVIDER_ALIAS", "keycloak-oidc") .withProviderLibsFrom(KeycloakTestUtils.getBuildProviders()) .withRealmImportFile("kc-test.json") .withEnv("JAVA_OPTS_APPEND", "-javaagent:/jacoco-agent/org.jacoco.agent-runtime.jar=destfile=/tmp/jacoco.exec") diff --git a/src/test/resources/kc-test.json b/src/test/resources/kc-test.json index 437c231..c4b6e33 100644 --- a/src/test/resources/kc-test.json +++ b/src/test/resources/kc-test.json @@ -1461,7 +1461,7 @@ "providerId" : "declarative-user-profile", "subComponents" : { }, "config" : { - "kc.user.profile.config" : [ "{\"attributes\":[{\"name\":\"username\",\"displayName\":\"${username}\",\"validations\":{\"length\":{\"min\":3,\"max\":255},\"username-prohibited-characters\":{},\"up-username-not-idn-homograph\":{}},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"email\",\"displayName\":\"${email}\",\"validations\":{\"email\":{},\"length\":{\"max\":255}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"firstName\",\"displayName\":\"${firstName}\",\"validations\":{\"length\":{\"max\":255},\"person-name-prohibited-characters\":{}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"lastName\",\"displayName\":\"${lastName}\",\"validations\":{\"length\":{\"max\":255},\"person-name-prohibited-characters\":{}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"displayName\",\"displayName\":\"\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"externalId\",\"displayName\":\"\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"preferredLanguage\",\"displayName\":\"${profile.attributes.preferredLanguage}\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[],\"edit\":[\"admin\"]},\"multivalued\":false}],\"groups\":[{\"name\":\"user-metadata\",\"displayHeader\":\"User metadata\",\"displayDescription\":\"Attributes, which refer to user metadata\"}]}" ] + "kc.user.profile.config" : [ "{\"attributes\":[{\"name\":\"username\",\"displayName\":\"${username}\",\"validations\":{\"length\":{\"min\":3,\"max\":255},\"username-prohibited-characters\":{},\"up-username-not-idn-homograph\":{}},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"email\",\"displayName\":\"${email}\",\"validations\":{\"email\":{},\"length\":{\"max\":255}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"firstName\",\"displayName\":\"${firstName}\",\"validations\":{\"length\":{\"max\":255},\"person-name-prohibited-characters\":{}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"lastName\",\"displayName\":\"${lastName}\",\"validations\":{\"length\":{\"max\":255},\"person-name-prohibited-characters\":{}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"displayName\",\"displayName\":\"\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"externalId\",\"displayName\":\"\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"preferredLanguage\",\"displayName\":\"${profile.attributes.preferredLanguage}\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[],\"edit\":[\"admin\"]},\"multivalued\":false}],\"groups\":[{\"name\":\"user-metadata\",\"displayHeader\":\"User metadata\",\"displayDescription\":\"Attributes, which refer to user metadata\"}],\"unmanagedAttributePolicy\":\"ENABLED\"}"] } } ], "org.keycloak.keys.KeyProvider" : [ { From 65e5b86a27b4b4e869d068f8552e97d44e156a2d Mon Sep 17 00:00:00 2001 From: "Mercedes.Segura" Date: Tue, 24 Feb 2026 16:03:46 +0100 Subject: [PATCH 08/35] test: add identity provider and mappers for custom user attributes --- src/test/resources/kc-test.json | 47 +++++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/src/test/resources/kc-test.json b/src/test/resources/kc-test.json index c4b6e33..c3c2175 100644 --- a/src/test/resources/kc-test.json +++ b/src/test/resources/kc-test.json @@ -1383,8 +1383,51 @@ "enabledEventTypes" : [ "SEND_RESET_PASSWORD", "UPDATE_CONSENT_ERROR", "GRANT_CONSENT", "VERIFY_PROFILE_ERROR", "REMOVE_TOTP", "REVOKE_GRANT", "UPDATE_TOTP", "LOGIN_ERROR", "CLIENT_LOGIN", "RESET_PASSWORD_ERROR", "UPDATE_CREDENTIAL", "IMPERSONATE_ERROR", "CODE_TO_TOKEN_ERROR", "CUSTOM_REQUIRED_ACTION", "OAUTH2_DEVICE_CODE_TO_TOKEN_ERROR", "RESTART_AUTHENTICATION", "IMPERSONATE", "UPDATE_PROFILE_ERROR", "LOGIN", "OAUTH2_DEVICE_VERIFY_USER_CODE", "UPDATE_PASSWORD_ERROR", "CLIENT_INITIATED_ACCOUNT_LINKING", "OAUTH2_EXTENSION_GRANT", "USER_DISABLED_BY_PERMANENT_LOCKOUT", "REMOVE_CREDENTIAL_ERROR", "TOKEN_EXCHANGE", "AUTHREQID_TO_TOKEN", "LOGOUT", "REGISTER", "DELETE_ACCOUNT_ERROR", "CLIENT_REGISTER", "IDENTITY_PROVIDER_LINK_ACCOUNT", "USER_DISABLED_BY_TEMPORARY_LOCKOUT", "DELETE_ACCOUNT", "UPDATE_PASSWORD", "CLIENT_DELETE", "FEDERATED_IDENTITY_LINK_ERROR", "IDENTITY_PROVIDER_FIRST_LOGIN", "CLIENT_DELETE_ERROR", "VERIFY_EMAIL", "CLIENT_LOGIN_ERROR", "RESTART_AUTHENTICATION_ERROR", "EXECUTE_ACTIONS", "REMOVE_FEDERATED_IDENTITY_ERROR", "TOKEN_EXCHANGE_ERROR", "PERMISSION_TOKEN", "FEDERATED_IDENTITY_OVERRIDE_LINK", "SEND_IDENTITY_PROVIDER_LINK_ERROR", "UPDATE_CREDENTIAL_ERROR", "EXECUTE_ACTION_TOKEN_ERROR", "OAUTH2_EXTENSION_GRANT_ERROR", "SEND_VERIFY_EMAIL", "OAUTH2_DEVICE_AUTH", "EXECUTE_ACTIONS_ERROR", "REMOVE_FEDERATED_IDENTITY", "OAUTH2_DEVICE_CODE_TO_TOKEN", "IDENTITY_PROVIDER_POST_LOGIN", "IDENTITY_PROVIDER_LINK_ACCOUNT_ERROR", "FEDERATED_IDENTITY_OVERRIDE_LINK_ERROR", "OAUTH2_DEVICE_VERIFY_USER_CODE_ERROR", "UPDATE_EMAIL", "REGISTER_ERROR", "REVOKE_GRANT_ERROR", "EXECUTE_ACTION_TOKEN", "LOGOUT_ERROR", "UPDATE_EMAIL_ERROR", "CLIENT_UPDATE_ERROR", "AUTHREQID_TO_TOKEN_ERROR", "INVITE_ORG_ERROR", "UPDATE_PROFILE", "CLIENT_REGISTER_ERROR", "FEDERATED_IDENTITY_LINK", "INVITE_ORG", "SEND_IDENTITY_PROVIDER_LINK", "SEND_VERIFY_EMAIL_ERROR", "RESET_PASSWORD", "CLIENT_INITIATED_ACCOUNT_LINKING_ERROR", "OAUTH2_DEVICE_AUTH_ERROR", "REMOVE_CREDENTIAL", "UPDATE_CONSENT", "REMOVE_TOTP_ERROR", "VERIFY_EMAIL_ERROR", "SEND_RESET_PASSWORD_ERROR", "CLIENT_UPDATE", "CUSTOM_REQUIRED_ACTION_ERROR", "IDENTITY_PROVIDER_POST_LOGIN_ERROR", "UPDATE_TOTP_ERROR", "CODE_TO_TOKEN", "VERIFY_PROFILE", "GRANT_CONSENT_ERROR", "IDENTITY_PROVIDER_FIRST_LOGIN_ERROR" ], "adminEventsEnabled" : false, "adminEventsDetailsEnabled" : false, - "identityProviders" : [ ], - "identityProviderMappers" : [ ], + "identityProviders": [ + { + "alias": "keycloak-oidc", + "displayName": "", + "internalId": "9064e24d-ab71-4ed9-a9ed-967431f0377e", + "providerId": "keycloak-oidc", + "enabled": true, + "updateProfileFirstLoginMode": "on", + "trustEmail": false, + "storeToken": false, + "addReadTokenRoleOnCreate": false, + "authenticateByDefault": false, + "linkOnly": false, + "hideOnLogin": false, + "config": { + "userInfoUrl": "https://example.com/auth/realms/test/protocol/openid-connect/userinfo", + "validateSignature": "true", + "tokenUrl": "https://example.com/auth/realms/test/protocol/openid-connect/token", + "clientId": "test", + "jwksUrl": "https://example.com/auth/realms/test/protocol/openid-connect/certs", + "issuer": "https://example.com/auth/realms/test", + "useJwksUrl": "true", + "pkceEnabled": "false", + "metadataDescriptorUrl": "https://example.com/auth/realms/test/.well-known/openid-configuration", + "authorizationUrl": "https://example.com/auth/realms/test/protocol/openid-connect/auth", + "clientAuthMethod": "client_secret_post", + "logoutUrl": "https://example.com/auth/realms/company/protocol/openid-connect/logout", + "syncMode": "FORCE", + "clientSecret": "**********" + } + } + ], + "identityProviderMappers": [ + { + "id": "3a1e8b54-f29f-4b86-96f2-cfd9838a059e", + "name": "attribute_mapping-job", + "identityProviderAlias": "keycloak-oidc", + "identityProviderMapper": "oidc-user-attribute-idp-mapper", + "config": { + "syncMode": "INHERIT", + "claim": "job", + "user.attribute": "job" + } + } + ], "components" : { "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy" : [ { "id" : "c9fb719f-552e-4e14-967f-7061d20eb23c", From 834abe77aedeb182c0534ef00bd3f7e583336875 Mon Sep 17 00:00:00 2001 From: "Mercedes.Segura" Date: Wed, 25 Feb 2026 09:43:16 +0100 Subject: [PATCH 09/35] docs: update README to reflect Microsoft Entra ID terminology and fix test --- README.md | 14 +++++++------- .../test/tests/functional/RealmSchemasTestsIT.java | 3 ++- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index dbf8650..1b35a3e 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ The following organization attributes are available: | SCIM_EXTERNAL_SHARED_SECRET | Shared secret value used for request authentication/validation. | | SCIM_EXTERNAL_SHARED_SECRET_HASH_ALGORITHM | PHC String Format representing hash algorithms and its parameters, used for request authentication/validation ([must be on of the following](https://www.keycloak.org/docs/26.1.5/server_admin/index.html#hashalgorithm)). | -### Azure Entra ID SCIM Configuration +### Microsoft Entra ID SCIM Configuration This extension is compatible with **Microsoft Entra ID** SCIM provisioning. @@ -117,7 +117,7 @@ When using Entra ID settings will be following: Replace with your actual Azure tenant ID. -* SCIM_AUTHENTICATION_MODE enables external authentication support for the SCIM server. In this case the external authentication source will be the Azure Entra ID. +* SCIM_AUTHENTICATION_MODE enables external authentication support for the SCIM server. In this case the external authentication source will be the Microsoft Entra ID. * SCIM_EXTERNAL_ISSUER ensures the JWT token was issued by your tenant. * SCIM_EXTERNAL_AUDIENCE must be exactly 8adf8e6e-67b2-4cf2-a259-e3dc5476c621 — this is the default audience used by Entra ID for non-gallery applications. * SCIM_EXTERNAL_JWKS_URI allows Keycloak to fetch public keys for token validation. @@ -177,7 +177,7 @@ For more information, refer to the following documents: https://learn.microsoft.com/en-us/entra/identity/saas-apps/tutorial-list -#### Identity Provider Linking with Azure Entra ID +#### Identity Provider Linking with Microsoft Entra ID Identity Provider linking with Entra ID requires a few additional configuration steps on both the Entra and Keycloak sides. @@ -251,7 +251,7 @@ This design does mean that provisioning a user through SCIM who previously exist ## User attributes for SCIM provisioning This section explains how to provision custom user attributes (e.g., `job`, `department`, `employeeId`) from an external -identity provider (such as Azure Entra ID) into Keycloak via SCIM. +identity provider (such as Microsoft Entra ID) into Keycloak via SCIM. By default, the SCIM server only exposes built-in user attributes (`userName`, `email`, `name.givenName`, `name.familyName`, `active`). To provision additional custom attributes, you need to configure Keycloak to accept @@ -342,7 +342,7 @@ For each custom attribute you want to provision via SCIM: #### Step 4: Map Attributes in Your SCIM Client (e.g., Entra ID) -In your SCIM client (e.g., Azure Entra ID Enterprise Application): +In your SCIM client (e.g., Microsoft Entra ID Enterprise Application): 1. Navigate to **Provisioning** > **Attribute Mapping (Preview)** > **Provision Microsoft Entra ID Users** 2. Click **Add New Mapping** @@ -359,7 +359,7 @@ The target attribute name must match the `User Attribute` value configured in th ### Example: Provisioning a "job" Attribute -This example shows how to provision the `jobTitle` attribute from Azure Entra ID to Keycloak as a `job` attribute. +This example shows how to provision the `jobTitle` attribute from Microsoft Entra ID to Keycloak as a `job` attribute. **Keycloak Configuration:** @@ -370,7 +370,7 @@ This example shows how to provision the `jobTitle` attribute from Azure Entra ID - **Claim**: `jobTitle` - **User Attribute**: `job` -**Azure Entra ID Configuration:** +**Microsoft Entra Configuration:** 1. In the Enterprise Application provisioning settings, add a mapping: - **Source attribute**: `jobTitle` diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmSchemasTestsIT.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmSchemasTestsIT.java index 6347bb9..0d7a4d3 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmSchemasTestsIT.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmSchemasTestsIT.java @@ -64,7 +64,7 @@ private void assertUserSchema(SchemaListItem schema) { assertEquals("User", schema.getName()); assertNotNull(schema.getDescription()); assertNotNull(schema.getAttributes()); - assertEquals(8, schema.getAttributes().size()); + assertEquals(9, schema.getAttributes().size()); assertUserAttribute(schema.getAttributes(), "userName", SchemaAttribute.TypeEnum.STRING); assertUserAttribute(schema.getAttributes(), "email", SchemaAttribute.TypeEnum.STRING); @@ -74,6 +74,7 @@ private void assertUserSchema(SchemaListItem schema) { assertUserAttribute(schema.getAttributes(), "displayName", SchemaAttribute.TypeEnum.STRING); assertUserAttribute(schema.getAttributes(), "externalId", SchemaAttribute.TypeEnum.STRING); assertUserAttribute(schema.getAttributes(), "preferredLanguage", SchemaAttribute.TypeEnum.STRING); + assertUserAttribute(schema.getAttributes(), "job", SchemaAttribute.TypeEnum.STRING); } /** From 4fd68ca608bef926bfc30b93f1f1bb4c6bc2996d Mon Sep 17 00:00:00 2001 From: "Mercedes.Segura" Date: Thu, 26 Feb 2026 14:41:24 +0100 Subject: [PATCH 10/35] feat: update user attribute source to include IDP_MAPPER and handle custom attributes --- .../keycloak/scim/server/metadata/MetadataController.java | 2 +- .../keycloak/scim/server/metadata/UserAttribute.java | 6 +++++- .../keycloak/scim/server/users/UsersController.java | 7 +++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/metadata/MetadataController.java b/src/main/java/fi/metatavu/keycloak/scim/server/metadata/MetadataController.java index 17a4691..1eedd82 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/metadata/MetadataController.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/metadata/MetadataController.java @@ -322,7 +322,7 @@ private List> getUserAttributeMappingList(ScimContext scimConte } if (!builtInAttributeNames.contains(attribute)) { customAttributes.add(new StringUserAttribute( - UserAttribute.Source.USER_MODEL, + UserAttribute.Source.IDP_MAPPER, attribute, attribute, attribute, diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/metadata/UserAttribute.java b/src/main/java/fi/metatavu/keycloak/scim/server/metadata/UserAttribute.java index 484bb38..ff084ae 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/metadata/UserAttribute.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/metadata/UserAttribute.java @@ -19,8 +19,12 @@ public class UserAttribute { * Attribute source */ public enum Source { + // User model attributes are stored in Keycloak user model USER_MODEL, - USER_PROFILE + // Custom attributes defined in user profile + USER_PROFILE, + // Attributes defined in identity provider attribute mapper + IDP_MAPPER } private final Source source; diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/users/UsersController.java b/src/main/java/fi/metatavu/keycloak/scim/server/users/UsersController.java index d5f3c09..d4a91a2 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/users/UsersController.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/users/UsersController.java @@ -569,6 +569,13 @@ protected fi.metatavu.keycloak.scim.server.model.User translateUser( result.putAdditionalProperty(userAttribute.getScimPath(), value); } } + List> mapperAttributes = userAttributes.listBySource(UserAttribute.Source.IDP_MAPPER); + for (UserAttribute userAttribute : mapperAttributes) { + Object value = userAttribute.read(user); + if (value != null) { + result.putAdditionalProperty(userAttribute.getScimPath(), value); + } + } return result; } From f1a4c925c1ba81e7b3ead81adbfbbadd08393a09 Mon Sep 17 00:00:00 2001 From: Nicola Date: Tue, 17 Mar 2026 19:12:07 +0100 Subject: [PATCH 11/35] docs: update installation instructions in README.md for GitHub Release --- README.md | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 64f1420..419e588 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,36 @@ This project provides a **SCIM 2.0-compliant extension** for [Keycloak](https:// ## Installation -### Option 1: Install from GitHub Packages (recommended) +### Option 1: Include it directly from GitHub Release +You can reference the JAR file from a GitHub Release directly in your init container or Dockerfile. + +For example, using a Helm `values.yaml`: +```yaml +extraInitContainers: | + - name: download-scim-plugin + image: alpine:latest + command: + - sh + - -c + - > + apk add --no-cache curl && + curl -L -o /extensions/keycloak-scim-server-.jar https://github.com/Metatavu/keycloak-scim-server/releases/download/v/keycloak-scim-server-.jar + volumeMounts: + - name: extensions + mountPath: /extensions + +extraVolumeMounts: | + - name: extensions + mountPath: /opt/keycloak/providers + +extraVolumes: | + - name: extensions + emptyDir: {} +``` + +### Option 2: Install from GitHub Packages (recommended) -Easiest way to use the extension is to download a JAR file from GitHub packages. +Download the JAR file from GitHub packages. 1. Download the latest JAR from: [GitHub Packages](https://github.com/Metatavu/keycloak-scim-server/packages/2454996) 2. Copy it to your Keycloak instance: @@ -26,7 +53,7 @@ Easiest way to use the extension is to download a JAR file from GitHub packages. 3. Restart Keycloak. -### Option 2: Build from Source +### Option 3: Build from Source 1. Build the extension: ```bash From 1f5aee8c8d73a75ce680723dfd8e81e757f57422 Mon Sep 17 00:00:00 2001 From: Nicola Date: Wed, 1 Apr 2026 11:28:06 +0200 Subject: [PATCH 12/35] feat: add debug logging for various operations across multiple classes --- .../keycloak/scim/server/ScimResources.java | 34 +++++++++++++++++++ .../adminEvents/AdminEventController.java | 10 ++++++ .../scim/server/authentication/JwksUtils.java | 12 ++++++- .../server/authentication/PhcStringUtils.java | 7 ++++ .../authentication/VerifierFactory.java | 5 +++ .../scim/server/filter/ScimFilterParser.java | 5 +++ .../organization/OrganizationController.java | 9 ++++- .../organization/OrganizationScimConfig.java | 11 ++++++ .../scim/server/realm/RealmScimConfig.java | 9 +++++ 9 files changed, 100 insertions(+), 2 deletions(-) diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/ScimResources.java b/src/main/java/fi/metatavu/keycloak/scim/server/ScimResources.java index e100019..a1c683c 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/ScimResources.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/ScimResources.java @@ -40,6 +40,7 @@ public Response createRealmUser( @Context KeycloakSession session, fi.metatavu.keycloak.scim.server.model.User createRequest ) { + logger.debug("POST /v2/Users"); RealmScimContext scimContext = realmScimServer.getScimContext(session); realmScimServer.verifyPermissions(scimContext); @@ -59,6 +60,7 @@ public Response listRealmUsers( @QueryParam("startIndex") @DefaultValue("0") Integer startIndex, @QueryParam("count") @DefaultValue("100") Integer count ) { + logger.debugf("GET /v2/Users filter=%s startIndex=%d count=%d", filter, startIndex, count); RealmScimContext scimContext = realmScimServer.getScimContext(session); realmScimServer.verifyPermissions(scimContext); @@ -86,6 +88,7 @@ public Response findRealmUser( @Context KeycloakSession session, @PathParam("id") String userId ) { + logger.debugf("GET /v2/Users/%s", userId); RealmScimContext scimContext = realmScimServer.getScimContext(session); realmScimServer.verifyPermissions(scimContext); @@ -105,6 +108,7 @@ public Response updateRealmUser( @PathParam("id") String userId, fi.metatavu.keycloak.scim.server.model.User updateRequest ) { + logger.debugf("PUT /v2/Users/%s", userId); RealmScimContext scimContext = realmScimServer.getScimContext(session); realmScimServer.verifyPermissions(scimContext); @@ -125,6 +129,7 @@ public Response patchRealmUser( @PathParam("id") String userId, fi.metatavu.keycloak.scim.server.model.PatchRequest patchRequest ) { + logger.debugf("PATCH /v2/Users/%s", userId); RealmScimContext scimContext = realmScimServer.getScimContext(session); realmScimServer.verifyPermissions(scimContext); @@ -143,6 +148,7 @@ public Response deleteRealmUser( @Context KeycloakSession session, @PathParam("id") String userId ) { + logger.debugf("DELETE /v2/Users/%s", userId); RealmScimContext scimContext = realmScimServer.getScimContext(session); realmScimServer.verifyPermissions(scimContext); @@ -158,6 +164,7 @@ public Response createRealmGroup( @Context KeycloakSession session, fi.metatavu.keycloak.scim.server.model.Group createRequest ) { + logger.debug("POST /v2/Groups"); RealmScimContext scimContext = realmScimServer.getScimContext(session); realmScimServer.verifyPermissions(scimContext); @@ -177,6 +184,7 @@ public Response listRealmGroups( @QueryParam("startIndex") @DefaultValue("0") int startIndex, @QueryParam("count") @DefaultValue("100") int count ) { + logger.debugf("GET /v2/Groups filter=%s startIndex=%d count=%d", filter, startIndex, count); RealmScimContext scimContext = realmScimServer.getScimContext(session); realmScimServer.verifyPermissions(scimContext); @@ -204,6 +212,7 @@ public Response findRealmGroup( @Context KeycloakSession session, @PathParam("id") String id ) { + logger.debugf("GET /v2/Groups/%s", id); RealmScimContext scimContext = realmScimServer.getScimContext(session); realmScimServer.verifyPermissions(scimContext); @@ -223,6 +232,7 @@ public Response updateRealmGroup( @Context KeycloakSession session, Group updateRequest ) { + logger.debugf("PUT /v2/Groups/%s", id); RealmScimContext scimContext = realmScimServer.getScimContext(session); realmScimServer.verifyPermissions(scimContext); @@ -243,6 +253,7 @@ public Response patchRealmGroup( @PathParam("id") String groupId, fi.metatavu.keycloak.scim.server.model.PatchRequest patchRequest ) { + logger.debugf("PATCH /v2/Groups/%s", groupId); RealmScimContext scimContext = realmScimServer.getScimContext(session); realmScimServer.verifyPermissions(scimContext); @@ -260,6 +271,7 @@ public Response deleteRealmGroup( @Context KeycloakSession session, @PathParam("id") String id ) { + logger.debugf("DELETE /v2/Groups/%s", id); RealmScimContext scimContext = realmScimServer.getScimContext(session); realmScimServer.verifyPermissions(scimContext); @@ -277,6 +289,7 @@ public Response listRealmResourceTypes( @Context KeycloakSession session, @Context UriInfo uriInfo ) { + logger.debug("GET /v2/ResourceTypes"); RealmScimContext scimContext = realmScimServer.getScimContext(session); realmScimServer.verifyPermissions(scimContext); @@ -291,6 +304,7 @@ public Response findRealmResourceType( @Context KeycloakSession session, @PathParam("id") String id ) { + logger.debugf("GET /v2/ResourceTypes/%s", id); RealmScimContext scimContext = realmScimServer.getScimContext(session); realmScimServer.verifyPermissions(scimContext); @@ -308,6 +322,7 @@ public Response listRealmSchemas( @Context KeycloakSession session, @Context UriInfo uriInfo ) { + logger.debug("GET /v2/Schemas"); RealmScimContext scimContext = realmScimServer.getScimContext(session); realmScimServer.verifyPermissions(scimContext); @@ -322,6 +337,7 @@ public Response findRealmSchema( @Context KeycloakSession session, @PathParam("id") String id ) { + logger.debugf("GET /v2/Schemas/%s", id); RealmScimContext scimContext = realmScimServer.getScimContext(session); realmScimServer.verifyPermissions(scimContext); @@ -339,6 +355,7 @@ public Response getRealmServiceProviderConfig( @Context KeycloakSession session, @Context UriInfo uriInfo ) { + logger.debug("GET /v2/ServiceProviderConfig"); RealmScimContext scimContext = realmScimServer.getScimContext(session); realmScimServer.verifyPermissions(scimContext); @@ -357,6 +374,7 @@ public Response createOrganizationUser( @PathParam("organizationId") String organizationId, fi.metatavu.keycloak.scim.server.model.User createRequest ) { + logger.debugf("POST /v2/organizations/%s/Users", organizationId); OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); organizationScimServer.verifyPermissions(scimContext); @@ -377,6 +395,7 @@ public Response listOrganizationUsers( @QueryParam("startIndex") @DefaultValue("0") Integer startIndex, @QueryParam("count") @DefaultValue("100") Integer count ) { + logger.debugf("GET /v2/organizations/%s/Users filter=%s startIndex=%d count=%d", organizationId, filter, startIndex, count); OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); organizationScimServer.verifyPermissions(scimContext); @@ -405,6 +424,7 @@ public Response findOrganizationUser( @PathParam("id") String userId, @PathParam("organizationId") String organizationId ) { + logger.debugf("GET /v2/organizations/%s/Users/%s", organizationId, userId); OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); organizationScimServer.verifyPermissions(scimContext); @@ -425,6 +445,7 @@ public Response updateOrganizationUser( @PathParam("organizationId") String organizationId, fi.metatavu.keycloak.scim.server.model.User updateRequest ) { + logger.debugf("PUT /v2/organizations/%s/Users/%s", organizationId, userId); OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); organizationScimServer.verifyPermissions(scimContext); @@ -446,6 +467,7 @@ public Response patchOrganizationUser( @PathParam("organizationId") String organizationId, fi.metatavu.keycloak.scim.server.model.PatchRequest patchRequest ) { + logger.debugf("PATCH /v2/organizations/%s/Users/%s", organizationId, userId); OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); organizationScimServer.verifyPermissions(scimContext); @@ -465,6 +487,7 @@ public Response deleteOrganizationUser( @PathParam("organizationId") String organizationId, @PathParam("id") String userId ) { + logger.debugf("DELETE /v2/organizations/%s/Users/%s", organizationId, userId); OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); organizationScimServer.verifyPermissions(scimContext); @@ -481,6 +504,7 @@ public Response createOrganizationGroup( @PathParam("organizationId") String organizationId, fi.metatavu.keycloak.scim.server.model.Group createRequest ) { + logger.debugf("POST /v2/organizations/%s/Groups", organizationId); OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); organizationScimServer.verifyPermissions(scimContext); @@ -501,6 +525,7 @@ public Response listOrganizationGroups( @QueryParam("startIndex") @DefaultValue("0") int startIndex, @QueryParam("count") @DefaultValue("100") int count ) { + logger.debugf("GET /v2/organizations/%s/Groups filter=%s startIndex=%d count=%d", organizationId, filter, startIndex, count); OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); organizationScimServer.verifyPermissions(scimContext); @@ -529,6 +554,7 @@ public Response findOrganizationGroup( @PathParam("organizationId") String organizationId, @PathParam("id") String id ) { + logger.debugf("GET /v2/organizations/%s/Groups/%s", organizationId, id); OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); organizationScimServer.verifyPermissions(scimContext); @@ -549,6 +575,7 @@ public Response updateOrganizationGroup( @PathParam("organizationId") String organizationId, Group updateRequest ) { + logger.debugf("PUT /v2/organizations/%s/Groups/%s", organizationId, id); OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); organizationScimServer.verifyPermissions(scimContext); @@ -570,6 +597,7 @@ public Response patchOrganizationGroup( @PathParam("organizationId") String organizationId, fi.metatavu.keycloak.scim.server.model.PatchRequest patchRequest ) { + logger.debugf("PATCH /v2/organizations/%s/Groups/%s", organizationId, groupId); OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); organizationScimServer.verifyPermissions(scimContext); @@ -588,6 +616,7 @@ public Response deleteOrganizationGroup( @PathParam("organizationId") String organizationId, @PathParam("id") String id ) { + logger.debugf("DELETE /v2/organizations/%s/Groups/%s", organizationId, id); OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); organizationScimServer.verifyPermissions(scimContext); @@ -606,6 +635,7 @@ public Response listOrganizationResourceTypes( @Context UriInfo uriInfo, @PathParam("organizationId") String organizationId ) { + logger.debugf("GET /v2/organizations/%s/ResourceTypes", organizationId); OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); organizationScimServer.verifyPermissions(scimContext); @@ -623,6 +653,7 @@ public Response findOrganizationResourceType( @PathParam("organizationId") String organizationId, @PathParam("id") String id ) { + logger.debugf("GET /v2/organizations/%s/ResourceTypes/%s", organizationId, id); OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); organizationScimServer.verifyPermissions(scimContext); @@ -641,6 +672,7 @@ public Response listOrganizationSchemas( @PathParam("organizationId") String organizationId, @Context UriInfo uriInfo ) { + logger.debugf("GET /v2/organizations/%s/Schemas", organizationId); OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); organizationScimServer.verifyPermissions(scimContext); @@ -658,6 +690,7 @@ public Response findOrganizationSchema( @PathParam("organizationId") String organizationId, @PathParam("id") String id ) { + logger.debugf("GET /v2/organizations/%s/Schemas/%s", organizationId, id); OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); organizationScimServer.verifyPermissions(scimContext); @@ -676,6 +709,7 @@ public Response getOrganizationServiceProviderConfig( @PathParam("organizationId") String organizationId, @Context UriInfo uriInfo ) { + logger.debugf("GET /v2/organizations/%s/ServiceProviderConfig", organizationId); OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); organizationScimServer.verifyPermissions(scimContext); return organizationScimServer.getServiceProviderConfig(scimContext); diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/adminEvents/AdminEventController.java b/src/main/java/fi/metatavu/keycloak/scim/server/adminEvents/AdminEventController.java index 8eb7b72..39178c2 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/adminEvents/AdminEventController.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/adminEvents/AdminEventController.java @@ -13,12 +13,16 @@ import org.keycloak.models.RealmModel; import org.keycloak.util.JsonSerialization; +import org.jboss.logging.Logger; + import java.io.IOException; import java.util.List; import java.util.Map; import java.util.UUID; public class AdminEventController extends AbstractController { + + private static final Logger logger = Logger.getLogger(AdminEventController.class.getName()); /** * Sends an admin event * @@ -78,10 +82,13 @@ public void sendAdminEvent( authDetails.setUserId("SCIM_CLIENT"); event.setAuthDetails(authDetails); + logger.debugf("Sending admin event: %s %s %s", operationType, resourceType, resourcePath); + if (representation != null) { try { event.setRepresentation(JsonSerialization.writeValueAsString(representation)); } catch (IOException e) { + logger.errorf(e, "Failed to serialize representation for admin event: %s %s %s", operationType, resourceType, resourcePath); throw new RuntimeException(e); } } @@ -92,6 +99,8 @@ public void sendAdminEvent( EventStoreProvider store = session.getProvider(EventStoreProvider.class); if (store != null) { store.onEvent(event, includeRepresentation); + } else { + logger.warn("Admin events enabled but no EventStoreProvider found — event not persisted"); } } @@ -101,6 +110,7 @@ public void sendAdminEvent( .map(providerFactory -> providerFactory.create(session)) .forEach(provider -> { if (provider instanceof EventListenerProvider eventListenerProvider) { + logger.debugf("Dispatching admin event to listener: %s", provider.getClass().getName()); eventListenerProvider.onEvent(event, includeRepresentation); } }); diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/authentication/JwksUtils.java b/src/main/java/fi/metatavu/keycloak/scim/server/authentication/JwksUtils.java index 38233e3..05cbb15 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/authentication/JwksUtils.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/authentication/JwksUtils.java @@ -4,6 +4,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.keycloak.jose.jwk.JWKParser; +import org.jboss.logging.Logger; + import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; @@ -20,6 +22,8 @@ */ public class JwksUtils { + private static final Logger logger = Logger.getLogger(JwksUtils.class.getName()); + /** * Loads all public keys from JWKS URL * @@ -38,6 +42,7 @@ public static List getPublicKeysFromJwks(String jwksUrl) throws URISynta HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()); if (response.statusCode() != 200) { + logger.errorf("Failed to fetch JWKS from %s: HTTP %d", jwksUrl, response.statusCode()); throw new RuntimeException("Failed to fetch JWKS: HTTP " + response.statusCode()); } @@ -48,6 +53,7 @@ public static List getPublicKeysFromJwks(String jwksUrl) throws URISynta List> keys = (List>) jwks.get("keys"); if (keys == null || keys.isEmpty()) { + logger.errorf("No keys found in JWKS response from %s", jwksUrl); throw new RuntimeException("No keys found in JWKS"); } @@ -55,7 +61,10 @@ public static List getPublicKeysFromJwks(String jwksUrl) throws URISynta String kid = (String) jwk.get("kid"); String use = (String) jwk.get("use"); - if (kid == null) continue; + if (kid == null) { + logger.warn("Skipping JWK entry with no 'kid' field"); + continue; + } if (use == null) { use = "sig"; @@ -69,6 +78,7 @@ public static List getPublicKeysFromJwks(String jwksUrl) throws URISynta result.add(new JwkKey(publicKey, kid, use)); } + logger.debugf("Loaded %d public key(s) from JWKS", result.size()); return result; } } diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/authentication/PhcStringUtils.java b/src/main/java/fi/metatavu/keycloak/scim/server/authentication/PhcStringUtils.java index fcb832d..bb3603a 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/authentication/PhcStringUtils.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/authentication/PhcStringUtils.java @@ -1,5 +1,7 @@ package fi.metatavu.keycloak.scim.server.authentication; +import org.jboss.logging.Logger; + import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -9,6 +11,8 @@ public class PhcStringUtils { + private static final Logger logger = Logger.getLogger(PhcStringUtils.class.getName()); + public static final String ARGON_2_PREFIX = "argon2"; public static final String PBKDF2_PREFIX = "pbkdf2"; @@ -33,11 +37,14 @@ public static PasswordCredentialModel fromPHCString(String phcString) { String algId = parts[1]; + logger.debugf("Parsing PHC string with algorithm: %s", algId); + if (algId.startsWith(ARGON_2_PREFIX)) { return parseArgon2(parts); } else if (algId.startsWith(PBKDF2_PREFIX)) { return parsePbkdf2(parts); } else { + logger.warnf("Unknown algorithm in PHC string: %s", algId); throw new IllegalArgumentException("Unknown algorithm in PHC string: " + algId); } } diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/authentication/VerifierFactory.java b/src/main/java/fi/metatavu/keycloak/scim/server/authentication/VerifierFactory.java index 25ce16e..d3470fc 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/authentication/VerifierFactory.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/authentication/VerifierFactory.java @@ -2,6 +2,7 @@ import fi.metatavu.keycloak.scim.server.config.ScimConfig; import fi.metatavu.keycloak.scim.server.config.ScimConfig.AuthenticationMode; +import org.jboss.logging.Logger; import org.keycloak.models.KeycloakSession; /** @@ -9,6 +10,8 @@ */ public class VerifierFactory { + private static final Logger logger = Logger.getLogger(VerifierFactory.class.getName()); + private VerifierFactory() {} /** @@ -20,11 +23,13 @@ public static Verifier build(ScimConfig config, KeycloakSession session) { } String sharedSecret = config.getSharedSecret(); if (sharedSecret == null || sharedSecret.isBlank()) { + logger.debugf("Building ExternalTokenVerifier (issuer=%s, jwksUri=%s)", config.getExternalIssuer(), config.getExternalJwksUri()); return new ExternalTokenVerifier( config.getExternalIssuer(), config.getExternalJwksUri(), config.getExternalAudience()); } else { + logger.debug("Building ExternalSharedSecretVerifier"); return new ExternalSharedSecretVerifier(session, sharedSecret); } } diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/filter/ScimFilterParser.java b/src/main/java/fi/metatavu/keycloak/scim/server/filter/ScimFilterParser.java index 04d9752..2fc256a 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/filter/ScimFilterParser.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/filter/ScimFilterParser.java @@ -1,5 +1,7 @@ package fi.metatavu.keycloak.scim.server.filter; +import org.jboss.logging.Logger; + import java.util.regex.*; /** @@ -7,6 +9,8 @@ */ public class ScimFilterParser { + private static final Logger logger = Logger.getLogger(ScimFilterParser.class.getName()); + private static final Pattern EQ_PATTERN = Pattern.compile( "(\\w+(\\.\\w+)*)\\s+eq\\s+(\"[^\"]+\"|true|false|\\d+)", Pattern.CASE_INSENSITIVE @@ -83,6 +87,7 @@ public ScimFilter parse(String filter) { return parseComparison(ew, ScimFilter.Operator.EW); } + logger.warnf("Unsupported SCIM filter expression: %s", filter); throw new UnsupportedFilter(filter); } diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationController.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationController.java index 2277d7a..698a725 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationController.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationController.java @@ -1,6 +1,7 @@ package fi.metatavu.keycloak.scim.server.organization; import fi.metatavu.keycloak.scim.server.AbstractController; +import org.jboss.logging.Logger; import org.keycloak.models.KeycloakContext; import org.keycloak.models.KeycloakSession; import org.keycloak.models.OrganizationModel; @@ -8,11 +9,17 @@ public class OrganizationController extends AbstractController { + private static final Logger logger = Logger.getLogger(OrganizationController.class.getName()); + public OrganizationModel findOrganizationById( KeycloakSession session, String organizationId ) { - return getOrganizationProvider(session).getById(organizationId); + OrganizationModel organization = getOrganizationProvider(session).getById(organizationId); + if (organization == null) { + logger.warnf("Organization not found: %s", organizationId); + } + return organization; } /** diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimConfig.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimConfig.java index cbc622c..5a63a75 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimConfig.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimConfig.java @@ -2,6 +2,8 @@ import fi.metatavu.keycloak.scim.server.config.ConfigurationError; import fi.metatavu.keycloak.scim.server.config.ScimConfig; +import org.jboss.logging.Logger; + import java.util.List; import java.util.Map; import org.keycloak.models.OrganizationModel; @@ -11,6 +13,8 @@ */ public class OrganizationScimConfig implements ScimConfig { + private static final Logger logger = Logger.getLogger(OrganizationScimConfig.class.getName()); + public static final String SCIM_EXTERNAL_SHARED_SECRET = "SCIM_EXTERNAL_SHARED_SECRET"; public static final String SCIM_EXTERNAL_JWKS_URI = "SCIM_EXTERNAL_JWKS_URI"; public static final String SCIM_EXTERNAL_AUDIENCE = "SCIM_EXTERNAL_AUDIENCE"; @@ -29,26 +33,33 @@ public OrganizationScimConfig(OrganizationModel organization) { public void validateConfig() throws ConfigurationError { AuthenticationMode mode = getAuthenticationMode(); if (mode == null) { + logger.warnf("Organization SCIM config invalid: %s is not set", SCIM_AUTHENTICATION_MODE); throw new ConfigurationError(SCIM_AUTHENTICATION_MODE + " is not set"); } + logger.debugf("Organization SCIM authentication mode: %s", mode); + boolean isSharedSecretPresent = getSharedSecret() != null && !getSharedSecret().isBlank(); if (mode == AuthenticationMode.EXTERNAL) { if (!isSharedSecretPresent) { if (getExternalIssuer() == null) { + logger.warnf("Organization SCIM config invalid: %s is not set", SCIM_EXTERNAL_ISSUER); throw new ConfigurationError(SCIM_EXTERNAL_ISSUER + " is not set"); } if (getExternalJwksUri() == null) { + logger.warnf("Organization SCIM config invalid: %s is not set", SCIM_EXTERNAL_JWKS_URI); throw new ConfigurationError(SCIM_EXTERNAL_JWKS_URI + " is not set"); } if (getExternalAudience() == null) { + logger.warnf("Organization SCIM config invalid: %s is not set", SCIM_EXTERNAL_AUDIENCE); throw new ConfigurationError(SCIM_EXTERNAL_AUDIENCE + " is not set"); } } } else { + logger.warnf("Organization SCIM config invalid: authentication mode %s is not supported in organization mode", mode); throw new ConfigurationError( String.format( SCIM_AUTHENTICATION_MODE + " %s AuthenticationMode not supported in organization mode", diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimConfig.java b/src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimConfig.java index 345a490..015b14a 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimConfig.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimConfig.java @@ -4,6 +4,7 @@ import fi.metatavu.keycloak.scim.server.config.ScimConfig; import org.eclipse.microprofile.config.Config; import org.eclipse.microprofile.config.ConfigProvider; +import org.jboss.logging.Logger; import org.keycloak.models.RealmModel; import java.util.Optional; @@ -13,6 +14,8 @@ */ public class RealmScimConfig implements ScimConfig { + private static final Logger logger = Logger.getLogger(RealmScimConfig.class.getName()); + public static final String SCIM_EXTERNAL_JWKS_URI = "scim.external.jwks.uri"; public static final String SCIM_EXTERNAL_AUDIENCE = "scim.external.audience"; public static final String SCIM_EXTERNAL_SHARED_SECRET = "scim.external.shared.secret"; @@ -35,21 +38,27 @@ public RealmScimConfig(RealmModel realm) { public void validateConfig() throws ConfigurationError { AuthenticationMode mode = getAuthenticationMode(); if (mode == null) { + logger.warn("Realm SCIM config invalid: SCIM_AUTHENTICATION_MODE is not set"); throw new ConfigurationError("SCIM_AUTHENTICATION_MODE is not set"); } + logger.debugf("Realm SCIM authentication mode: %s", mode); + boolean isSharedSecretPresent = getSharedSecret() != null && !getSharedSecret().isBlank(); if (mode == AuthenticationMode.EXTERNAL && !isSharedSecretPresent) { if (getExternalIssuer() == null) { + logger.warn("Realm SCIM config invalid: SCIM_EXTERNAL_ISSUER is not set"); throw new ConfigurationError("SCIM_EXTERNAL_ISSUER is not set"); } if (getExternalJwksUri() == null) { + logger.warn("Realm SCIM config invalid: SCIM_EXTERNAL_JWKS_URI is not set"); throw new ConfigurationError("SCIM_EXTERNAL_JWKS_URI is not set"); } if (getExternalAudience() == null) { + logger.warn("Realm SCIM config invalid: SCIM_EXTERNAL_AUDIENCE is not set"); throw new ConfigurationError("SCIM_EXTERNAL_AUDIENCE is not set"); } } From a1ac8447e40bf6ddd5b60429b37ca7edb0f921a1 Mon Sep 17 00:00:00 2001 From: Nicola Date: Wed, 1 Apr 2026 14:57:36 +0200 Subject: [PATCH 13/35] fix: reduce debug logging during external token validation --- .../keycloak/scim/server/authentication/VerifierFactory.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/authentication/VerifierFactory.java b/src/main/java/fi/metatavu/keycloak/scim/server/authentication/VerifierFactory.java index d3470fc..ff26aea 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/authentication/VerifierFactory.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/authentication/VerifierFactory.java @@ -23,13 +23,11 @@ public static Verifier build(ScimConfig config, KeycloakSession session) { } String sharedSecret = config.getSharedSecret(); if (sharedSecret == null || sharedSecret.isBlank()) { - logger.debugf("Building ExternalTokenVerifier (issuer=%s, jwksUri=%s)", config.getExternalIssuer(), config.getExternalJwksUri()); return new ExternalTokenVerifier( config.getExternalIssuer(), config.getExternalJwksUri(), config.getExternalAudience()); } else { - logger.debug("Building ExternalSharedSecretVerifier"); return new ExternalSharedSecretVerifier(session, sharedSecret); } } From 18570a6ec01d8cff21eabb037b943b2071ee96dc Mon Sep 17 00:00:00 2001 From: Nicola Date: Wed, 1 Apr 2026 14:57:46 +0200 Subject: [PATCH 14/35] fix: streamline debug logging in AdminEventController --- .../scim/server/adminEvents/AdminEventController.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/adminEvents/AdminEventController.java b/src/main/java/fi/metatavu/keycloak/scim/server/adminEvents/AdminEventController.java index 39178c2..ccdc393 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/adminEvents/AdminEventController.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/adminEvents/AdminEventController.java @@ -82,8 +82,6 @@ public void sendAdminEvent( authDetails.setUserId("SCIM_CLIENT"); event.setAuthDetails(authDetails); - logger.debugf("Sending admin event: %s %s %s", operationType, resourceType, resourcePath); - if (representation != null) { try { event.setRepresentation(JsonSerialization.writeValueAsString(representation)); @@ -110,7 +108,7 @@ public void sendAdminEvent( .map(providerFactory -> providerFactory.create(session)) .forEach(provider -> { if (provider instanceof EventListenerProvider eventListenerProvider) { - logger.debugf("Dispatching admin event to listener: %s", provider.getClass().getName()); + logger.debugf("Sending admin event: %s %s %s", operationType, resourceType, resourcePath); eventListenerProvider.onEvent(event, includeRepresentation); } }); From 28f7efa509fd074e1af978a23fe0815e77478f09 Mon Sep 17 00:00:00 2001 From: Garth <244253+xgp@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:07:19 +0200 Subject: [PATCH 15/35] added basic auth for okta compatibility. updated readme. --- README.md | 446 +++++++++++++----- .../scim/server/AbstractScimServer.java | 33 +- .../authentication/BasicAuthVerifier.java | 85 ++++ .../authentication/VerifierFactory.java | 12 +- .../scim/server/config/ScimConfig.java | 14 + .../organization/OrganizationScimConfig.java | 25 +- .../scim/server/realm/RealmScimConfig.java | 59 ++- .../keycloak/scim/server/test/ScimClient.java | 25 +- .../functional/RealmBasicAuthTestsIT.java | 56 +++ .../server/test/utils/KeycloakTestUtils.java | 18 + 10 files changed, 620 insertions(+), 153 deletions(-) create mode 100644 src/main/java/fi/metatavu/keycloak/scim/server/authentication/BasicAuthVerifier.java create mode 100644 src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmBasicAuthTestsIT.java diff --git a/README.md b/README.md index bdabd7d..64bceee 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,35 @@ This project provides a **SCIM 2.0-compliant extension** for [Keycloak](https://www.keycloak.org/), enabling SCIM-based user and group provisioning. It supports: -- **Realm-level SCIM APIs**: +- **Realm-level SCIM APIs**: `/realms/{realm}/scim/v2` -- **Organization-level SCIM APIs** (Keycloak 26+ with Organizations): +- **Organization-level SCIM APIs** (Keycloak 26+ with Organizations): `/realms/{realm}/scim/v2/organizations/{organizationId}` +## Table of Contents + +- [Prerequisites](#prerequisites) +- [Installation](#installation) + - [Option 1: Include from GitHub Release](#option-1-include-it-directly-from-github-release) + - [Option 2: Install from GitHub Packages](#option-2-install-from-github-packages-recommended) + - [Option 3: Build from Source](#option-3-build-from-source) +- [Configuration](#configuration) + - [Instance-Level Configuration](#instance-level-configuration) + - [Realm-Level Configuration](#realm-level-configuration) + - [Organization-Level Configuration](#organization-level-configuration) +- [Authentication](#authentication) + - [Keycloak Authentication](#keycloak-authentication) + - [External JWT (JWKS) Authentication](#external-jwt-jwks-authentication) + - [External Shared Secret (Bearer Token) Authentication](#external-shared-secret-bearer-token-authentication) + - [External Basic Auth Authentication](#external-basic-auth-authentication) +- [Vendor Configuration Guides](#vendor-configuration-guides) + - [Microsoft Entra ID](#microsoft-entra-id) + - [Okta](#okta) +- [Identity Provider Linking](#identity-provider-linking) + - [Identity Provider Linking with Azure Entra ID](#identity-provider-linking-with-azure-entra-id) +- [SCIM-Managed Users](#scim-managed-users) +- [License](#license) + ## Prerequisites - **Keycloak**: This extension is developed for Keycloak **26.3.5**. It may work with other versions, but compatibility is not guaranteed. @@ -15,6 +39,7 @@ This project provides a **SCIM 2.0-compliant extension** for [Keycloak](https:// ## Installation ### Option 1: Include it directly from GitHub Release + You can reference the JAR file from a GitHub Release directly in your init container or Dockerfile. For example, using a Helm `values.yaml`: @@ -43,16 +68,15 @@ extraVolumes: | ### Option 2: Install from GitHub Packages (recommended) -Download the JAR file from GitHub packages. +Download the JAR file from GitHub packages. 1. Download the latest JAR from: [GitHub Packages](https://github.com/Metatavu/keycloak-scim-server/packages/2454996) 2. Copy it to your Keycloak instance: ```bash - cp keycloak-scim-server-*.jar $KEYCLOAK_HOME/providers/ +cp keycloak-scim-server-*.jar $KEYCLOAK_HOME/providers/ ``` 3. Restart Keycloak. - ### Option 3: Build from Source 1. Build the extension: @@ -66,29 +90,30 @@ cp build/libs/keycloak-scim-server-*.jar $KEYCLOAK_HOME/providers/ ## Configuration -### Configuration on Instance level +All settings can be applied at three levels. Settings at a more specific level override broader ones (organization > realm > instance). -Configuration on instance level is done by defining environment variables in the Keycloak server. +### Instance-Level Configuration -The following environment variables are available: +Configuration on instance level is done by defining environment variables on the Keycloak server. -| Setting | Value | -|--------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| SCIM_AUTHENTICATION_MODE | Authentication mode for SCIM API. Possible values are KEYCLOAK and EXTERNAL. If the value is not set the server will respond unauthorzed for all requests. | -| SCIM_EXTERNAL_ISSUER | Issuer for the external authentication. This is used to validate the JWT token. | -| SCIM_EXTERNAL_AUDIENCE | JWKS URI for the external authentication. This is used to validate the JWT token. | -| SCIM_EXTERNAL_JWKS_URI | Audience for the external authentication. This is used to validate the JWT token. | -| SCIM_EXTERNAL_SHARED_SECRET | Shared secret value used for request authentication/validation. | -| SCIM_EXTERNAL_SHARED_SECRET_HASH_ALGORITHM | PHC String Format representing hash algorithms and its parameters, used for request authentication/validation ([must be on of the following](https://www.keycloak.org/docs/26.1.5/server_admin/index.html#hashalgorithm)). | -| SCIM_LINK_IDP | Enables support for linking realm identity provider with user. | -| SCIM_IDENTITY_PROVIDER_ALIAS | Alias of Identity Provider to be linked to the user. | +| Setting | Description | +|------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `SCIM_AUTHENTICATION_MODE` | Authentication mode for SCIM API. Possible values are `KEYCLOAK` and `EXTERNAL`. If not set the server will respond unauthorized for all requests. | +| `SCIM_EXTERNAL_ISSUER` | Issuer for external JWT authentication. Used to validate the JWT token issuer claim. | +| `SCIM_EXTERNAL_AUDIENCE` | Audience for external JWT authentication. Used to validate the JWT token audience claim. | +| `SCIM_EXTERNAL_JWKS_URI` | JWKS URI for external JWT authentication. Used to fetch public keys for JWT token signature validation. | +| `SCIM_EXTERNAL_SHARED_SECRET`| Shared secret in [PHC String Format](https://github.com/P-H-C/phc-string-format/blob/master/phc-sf-spec.md) used for bearer token authentication/validation. | +| `SCIM_BASIC_AUTH_USERNAME` | Username for HTTP Basic authentication. | +| `SCIM_BASIC_AUTH_PASSWORD` | Password hash in [PHC String Format](https://github.com/P-H-C/phc-string-format/blob/master/phc-sf-spec.md) for HTTP Basic authentication. | +| `SCIM_LINK_IDP` | Enables support for linking realm identity provider with user. | +| `SCIM_IDENTITY_PROVIDER_ALIAS`| Alias of Identity Provider to be linked to the user. | -### Configuration on Realm level +### Realm-Level Configuration -The following REST call can be called through the Keycloak Admin API to store the settings under realm attributes. +The following REST call can be made through the Keycloak Admin API to store settings as realm attributes. Realm-level settings override instance-level settings. PUT `/admin/realms/{realm}` -``` +```json { "attributes": { "scim.authentication.mode": "EXTERNAL|KEYCLOAK", @@ -96,203 +121,364 @@ PUT `/admin/realms/{realm}` "scim.external.jwks.uri": "string", "scim.external.audience": "string", "scim.external.shared.secret": "string", - "scim.external.shared.secret.hash.algorithm": "string" + "scim.basic.auth.username": "string", + "scim.basic.auth.password": "string", "scim.link.idp": "true|false", "scim.identity.provider.alias": "string" } } ``` -### Configuration on Organization level +### Organization-Level Configuration + +Configuration on organization level is done by defining organization attributes in the Keycloak server. Only `EXTERNAL` authentication mode is supported at the organization level. + +| Setting | Description | +|------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------| +| `SCIM_AUTHENTICATION_MODE` | Must be `EXTERNAL`. Only external authentication is supported at the organization level. | +| `SCIM_EXTERNAL_ISSUER` | Issuer for external JWT authentication. | +| `SCIM_EXTERNAL_AUDIENCE` | Audience for external JWT authentication. | +| `SCIM_EXTERNAL_JWKS_URI` | JWKS URI for external JWT authentication. | +| `SCIM_EXTERNAL_SHARED_SECRET`| Shared secret in PHC String Format for bearer token authentication. | +| `SCIM_BASIC_AUTH_USERNAME` | Username for HTTP Basic authentication. | +| `SCIM_BASIC_AUTH_PASSWORD` | Password hash in PHC String Format for HTTP Basic authentication. | +| `SCIM_LINK_IDP` | Enables support for linking organization identity provider with user. | +| `SCIM_EMAIL_AS_USERNAME` | Forces server to use email as username instead of actual username. When enabled, username will be unaffected by update operations. Organization-level only. | + +## Authentication + +The SCIM server supports four authentication methods. For `EXTERNAL` mode, the method is determined automatically based on the authorization header and configuration. + +### Keycloak Authentication + +Uses Keycloak's built-in service account authentication. The SCIM client authenticates using an OAuth2 client credentials flow against Keycloak itself, and the resulting access token is validated natively. + +**Required settings:** + +| Setting | Value | +|----------------------------|-------------| +| `SCIM_AUTHENTICATION_MODE` | `KEYCLOAK` | + +**Requirements:** +- A Keycloak client with **Service Accounts Enabled** +- The client's service account must have the `scim-access` realm role + +**Example:** The SCIM client obtains a token via the Keycloak token endpoint and sends it as a bearer token: +``` +Authorization: Bearer +``` + +### External JWT (JWKS) Authentication + +Validates a JWT bearer token issued by an external identity provider. The token signature is verified against public keys fetched from the configured JWKS endpoint, and the issuer and audience claims are validated. + +**Required settings:** + +| Setting | Value | +|----------------------------|--------------------------------------------| +| `SCIM_AUTHENTICATION_MODE` | `EXTERNAL` | +| `SCIM_EXTERNAL_ISSUER` | Expected `iss` claim (e.g., `https://sts.windows.net//`) | +| `SCIM_EXTERNAL_AUDIENCE` | Expected `aud` claim | +| `SCIM_EXTERNAL_JWKS_URI` | URL to the JWKS endpoint for public keys | + +**Example request:** +``` +Authorization: Bearer +``` + +### External Shared Secret (Bearer Token) Authentication + +Validates a static bearer token against a pre-configured hash. The client sends the raw secret as a bearer token, and the server verifies it against the stored hash using Keycloak's password hashing infrastructure. + +**Required settings:** -Configuration on organization level is done by defining organization attributes in the Keycloak server. -The following organization attributes are available: +| Setting | Value | +|------------------------------|------------------------------------------------------| +| `SCIM_AUTHENTICATION_MODE` | `EXTERNAL` | +| `SCIM_EXTERNAL_SHARED_SECRET`| Hashed token in PHC String Format (e.g., Argon2id) | -| Setting | Value | -|--------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| SCIM_AUTHENTICATION_MODE | Authentication mode for SCIM API. Possible values are KEYCLOAK and EXTERNAL. If the value is not set the server will respond unauthorzed for all requests. Currently on organization level only EXTERNAL is supported. | -| SCIM_EXTERNAL_ISSUER | Issuer for the external authentication. This is used to validate the JWT token. | -| SCIM_EXTERNAL_AUDIENCE | Audience for the external authentication. This is used to validate the JWT token. | -| SCIM_EXTERNAL_JWKS_URI | JWKS URI for the external authentication. This is used to validate the JWT token. | -| SCIM_LINK_IDP | Enables support for linking organization identity provider with user. | -| SCIM_EMAIL_AS_USERNAME | Forces server to user email as username instead of actual username. When this setting is enabled username will be unaffected by any update operations. This setting is currently supported only in organization level configuration | -| SCIM_EXTERNAL_SHARED_SECRET | Shared secret value used for request authentication/validation. | -| SCIM_EXTERNAL_SHARED_SECRET_HASH_ALGORITHM | PHC String Format representing hash algorithms and its parameters, used for request authentication/validation ([must be on of the following](https://www.keycloak.org/docs/26.1.5/server_admin/index.html#hashalgorithm)). | +The hash must be in [PHC String Format](https://github.com/P-H-C/phc-string-format/blob/master/phc-sf-spec.md) using a [Keycloak-supported hash algorithm](https://www.keycloak.org/docs/26.1.5/server_admin/index.html#hashalgorithm) (e.g., `argon2id`, `pbkdf2-sha512`). + +**Example:** If the shared secret is `my-secret-token`, hash it using Argon2id and configure the resulting PHC string: +``` +SCIM_EXTERNAL_SHARED_SECRET=$argon2id$v=19$m=16,t=2,p=1$$ +``` -### Azure Entra ID SCIM Configuration +The client then sends the raw secret: +``` +Authorization: Bearer my-secret-token +``` -This extension is compatible with **Microsoft Entra ID** SCIM provisioning. +### External Basic Auth Authentication -#### Keycloak Configuration +Validates credentials sent via HTTP Basic Authentication. The client sends a Base64-encoded `username:password` pair, and the server verifies the username against the configured value and the password against a stored hash. -Before Entra ID can provision users and groups to Keycloak via SCIM, you need to configure SCIM authentication settings. +**Required settings:** -These settings can be applied either: +| Setting | Value | +|----------------------------|------------------------------------------------------| +| `SCIM_AUTHENTICATION_MODE` | `EXTERNAL` | +| `SCIM_BASIC_AUTH_USERNAME` | Expected username | +| `SCIM_BASIC_AUTH_PASSWORD` | Password hash in PHC String Format (e.g., Argon2id) | -* At the realm level (for /realms/{realm}/scim/v2) -* Or at the organization level (for /realms/organizations/scim/v2/organizations/{organizationId}) +The password hash must be in [PHC String Format](https://github.com/P-H-C/phc-string-format/blob/master/phc-sf-spec.md) using a [Keycloak-supported hash algorithm](https://www.keycloak.org/docs/26.1.5/server_admin/index.html#hashalgorithm). -For more details, refer to the sections [Configuration on Realm Level] and [Configuration on Organization Level in this document]. +**Example:** If the username is `scim-admin` and password is `my-password`, hash the password using Argon2id and configure: +``` +SCIM_BASIC_AUTH_USERNAME=scim-admin +SCIM_BASIC_AUTH_PASSWORD=$argon2id$v=19$m=16,t=2,p=1$$ +``` -SCIM Settings for Entra ID +The client then sends: +``` +Authorization: Basic +``` -When using Entra ID settings will be following: +## Vendor Configuration Guides -| Setting | Value | -|--------------------------|------------------------------------------------------------------------------| -| SCIM_AUTHENTICATION_MODE | ```EXTERNAL``` | -| SCIM_EXTERNAL_ISSUER | ```https://sts.windows.net//``` | -| SCIM_EXTERNAL_AUDIENCE | ```8adf8e6e-67b2-4cf2-a259-e3dc5476c621``` | -| SCIM_EXTERNAL_JWKS_URI | ```https://login.microsoftonline.com//discovery/v2.0/keys``` | +### Microsoft Entra ID -Replace with your actual Azure tenant ID. +Entra ID supports two authentication options when provisioning to this SCIM server: -* SCIM_AUTHENTICATION_MODE enables external authentication support for the SCIM server. In this case the external authentication source will be the Azure Entra ID. -* SCIM_EXTERNAL_ISSUER ensures the JWT token was issued by your tenant. -* SCIM_EXTERNAL_AUDIENCE must be exactly 8adf8e6e-67b2-4cf2-a259-e3dc5476c621 — this is the default audience used by Entra ID for non-gallery applications. -* SCIM_EXTERNAL_JWKS_URI allows Keycloak to fetch public keys for token validation. +#### Option A: JWT Authentication (recommended) -OR +Entra ID sends a bearer token signed by Microsoft's identity platform. The SCIM server validates it using Microsoft's JWKS endpoint. -| Setting | Value | -|-----------------------------|----------------------------| -| SCIM_AUTHENTICATION_MODE | ```EXTERNAL``` | -| SCIM_EXTERNAL_SHARED_SECRET | `````` | +**Keycloak settings:** -Replace with your hashed token value (using SHA-512 Hex). +| Setting | Value | +|----------------------------|----------------------------------------------------------------------| +| `SCIM_AUTHENTICATION_MODE` | `EXTERNAL` | +| `SCIM_EXTERNAL_ISSUER` | `https://sts.windows.net//` | +| `SCIM_EXTERNAL_AUDIENCE` | `8adf8e6e-67b2-4cf2-a259-e3dc5476c621` | +| `SCIM_EXTERNAL_JWKS_URI` | `https://login.microsoftonline.com//discovery/v2.0/keys` | -#### Azure Configuration +Replace `` with your Azure tenant ID. The audience `8adf8e6e-67b2-4cf2-a259-e3dc5476c621` is the default used by Entra ID for non-gallery applications. -Step-by-step guide on the Azure: +#### Option B: Shared Secret Authentication -1. Sign in to the [Azure portal](https://portal.azure.com) -2. Go to **Identity → Applications → Enterprise applications** -3. Click **+ New application → + Create your own application** -4. Enter a name for your application (e.g., My Keycloak SCIM). -5. Choose **Integrate any other application you don't find in the gallery.** -6. Click **Create** to create the application. The application will open automatically in its management screen. +Entra ID sends a static bearer token that you generate and configure on both sides. + +**Keycloak settings:** + +| Setting | Value | +|------------------------------|----------------------------| +| `SCIM_AUTHENTICATION_MODE` | `EXTERNAL` | +| `SCIM_EXTERNAL_SHARED_SECRET`| `` | + +Replace `` with the PHC String Format hash of your token. + +#### Azure Portal Setup + +1. Sign in to the [Azure portal](https://portal.azure.com). +2. Go to **Identity > Applications > Enterprise applications**. +3. Click **+ New application > + Create your own application**. +4. Enter a name for your application (e.g., "My Keycloak SCIM"). +5. Choose **Integrate any other application you don't find in the gallery**. +6. Click **Create**. 7. In the application's left-hand menu, select **Provisioning**. 8. Click **+ New configuration**. 9. Fill in the following: - - Tenant URL (realm): https://mykeycloak.example.com/realms/my-realm/scim/v2 or - - Tenant URL (organization): https://mykeycloak.example.com/realms/my-realm/scim/v2/organizations/{organizationId} - - Secret Token: Leave this field empty (the application will use the Entra ID bearer token) OR enter the shared secret value (not hashed). + - **Tenant URL** (realm): `https://mykeycloak.example.com/realms/my-realm/scim/v2` + - **Tenant URL** (organization): `https://mykeycloak.example.com/realms/my-realm/scim/v2/organizations/{organizationId}` + - **Secret Token**: Leave empty for JWT authentication (Option A), or enter the raw shared secret (Option B). 10. Click **Test Connection** to verify the SCIM endpoint. 11. Click **Create**. 12. Navigate to **Attribute Mapping (Preview)**. 13. Open **Provision Microsoft Entra ID Groups**. 14. Set **Enabled** to **No**. 15. Click **Save**. -16. Go back → **open Provision Microsoft Entra ID Users**. -17. Open Provision Microsoft Entra ID Users. -18. Define mappings, following are required for Keycloak extension: -- userName -- active -- emails[type eq "work"].value -- name.givenName -- name.familyName -19. Click Save. -20. Go back to Provisioning. -21. Set Provisioning Status to On. -22. Click Save. -23. Reload the page to ensure the configuration was saved. -24. Navigate to **Manage > Users and groups > + Add user/group**. -25. Select the user you want to provision and click Assign. -26. Navigate to **Provision on demand**. -27. Find the user you just assigned. -28. Click on the user and select **Provision**. -29. Verify that the provisioning completes successfully. - -For more information, refer to the following documents: - -https://learn.microsoft.com/en-us/entra/identity/saas-apps/tutorial-list - -#### Identity Provider Linking with Azure Entra ID +16. Go back and open **Provision Microsoft Entra ID Users**. +17. Define mappings. The following are required: + - `userName` + - `active` + - `emails[type eq "work"].value` + - `name.givenName` + - `name.familyName` +18. Click **Save**. +19. Go back to **Provisioning**. +20. Set **Provisioning Status** to **On**. +21. Click **Save**. +22. Reload the page to ensure the configuration was saved. +23. Navigate to **Manage > Users and groups > + Add user/group**. +24. Select the user you want to provision and click **Assign**. +25. Navigate to **Provision on demand**. +26. Find the user you just assigned. +27. Click on the user and select **Provision**. +28. Verify that the provisioning completes successfully. + +For more information, refer to the [Microsoft Entra ID SCIM provisioning documentation](https://learn.microsoft.com/en-us/entra/identity/saas-apps/tutorial-list). + +### Okta + +Okta supports three authentication methods when provisioning to a SCIM server. All three are supported by this extension. + +For general Okta SCIM setup, refer to the [Okta SCIM provisioning documentation](https://help.okta.com/en-us/content/topics/apps/apps_app_integration_wizard_scim.htm). + +#### Option A: HTTP Header (Bearer Token) + +Okta sends a static bearer token in the `Authorization` header. This uses the shared secret authentication method. + +**Keycloak settings:** + +| Setting | Value | +|------------------------------|------------------------------------------| +| `SCIM_AUTHENTICATION_MODE` | `EXTERNAL` | +| `SCIM_EXTERNAL_SHARED_SECRET`| PHC String Format hash of your API token | + +**Okta setup:** +1. In the Okta Admin Console, go to **Applications > Applications**. +2. Select your SCIM application. +3. Go to the **Provisioning** tab and click **Configure API Integration**. +4. Select **HTTP Header** as the authentication mode. +5. In the **Authorization** field, paste the raw API token (the unhashed value). +6. Set the **SCIM connector base URL** to: `https://mykeycloak.example.com/realms/my-realm/scim/v2` +7. Click **Test API Credentials** to verify. +8. Click **Save**. + +#### Option B: Basic Auth + +Okta sends credentials via HTTP Basic Authentication (Base64-encoded `username:password`). + +**Keycloak settings:** + +| Setting | Value | +|----------------------------|-------------------------------------------------| +| `SCIM_AUTHENTICATION_MODE` | `EXTERNAL` | +| `SCIM_BASIC_AUTH_USERNAME` | The username you want Okta to authenticate with | +| `SCIM_BASIC_AUTH_PASSWORD` | PHC String Format hash of the password | + +**Okta setup:** +1. In the Okta Admin Console, go to **Applications > Applications**. +2. Select your SCIM application. +3. Go to the **Provisioning** tab and click **Configure API Integration**. +4. Select **Basic Auth** as the authentication mode. +5. Enter the **Username** and **Password** (the raw, unhashed values). +6. Set the **SCIM connector base URL** to: `https://mykeycloak.example.com/realms/my-realm/scim/v2` +7. Click **Test API Credentials** to verify. +8. Click **Save**. + +#### Option C: OAuth2 + +Okta obtains an access token from an OAuth2 token endpoint, then sends it as a bearer token. Since Keycloak is itself an OAuth2 provider, you can point Okta at Keycloak's token endpoint. The resulting JWT is then validated by the SCIM server using the JWKS authentication method. + +**Keycloak prerequisites:** +1. Create a Keycloak client with **Client Authentication** enabled (confidential client). +2. Enable **Service Accounts Enabled** on the client. +3. Note the client ID and client secret. + +**Keycloak settings:** + +| Setting | Value | +|----------------------------|------------------------------------------------------------------------------------| +| `SCIM_AUTHENTICATION_MODE` | `EXTERNAL` | +| `SCIM_EXTERNAL_ISSUER` | `https://mykeycloak.example.com/realms/my-realm` | +| `SCIM_EXTERNAL_AUDIENCE` | The client ID of the Keycloak client, or `account` | +| `SCIM_EXTERNAL_JWKS_URI` | `https://mykeycloak.example.com/realms/my-realm/protocol/openid-connect/certs` | + +**Okta setup:** +1. In the Okta Admin Console, go to **Applications > Applications**. +2. Select your SCIM application. +3. Go to the **Provisioning** tab and click **Configure API Integration**. +4. Select **OAuth2** as the authentication mode. +5. Configure the following: + - **Access Token Endpoint**: `https://mykeycloak.example.com/realms/my-realm/protocol/openid-connect/token` + - **Client ID**: The Keycloak client ID + - **Client Secret**: The Keycloak client secret +6. Set the **SCIM connector base URL** to: `https://mykeycloak.example.com/realms/my-realm/scim/v2` +7. Click **Test API Credentials** to verify. +8. Click **Save**. + +## Identity Provider Linking + +### Identity Provider Linking with Azure Entra ID Identity Provider linking with Entra ID requires a few additional configuration steps on both the Entra and Keycloak sides. **Step 1: Add externalId** -In the Keycloak admin console, ensure that you have externalId attribute defined in your Realm Settings > User Profile. This attribute is used to store user's external id in the Keycloak side and without it the Identity Provider linking will fail. +In the Keycloak admin console, ensure that you have an `externalId` attribute defined in your **Realm Settings > User Profile**. This attribute is used to store the user's external ID in Keycloak and without it the Identity Provider linking will fail. **Step 2: Map externalId in SCIM provisioning** -In the Entra Id, make sure that the objectId from Entra ID is mapped into the SCIM externalId field: +In Entra ID, make sure that the `objectId` from Entra ID is mapped into the SCIM `externalId` field: -1. Navigate to your **Enterprise Application** > **Provisioning** > **Attribute Mapping (Preview)** > **Provision Microsoft Entra ID Users**. +1. Navigate to your **Enterprise Application > Provisioning > Attribute Mapping (Preview) > Provision Microsoft Entra ID Users**. 2. Click **Add New Mapping**. 3. Set: - - **Source attribute**: objectId - - **Target attribute**: externalId + - **Source attribute**: `objectId` + - **Target attribute**: `externalId` 4. Click **Save**. -This ensures that during SCIM provisioning, the Entra objectId is stored in Keycloak as the user’s externalId, which will later be used for identity linking. +This ensures that during SCIM provisioning, the Entra `objectId` is stored in Keycloak as the user's `externalId`, which will later be used for identity linking. **Step 3: Configure Keycloak Identity Provider to Use Object ID** -Next, configure your Entra ID Identity Provider in Keycloak to use the oid claim from the login token instead of the default sub claim (which is app-specific). +Configure your Entra ID Identity Provider in Keycloak to use the `oid` claim from the login token instead of the default `sub` claim (which is app-specific). 1. Navigate to **Identity Providers** > select your **Entra ID provider**. -2. Go to the **Mappers tab**. +2. Go to the **Mappers** tab. 3. Click **Add Mapper**. 4. Fill in the mapper details: - - **Name**: map_oid_as_brokerid (or any descriptive name) + - **Name**: `map_oid_as_brokerid` (or any descriptive name) - **Sync Mode**: Force - **Mapper Type**: Username Template Importer - - **Template**: ${CLAIM.oid} + - **Template**: `${CLAIM.oid}` - **Target**: BROKER_ID 5. Click **Save**. -This mapper tells Keycloak to use the Entra oid claim as the Broker ID, ensuring that the login user is matched correctly with the SCIM-provisioned user. +This mapper tells Keycloak to use the Entra `oid` claim as the Broker ID, ensuring that the login user is matched correctly with the SCIM-provisioned user. **Step 4: Enable Identity Provider Linking in SCIM** -Finally, instruct your SCIM server to automatically link users to the configured Identity Provider during provisioning: - -Add the following attribute to your SCIM configuration: +Add the following settings to your SCIM configuration: - SCIM_LINK_IDP=true +``` +SCIM_LINK_IDP=true +``` -In case you want to link user to a realm level identity provider, also add the following attribute: +If you want to link users to a realm-level identity provider, also add: - SCIM_IDENTITY_PROVIDER_ALIAS= +``` +SCIM_IDENTITY_PROVIDER_ALIAS= +``` -This will ensure that when a user is provisioned via SCIM, a corresponding Identity Provider link is also created automatically based on the externalId / oid. +This ensures that when a user is provisioned via SCIM, a corresponding Identity Provider link is created automatically based on the `externalId` / `oid`. ## SCIM-Managed Users -By default, the SCIM server only exposes users who are explicitly assigned the scim-managed role within the realm. This ensures that only users intended to be managed through SCIM are returned or modifiable via SCIM API operations. +By default, the SCIM server only exposes users who are explicitly assigned the `scim-managed` role within the realm. This ensures that only users intended to be managed through SCIM are returned or modifiable via SCIM API operations. This prevents accidental exposure or modification of users that were: - - created manually via the Keycloak admin UI - - imported from external identity providers - - or otherwise not intended to be managed through SCIM +- created manually via the Keycloak admin UI +- imported from external identity providers +- or otherwise not intended to be managed through SCIM -If you want to expose all users (i.e., bypass filtering), you can simply assign the scim-managed role to every user. This effectively disables the filter, making the SCIM behavior equivalent to an unfiltered list. +If you want to expose all users (i.e., bypass filtering), you can simply assign the `scim-managed` role to every user. This effectively disables the filter, making the SCIM behavior equivalent to an unfiltered list. This role-based filtering applies to all SCIM operations, including: - - GET /Users - - PATCH /Users/{id} - - DELETE /Users/{id} +- `GET /Users` +- `PATCH /Users/{id}` +- `DELETE /Users/{id}` -Users without the scim-managed role will be invisible to SCIM clients — they won’t be listed, updated, or removed through SCIM. +Users without the `scim-managed` role will be invisible to SCIM clients -- they won't be listed, updated, or removed through SCIM. This filtering mechanism is designed to improve safety, especially in complex deployments involving federated users, legacy accounts, or overlapping identity sources (such as Entra ID + local users). -This design does mean that provisioning a user through SCIM who previously existed without the role may cause conflicts or provisioning failures if role assignment isn’t handled correctly. However, this is a deliberate design choice to provide fine-grained control over which users are SCIM-visible. +This design does mean that provisioning a user through SCIM who previously existed without the role may cause conflicts or provisioning failures if role assignment isn't handled correctly. However, this is a deliberate design choice to provide fine-grained control over which users are SCIM-visible. ## License [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0) - + --- diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/AbstractScimServer.java b/src/main/java/fi/metatavu/keycloak/scim/server/AbstractScimServer.java index 61311b6..3666a88 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/AbstractScimServer.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/AbstractScimServer.java @@ -7,6 +7,7 @@ import fi.metatavu.keycloak.scim.server.groups.GroupsController; import fi.metatavu.keycloak.scim.server.metadata.MetadataController; import fi.metatavu.keycloak.scim.server.users.UsersController; +import java.util.Base64; import jakarta.mail.internet.AddressException; import jakarta.mail.internet.InternetAddress; import jakarta.ws.rs.ForbiddenException; @@ -105,8 +106,36 @@ public void verifyPermissions(T scimContext) { if (config.getAuthenticationMode() == ScimConfig.AuthenticationMode.KEYCLOAK) { keycloakAuthentication(context, session, realm, headers); + } else if (authorization.startsWith("Basic ")) { + basicAuthentication(config, authorization, session); } else { - externalAuthentication(config, extractToken(authorization), session); + externalAuthentication(config, extractBearerToken(authorization), session); + } + } + + private void basicAuthentication(ScimConfig config, String authorization, KeycloakSession session) { + String basicAuthUsername = config.getBasicAuthUsername(); + String basicAuthPassword = config.getBasicAuthPassword(); + + if (basicAuthUsername == null || basicAuthUsername.isBlank() || basicAuthPassword == null || basicAuthPassword.isBlank()) { + logger.warn("Basic auth credentials received but Basic auth is not configured"); + throw new NotAuthorizedException("Basic auth is not configured"); + } + + String encoded = authorization.substring("Basic ".length()).trim(); + String decoded; + try { + decoded = new String(Base64.getDecoder().decode(encoded)); + } catch (IllegalArgumentException e) { + logger.warn("Invalid Base64 in Basic auth header"); + throw new NotAuthorizedException("Invalid Basic auth header"); + } + + Verifier verifier = VerifierFactory.buildBasicAuth(config, session); + + if (!verifier.verify(decoded)) { + logger.warn("Basic auth verification failed"); + throw new NotAuthorizedException("Basic auth verification failed"); } } @@ -153,7 +182,7 @@ private void keycloakAuthentication(KeycloakContext context, KeycloakSession ses } } - private String extractToken(String authorization) { + private String extractBearerToken(String authorization) { if (authorization.startsWith("Bearer ")) { return authorization.substring("Bearer ".length()).trim(); } else { diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/authentication/BasicAuthVerifier.java b/src/main/java/fi/metatavu/keycloak/scim/server/authentication/BasicAuthVerifier.java new file mode 100644 index 0000000..241ce14 --- /dev/null +++ b/src/main/java/fi/metatavu/keycloak/scim/server/authentication/BasicAuthVerifier.java @@ -0,0 +1,85 @@ +package fi.metatavu.keycloak.scim.server.authentication; + +import java.util.List; +import java.util.Optional; +import org.jboss.logging.Logger; +import org.keycloak.common.util.MultivaluedHashMap; +import org.keycloak.credential.hash.PasswordHashProvider; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.credential.PasswordCredentialModel; + +/** + * Verifies Basic Auth credentials (username and password) + */ +public class BasicAuthVerifier implements Verifier { + + private static final Logger logger = Logger.getLogger(BasicAuthVerifier.class); + + private final KeycloakSession session; + private final String expectedUsername; + private final String hashedPassword; + + /** + * Constructor + * + * @param session Keycloak Session + * @param expectedUsername expected username + * @param hashedPassword password hash in PHC String format + */ + public BasicAuthVerifier(KeycloakSession session, String expectedUsername, String hashedPassword) { + this.session = session; + this.expectedUsername = expectedUsername; + this.hashedPassword = hashedPassword; + } + + /** + * Verifies the given credentials. + * + * @param credentials username:password string (already Base64-decoded) + * @return true if the credentials are valid, false otherwise + */ + @Override + public boolean verify(String credentials) { + int colonIndex = credentials.indexOf(':'); + if (colonIndex < 0) { + logger.warn("Basic auth credentials missing colon separator"); + return false; + } + + String username = credentials.substring(0, colonIndex); + String password = credentials.substring(colonIndex + 1); + + if (!expectedUsername.equals(username)) { + logger.warn("Basic auth username mismatch"); + return false; + } + + if (hashedPassword == null || hashedPassword.isBlank()) { + logger.warn("Basic auth password hash is null or blank"); + return false; + } + + PasswordCredentialModel model = PhcStringUtils.fromPHCString(hashedPassword); + String algorithm = model.getPasswordCredentialData().getAlgorithm(); + MultivaluedHashMap additionalParameters = model.getPasswordCredentialData() + .getAdditionalParameters(); + String type = Optional.ofNullable(additionalParameters) + .map(params -> params.get("type")) + .filter(typeList -> !typeList.isEmpty()) + .map(List::getFirst) + .orElse(""); + PasswordHashProvider hashProvider = session.getProvider(PasswordHashProvider.class, algorithm.replace(type, "")); + + if (hashProvider == null) { + throw new RuntimeException( + String.format( + "Hash provider not found with hash algorithm: %s. Only official Keycloak hash algorithms are expected (see README.md).", + algorithm + ) + ); + } + + return hashProvider.verify(password, model); + } + +} diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/authentication/VerifierFactory.java b/src/main/java/fi/metatavu/keycloak/scim/server/authentication/VerifierFactory.java index ff26aea..b370830 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/authentication/VerifierFactory.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/authentication/VerifierFactory.java @@ -15,7 +15,7 @@ public class VerifierFactory { private VerifierFactory() {} /** - * Builds the verifier based on the ScimConfig + * Builds a verifier for Bearer token authentication based on the ScimConfig */ public static Verifier build(ScimConfig config, KeycloakSession session) { if (config.getAuthenticationMode() != AuthenticationMode.EXTERNAL) { @@ -31,4 +31,14 @@ public static Verifier build(ScimConfig config, KeycloakSession session) { return new ExternalSharedSecretVerifier(session, sharedSecret); } } + + /** + * Builds a verifier for Basic Auth authentication based on the ScimConfig + */ + public static Verifier buildBasicAuth(ScimConfig config, KeycloakSession session) { + if (config.getAuthenticationMode() != AuthenticationMode.EXTERNAL) { + throw new IllegalArgumentException("Authentication mode must be EXTERNAL"); + } + return new BasicAuthVerifier(session, config.getBasicAuthUsername(), config.getBasicAuthPassword()); + } } diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/config/ScimConfig.java b/src/main/java/fi/metatavu/keycloak/scim/server/config/ScimConfig.java index 7765be2..e7ae5d3 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/config/ScimConfig.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/config/ScimConfig.java @@ -75,4 +75,18 @@ enum AuthenticationMode { * @return true if email should be used as username */ boolean getEmailAsUsername(); + + /** + * Gets the basic auth username (if using EXTERNAL mode with Basic auth) + * + * @return basic auth username or null if not configured + */ + String getBasicAuthUsername(); + + /** + * Gets the basic auth password in PHC String format (if using EXTERNAL mode with Basic auth) + * + * @return basic auth password hash or null if not configured + */ + String getBasicAuthPassword(); } diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimConfig.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimConfig.java index e5bc036..a5a717c 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimConfig.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimConfig.java @@ -22,6 +22,8 @@ public class OrganizationScimConfig implements ScimConfig { public static final String SCIM_EXTERNAL_ISSUER = "SCIM_EXTERNAL_ISSUER"; public static final String SCIM_AUTHENTICATION_MODE = "SCIM_AUTHENTICATION_MODE"; public static final String SCIM_EMAIL_AS_USERNAME = "SCIM_EMAIL_AS_USERNAME"; + public static final String SCIM_BASIC_AUTH_USERNAME = "SCIM_BASIC_AUTH_USERNAME"; + public static final String SCIM_BASIC_AUTH_PASSWORD = "SCIM_BASIC_AUTH_PASSWORD"; private final OrganizationModel organization; @@ -40,9 +42,20 @@ public void validateConfig() throws ConfigurationError { logger.debugf("Organization SCIM authentication mode: %s", mode); boolean isSharedSecretPresent = getSharedSecret() != null && !getSharedSecret().isBlank(); + boolean isBasicAuthUsernamePresent = getBasicAuthUsername() != null && !getBasicAuthUsername().isBlank(); + boolean isBasicAuthPasswordPresent = getBasicAuthPassword() != null && !getBasicAuthPassword().isBlank(); if (mode == AuthenticationMode.EXTERNAL) { - if (!isSharedSecretPresent) { + if (isBasicAuthUsernamePresent || isBasicAuthPasswordPresent) { + if (!isBasicAuthUsernamePresent) { + logger.warnf("Organization SCIM config invalid: %s is not set", SCIM_BASIC_AUTH_USERNAME); + throw new ConfigurationError(SCIM_BASIC_AUTH_USERNAME + " must be set when " + SCIM_BASIC_AUTH_PASSWORD + " is set"); + } + if (!isBasicAuthPasswordPresent) { + logger.warnf("Organization SCIM config invalid: %s is not set", SCIM_BASIC_AUTH_PASSWORD); + throw new ConfigurationError(SCIM_BASIC_AUTH_PASSWORD + " must be set when " + SCIM_BASIC_AUTH_USERNAME + " is set"); + } + } else if (!isSharedSecretPresent) { if (getExternalIssuer() == null) { logger.warnf("Organization SCIM config invalid: %s is not set", SCIM_EXTERNAL_ISSUER); throw new ConfigurationError(SCIM_EXTERNAL_ISSUER + " is not set"); @@ -115,6 +128,16 @@ public boolean getEmailAsUsername() { return "true".equalsIgnoreCase(getAttribute(SCIM_EMAIL_AS_USERNAME)); } + @Override + public String getBasicAuthUsername() { + return getAttribute(SCIM_BASIC_AUTH_USERNAME); + } + + @Override + public String getBasicAuthPassword() { + return getAttribute(SCIM_BASIC_AUTH_PASSWORD); + } + /** * Gets the organization attribute * diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimConfig.java b/src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimConfig.java index efa2330..515a6f2 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimConfig.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimConfig.java @@ -24,6 +24,8 @@ public class RealmScimConfig implements ScimConfig { public static final String SCIM_LINK_IDP = "scim.link.idp"; public static final String SCIM_IDENTITY_PROVIDER_ALIAS = "scim.identity.provider.alias"; public static final String SCIM_EMAIL_AS_USERNAME = "scim.email.as.username"; + public static final String SCIM_BASIC_AUTH_USERNAME = "scim.basic.auth.username"; + public static final String SCIM_BASIC_AUTH_PASSWORD = "scim.basic.auth.password"; private final Config config; private final RealmModel realm; @@ -48,21 +50,34 @@ public void validateConfig() throws ConfigurationError { logger.debugf("Realm SCIM authentication mode: %s", mode); boolean isSharedSecretPresent = getSharedSecret() != null && !getSharedSecret().isBlank(); - - if (mode == AuthenticationMode.EXTERNAL && !isSharedSecretPresent) { - if (getExternalIssuer() == null) { - logger.warn("Realm SCIM config invalid: SCIM_EXTERNAL_ISSUER is not set"); - throw new ConfigurationError("SCIM_EXTERNAL_ISSUER is not set"); - } - - if (getExternalJwksUri() == null) { - logger.warn("Realm SCIM config invalid: SCIM_EXTERNAL_JWKS_URI is not set"); - throw new ConfigurationError("SCIM_EXTERNAL_JWKS_URI is not set"); - } - - if (getExternalAudience() == null) { - logger.warn("Realm SCIM config invalid: SCIM_EXTERNAL_AUDIENCE is not set"); - throw new ConfigurationError("SCIM_EXTERNAL_AUDIENCE is not set"); + boolean isBasicAuthUsernamePresent = getBasicAuthUsername() != null && !getBasicAuthUsername().isBlank(); + boolean isBasicAuthPasswordPresent = getBasicAuthPassword() != null && !getBasicAuthPassword().isBlank(); + + if (mode == AuthenticationMode.EXTERNAL) { + if (isBasicAuthUsernamePresent || isBasicAuthPasswordPresent) { + if (!isBasicAuthUsernamePresent) { + logger.warn("Realm SCIM config invalid: SCIM_BASIC_AUTH_USERNAME is not set"); + throw new ConfigurationError("SCIM_BASIC_AUTH_USERNAME must be set when SCIM_BASIC_AUTH_PASSWORD is set"); + } + if (!isBasicAuthPasswordPresent) { + logger.warn("Realm SCIM config invalid: SCIM_BASIC_AUTH_PASSWORD is not set"); + throw new ConfigurationError("SCIM_BASIC_AUTH_PASSWORD must be set when SCIM_BASIC_AUTH_USERNAME is set"); + } + } else if (!isSharedSecretPresent) { + if (getExternalIssuer() == null) { + logger.warn("Realm SCIM config invalid: SCIM_EXTERNAL_ISSUER is not set"); + throw new ConfigurationError("SCIM_EXTERNAL_ISSUER is not set"); + } + + if (getExternalJwksUri() == null) { + logger.warn("Realm SCIM config invalid: SCIM_EXTERNAL_JWKS_URI is not set"); + throw new ConfigurationError("SCIM_EXTERNAL_JWKS_URI is not set"); + } + + if (getExternalAudience() == null) { + logger.warn("Realm SCIM config invalid: SCIM_EXTERNAL_AUDIENCE is not set"); + throw new ConfigurationError("SCIM_EXTERNAL_AUDIENCE is not set"); + } } } @@ -154,6 +169,20 @@ public boolean getEmailAsUsername() { .orElse(false); } + @Override + public String getBasicAuthUsername() { + return readRealmAttribute(SCIM_BASIC_AUTH_USERNAME) + .or(() -> config.getOptionalValue(SCIM_BASIC_AUTH_USERNAME, String.class)) + .orElse(null); + } + + @Override + public String getBasicAuthPassword() { + return readRealmAttribute(SCIM_BASIC_AUTH_PASSWORD) + .or(() -> config.getOptionalValue(SCIM_BASIC_AUTH_PASSWORD, String.class)) + .orElse(null); + } + /** * Helper method to read the first string from a realm attribute. */ diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/ScimClient.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/ScimClient.java index bc8711d..6504fc7 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/ScimClient.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/ScimClient.java @@ -8,6 +8,7 @@ import fi.metatavu.keycloak.scim.server.test.client.model.*; import java.net.URI; +import java.util.Base64; /** * SCIM client @@ -15,10 +16,10 @@ public class ScimClient { private final URI scimUri; - private final String accessToken; + private final String authorizationHeader; /** - * Constructor + * Constructor for Bearer token authentication * * @param scimUri SCIM URI * @param accessToken access token @@ -28,7 +29,23 @@ public ScimClient( String accessToken ) { this.scimUri = scimUri; - this.accessToken = accessToken; + this.authorizationHeader = "Bearer " + accessToken; + } + + /** + * Constructor for Basic authentication + * + * @param scimUri SCIM URI + * @param username username + * @param password password + */ + public ScimClient( + URI scimUri, + String username, + String password + ) { + this.scimUri = scimUri; + this.authorizationHeader = "Basic " + Base64.getEncoder().encodeToString((username + ":" + password).getBytes()); } /** @@ -246,7 +263,7 @@ private ApiClient getApiClient() { result.setHost(scimUri.getHost()); result.setScheme(scimUri.getScheme()); result.setPort(scimUri.getPort()); - result.setRequestInterceptor(builder -> builder.header("Authorization", "Bearer " + accessToken)); + result.setRequestInterceptor(builder -> builder.header("Authorization", authorizationHeader)); return result; } } \ No newline at end of file diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmBasicAuthTestsIT.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmBasicAuthTestsIT.java new file mode 100644 index 0000000..a96faa4 --- /dev/null +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmBasicAuthTestsIT.java @@ -0,0 +1,56 @@ +package fi.metatavu.keycloak.scim.server.test.tests.functional; + +import dasniko.testcontainers.keycloak.KeycloakContainer; +import fi.metatavu.keycloak.scim.server.test.ScimClient; +import fi.metatavu.keycloak.scim.server.test.client.ApiException; +import fi.metatavu.keycloak.scim.server.test.tests.AbstractRealmScimTest; +import fi.metatavu.keycloak.scim.server.test.utils.KeycloakTestUtils; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +@Testcontainers +public class RealmBasicAuthTestsIT extends AbstractRealmScimTest { + + @Container + protected static final KeycloakContainer keycloakContainer = KeycloakTestUtils.createBasicAuthRealmKeycloakContainer(network); + + @Override + protected KeycloakContainer getKeycloakContainer() { + return keycloakContainer; + } + + @AfterAll + static void tearDown() { + KeycloakTestUtils.stopKeycloakContainer(keycloakContainer); + } + + @Test + void testGetResourceTypesWithBasicAuth() throws ApiException { + ScimClient scimClient = new ScimClient(getScimUri(), "scim-admin", "tutu"); + scimClient.getResourceTypes(); + } + + @Test + void testErrorGetResourceTypesWithWrongPassword() { + assertThrows( + ApiException.class, () -> { + ScimClient scimClient = new ScimClient(getScimUri(), "scim-admin", "wrong-password"); + scimClient.getResourceTypes(); + } + ); + } + + @Test + void testErrorGetResourceTypesWithWrongUsername() { + assertThrows( + ApiException.class, () -> { + ScimClient scimClient = new ScimClient(getScimUri(), "wrong-user", "tutu"); + scimClient.getResourceTypes(); + } + ); + } +} diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/utils/KeycloakTestUtils.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/utils/KeycloakTestUtils.java index 3313b03..ad2ae57 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/utils/KeycloakTestUtils.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/utils/KeycloakTestUtils.java @@ -138,6 +138,24 @@ public static KeycloakContainer createExternalAuthSharedSecretRealmKeycloakConta .withLogConsumer(outputFrame -> System.out.printf("KEYCLOAK: %s", outputFrame.getUtf8String())); } + @SuppressWarnings("resource") + public static KeycloakContainer createBasicAuthRealmKeycloakContainer(Network network) { + return new KeycloakContainer(KeycloakTestUtils.getKeycloakImage()) + .withNetwork(network) + .withNetworkAliases("scim-keycloak") + .withEnv("SCIM_AUTHENTICATION_MODE", "EXTERNAL") + .withEnv("SCIM_BASIC_AUTH_USERNAME", "scim-admin") + .withEnv("SCIM_BASIC_AUTH_PASSWORD", "$argon2id$v=19$m=16,t=2,p=1$UUppcFAwQUp0SkQwVGZudQ$j5RwfEzt3Gvwpbqp0VDcJg") // tutu with argon2id + .withProviderLibsFrom(KeycloakTestUtils.getBuildProviders()) + .withRealmImportFile("kc-test.json") + .withEnv("JAVA_OPTS_APPEND", "-javaagent:/jacoco-agent/org.jacoco.agent-runtime.jar=destfile=/tmp/jacoco.exec") + .withCopyFileToContainer( + MountableFile.forHostPath(getJacocoAgentPath()), + "/jacoco-agent/org.jacoco.agent-runtime.jar" + ) + .withLogConsumer(outputFrame -> System.out.printf("KEYCLOAK: %s", outputFrame.getUtf8String())); + } + @SuppressWarnings("resource") public static KeycloakContainer createNoAuthRealmKeycloakContainer(Network network) { return new KeycloakContainer(KeycloakTestUtils.getKeycloakImage()) From 865decb121a0bc1e1bba709e4a7b179cfdda8678 Mon Sep 17 00:00:00 2001 From: "Mercedes.Segura" Date: Sun, 19 Apr 2026 20:38:27 +0200 Subject: [PATCH 16/35] fix wrong merge --- .../keycloak/scim/server/config/ScimConfig.java | 7 +------ .../server/organization/OrganizationScimConfig.java | 4 ---- .../keycloak/scim/server/realm/RealmScimConfig.java | 12 ------------ 3 files changed, 1 insertion(+), 22 deletions(-) diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/config/ScimConfig.java b/src/main/java/fi/metatavu/keycloak/scim/server/config/ScimConfig.java index e4b9fca..d6ac248 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/config/ScimConfig.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/config/ScimConfig.java @@ -75,13 +75,8 @@ enum AuthenticationMode { * @return true if email should be used as username */ boolean getEmailAsUsername(); - + /** - * Returns the identity provider alias - * - * @return identity provider alias or null if not configured - */ - String getIdentityProviderAlias(); * Gets the basic auth username (if using EXTERNAL mode with Basic auth) * * @return basic auth username or null if not configured diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimConfig.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimConfig.java index 080248c..a5a717c 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimConfig.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimConfig.java @@ -128,10 +128,6 @@ public boolean getEmailAsUsername() { return "true".equalsIgnoreCase(getAttribute(SCIM_EMAIL_AS_USERNAME)); } - // Organization SCIM configuration does not support identity provider alias, so we return empty string - @Override - public String getIdentityProviderAlias() { - return ""; @Override public String getBasicAuthUsername() { return getAttribute(SCIM_BASIC_AUTH_USERNAME); diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimConfig.java b/src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimConfig.java index ea0b6fb..a5697ce 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimConfig.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimConfig.java @@ -24,7 +24,6 @@ public class RealmScimConfig implements ScimConfig { public static final String SCIM_IDENTITY_PROVIDER_ALIAS = "scim.identity.provider.alias"; public static final String SCIM_LINK_IDP = "scim.link.idp"; - public static final String SCIM_IDENTITY_PROVIDER_ALIAS = "scim.identity.provider.alias"; public static final String SCIM_EMAIL_AS_USERNAME = "scim.email.as.username"; public static final String SCIM_BASIC_AUTH_USERNAME = "scim.basic.auth.username"; public static final String SCIM_BASIC_AUTH_PASSWORD = "scim.basic.auth.password"; @@ -185,17 +184,6 @@ public String getBasicAuthPassword() { .orElse(null); } - /** - * Returns the configured identity provider alias . - */ - @Override - public String getIdentityProviderAlias() { - return readRealmAttribute(SCIM_IDENTITY_PROVIDER_ALIAS) - .or(() -> config.getOptionalValue(SCIM_IDENTITY_PROVIDER_ALIAS, String.class)) - .orElse(null); - } - - /** * Helper method to read the first string from a realm attribute. */ From 6eddda37756ebac3706156cb6bbde8ef8145a821 Mon Sep 17 00:00:00 2001 From: Garth <244253+xgp@users.noreply.github.com> Date: Thu, 23 Apr 2026 13:52:44 +0200 Subject: [PATCH 17/35] address PR review: lazy provider init, remove organizationType and redundant session storage - Remove organizationType from ScimResources, ScimRealmResourceProvider, and ScimRealmResourceProviderFactory. The default OrganizationScimServerProvider is resolved via session.getProvider() without a provider ID. Implementors can override using KC_SPI_ORGANIZATION_SCIM_SERVER_PROVIDER__PROVIDER_DEFAULT. - Lazy-initialize OrganizationScimServer in ScimResources to avoid failing all SCIM endpoints (including realm-level) when the org provider is missing or misconfigured. Includes null guard and try/catch with warning. - Remove redundant session field from KeycloakOrganizationScimServerProvider (was stored but never read, since createScimContext is static). - Remove redundant session field from OrganizationScimServer (methods use scimContext.getSession() instead). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../server/ScimRealmResourceProvider.java | 10 +- .../ScimRealmResourceProviderFactory.java | 9 +- .../keycloak/scim/server/ScimResources.java | 127 ++++++++++-------- .../organization/OrganizationScimServer.java | 6 +- ...eycloakOrganizationScimServerProvider.java | 12 +- ...OrganizationScimServerProviderFactory.java | 2 +- 6 files changed, 82 insertions(+), 84 deletions(-) diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/ScimRealmResourceProvider.java b/src/main/java/fi/metatavu/keycloak/scim/server/ScimRealmResourceProvider.java index 97b6180..60d64fe 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/ScimRealmResourceProvider.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/ScimRealmResourceProvider.java @@ -9,16 +9,14 @@ public class ScimRealmResourceProvider implements RealmResourceProvider { private final KeycloakSession session; - private final String organizationType; - - public ScimRealmResourceProvider(KeycloakSession session, String organizationType) { + + public ScimRealmResourceProvider(KeycloakSession session) { this.session = session; - this.organizationType = organizationType; } - + @Override public Object getResource() { - return new ScimResources(session, organizationType); + return new ScimResources(session); } @Override diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/ScimRealmResourceProviderFactory.java b/src/main/java/fi/metatavu/keycloak/scim/server/ScimRealmResourceProviderFactory.java index 1ff5625..d904ecf 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/ScimRealmResourceProviderFactory.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/ScimRealmResourceProviderFactory.java @@ -1,6 +1,5 @@ package fi.metatavu.keycloak.scim.server; -import com.google.common.base.Strings; import org.keycloak.Config; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; @@ -17,19 +16,13 @@ public class ScimRealmResourceProviderFactory implements RealmResourceProviderFa private static final Logger logger = Logger.getLogger(ScimRealmResourceProviderFactory.class); - private String organizationType = "default"; - @Override public RealmResourceProvider create(KeycloakSession session) { - return new ScimRealmResourceProvider(session, organizationType); + return new ScimRealmResourceProvider(session); } @Override public void init(Config.Scope config) { - // allows overriding the default organization type with a custom implementation (e.g. `phasetwo`) - String orgTypeConfig = config.get("organizationType"); - if (!Strings.isNullOrEmpty(orgTypeConfig)) organizationType = orgTypeConfig; - logger.infof("Initializing SCIM resource with **%s** org type.", organizationType); } @Override diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/ScimResources.java b/src/main/java/fi/metatavu/keycloak/scim/server/ScimResources.java index 2acffaa..fffa0f5 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/ScimResources.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/ScimResources.java @@ -22,12 +22,31 @@ public class ScimResources { private static final Logger logger = Logger.getLogger(ScimResources.class.getName()); private final ScimFilterParser scimFilterParser; private final RealmScimServer realmScimServer; - private final OrganizationScimServer organizationScimServer; + private final KeycloakSession session; + private OrganizationScimServer organizationScimServer; - ScimResources(KeycloakSession session, String organizationType) { + ScimResources(KeycloakSession session) { + this.session = session; scimFilterParser = new ScimFilterParser(); realmScimServer = new RealmScimServer(); - organizationScimServer = session.getProvider(OrganizationScimServerProvider.class, organizationType).getScimServer(session); + } + + private OrganizationScimServer getOrganizationScimServer() { + if (organizationScimServer == null) { + try { + OrganizationScimServerProvider provider = session.getProvider(OrganizationScimServerProvider.class); + if (provider == null) { + throw new NotFoundException("No OrganizationScimServerProvider is registered. Organization SCIM endpoints are not available."); + } + organizationScimServer = provider.getScimServer(session); + } catch (NotFoundException e) { + throw e; + } catch (Exception e) { + logger.warn("Failed to load OrganizationScimServerProvider. Organization SCIM endpoints will not be available.", e); + throw new NotFoundException("Organization SCIM endpoints are not available."); + } + } + return organizationScimServer; } // Realm Server endpoints @@ -376,10 +395,10 @@ public Response createOrganizationUser( fi.metatavu.keycloak.scim.server.model.User createRequest ) { logger.debugf("POST /v2/organizations/%s/Users", organizationId); - OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); - organizationScimServer.verifyPermissions(scimContext); + OrganizationScimContext scimContext = getOrganizationScimServer().getScimContext(session, organizationId); + getOrganizationScimServer().verifyPermissions(scimContext); - return organizationScimServer.createUser( + return getOrganizationScimServer().createUser( scimContext, createRequest ); @@ -397,8 +416,8 @@ public Response listOrganizationUsers( @QueryParam("count") @DefaultValue("100") Integer count ) { logger.debugf("GET /v2/organizations/%s/Users filter=%s startIndex=%d count=%d", organizationId, filter, startIndex, count); - OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); - organizationScimServer.verifyPermissions(scimContext); + OrganizationScimContext scimContext = getOrganizationScimServer().getScimContext(session, organizationId); + getOrganizationScimServer().verifyPermissions(scimContext); ScimFilter scimFilter; try { @@ -408,7 +427,7 @@ public Response listOrganizationUsers( return Response.status(Response.Status.BAD_REQUEST).entity("Invalid filter").build(); } - return organizationScimServer.listUsers( + return getOrganizationScimServer().listUsers( scimContext, scimFilter, startIndex, @@ -426,10 +445,10 @@ public Response findOrganizationUser( @PathParam("organizationId") String organizationId ) { logger.debugf("GET /v2/organizations/%s/Users/%s", organizationId, userId); - OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); - organizationScimServer.verifyPermissions(scimContext); + OrganizationScimContext scimContext = getOrganizationScimServer().getScimContext(session, organizationId); + getOrganizationScimServer().verifyPermissions(scimContext); - return organizationScimServer.findUser( + return getOrganizationScimServer().findUser( scimContext, userId ); @@ -447,10 +466,10 @@ public Response updateOrganizationUser( fi.metatavu.keycloak.scim.server.model.User updateRequest ) { logger.debugf("PUT /v2/organizations/%s/Users/%s", organizationId, userId); - OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); - organizationScimServer.verifyPermissions(scimContext); + OrganizationScimContext scimContext = getOrganizationScimServer().getScimContext(session, organizationId); + getOrganizationScimServer().verifyPermissions(scimContext); - return organizationScimServer.updateUser( + return getOrganizationScimServer().updateUser( scimContext, userId, updateRequest @@ -469,10 +488,10 @@ public Response patchOrganizationUser( fi.metatavu.keycloak.scim.server.model.PatchRequest patchRequest ) { logger.debugf("PATCH /v2/organizations/%s/Users/%s", organizationId, userId); - OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); - organizationScimServer.verifyPermissions(scimContext); + OrganizationScimContext scimContext = getOrganizationScimServer().getScimContext(session, organizationId); + getOrganizationScimServer().verifyPermissions(scimContext); - return organizationScimServer.patchUser( + return getOrganizationScimServer().patchUser( scimContext, userId, patchRequest @@ -489,10 +508,10 @@ public Response deleteOrganizationUser( @PathParam("id") String userId ) { logger.debugf("DELETE /v2/organizations/%s/Users/%s", organizationId, userId); - OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); - organizationScimServer.verifyPermissions(scimContext); + OrganizationScimContext scimContext = getOrganizationScimServer().getScimContext(session, organizationId); + getOrganizationScimServer().verifyPermissions(scimContext); - return organizationScimServer.deleteUser(scimContext, userId); + return getOrganizationScimServer().deleteUser(scimContext, userId); } @POST @@ -506,10 +525,10 @@ public Response createOrganizationGroup( fi.metatavu.keycloak.scim.server.model.Group createRequest ) { logger.debugf("POST /v2/organizations/%s/Groups", organizationId); - OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); - organizationScimServer.verifyPermissions(scimContext); + OrganizationScimContext scimContext = getOrganizationScimServer().getScimContext(session, organizationId); + getOrganizationScimServer().verifyPermissions(scimContext); - return organizationScimServer.createGroup( + return getOrganizationScimServer().createGroup( scimContext, createRequest ); @@ -527,8 +546,8 @@ public Response listOrganizationGroups( @QueryParam("count") @DefaultValue("100") int count ) { logger.debugf("GET /v2/organizations/%s/Groups filter=%s startIndex=%d count=%d", organizationId, filter, startIndex, count); - OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); - organizationScimServer.verifyPermissions(scimContext); + OrganizationScimContext scimContext = getOrganizationScimServer().getScimContext(session, organizationId); + getOrganizationScimServer().verifyPermissions(scimContext); ScimFilter scimFilter; try { @@ -538,7 +557,7 @@ public Response listOrganizationGroups( return Response.status(Response.Status.BAD_REQUEST).entity("Invalid filter").build(); } - return organizationScimServer.listGroups( + return getOrganizationScimServer().listGroups( scimContext, scimFilter, startIndex, @@ -556,10 +575,10 @@ public Response findOrganizationGroup( @PathParam("id") String id ) { logger.debugf("GET /v2/organizations/%s/Groups/%s", organizationId, id); - OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); - organizationScimServer.verifyPermissions(scimContext); + OrganizationScimContext scimContext = getOrganizationScimServer().getScimContext(session, organizationId); + getOrganizationScimServer().verifyPermissions(scimContext); - return organizationScimServer.findGroup( + return getOrganizationScimServer().findGroup( scimContext, id ); @@ -577,10 +596,10 @@ public Response updateOrganizationGroup( Group updateRequest ) { logger.debugf("PUT /v2/organizations/%s/Groups/%s", organizationId, id); - OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); - organizationScimServer.verifyPermissions(scimContext); + OrganizationScimContext scimContext = getOrganizationScimServer().getScimContext(session, organizationId); + getOrganizationScimServer().verifyPermissions(scimContext); - return organizationScimServer.updateGroup( + return getOrganizationScimServer().updateGroup( scimContext, id, updateRequest @@ -599,10 +618,10 @@ public Response patchOrganizationGroup( fi.metatavu.keycloak.scim.server.model.PatchRequest patchRequest ) { logger.debugf("PATCH /v2/organizations/%s/Groups/%s", organizationId, groupId); - OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); - organizationScimServer.verifyPermissions(scimContext); + OrganizationScimContext scimContext = getOrganizationScimServer().getScimContext(session, organizationId); + getOrganizationScimServer().verifyPermissions(scimContext); - return organizationScimServer.patchGroup( + return getOrganizationScimServer().patchGroup( scimContext, groupId, patchRequest @@ -618,10 +637,10 @@ public Response deleteOrganizationGroup( @PathParam("id") String id ) { logger.debugf("DELETE /v2/organizations/%s/Groups/%s", organizationId, id); - OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); - organizationScimServer.verifyPermissions(scimContext); + OrganizationScimContext scimContext = getOrganizationScimServer().getScimContext(session, organizationId); + getOrganizationScimServer().verifyPermissions(scimContext); - return organizationScimServer.deleteGroup( + return getOrganizationScimServer().deleteGroup( scimContext, id ); @@ -637,10 +656,10 @@ public Response listOrganizationResourceTypes( @PathParam("organizationId") String organizationId ) { logger.debugf("GET /v2/organizations/%s/ResourceTypes", organizationId); - OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); - organizationScimServer.verifyPermissions(scimContext); + OrganizationScimContext scimContext = getOrganizationScimServer().getScimContext(session, organizationId); + getOrganizationScimServer().verifyPermissions(scimContext); - return organizationScimServer.listResourceTypes( + return getOrganizationScimServer().listResourceTypes( scimContext ); } @@ -655,10 +674,10 @@ public Response findOrganizationResourceType( @PathParam("id") String id ) { logger.debugf("GET /v2/organizations/%s/ResourceTypes/%s", organizationId, id); - OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); - organizationScimServer.verifyPermissions(scimContext); + OrganizationScimContext scimContext = getOrganizationScimServer().getScimContext(session, organizationId); + getOrganizationScimServer().verifyPermissions(scimContext); - return organizationScimServer.findResourceType( + return getOrganizationScimServer().findResourceType( scimContext, id ); @@ -674,10 +693,10 @@ public Response listOrganizationSchemas( @Context UriInfo uriInfo ) { logger.debugf("GET /v2/organizations/%s/Schemas", organizationId); - OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); - organizationScimServer.verifyPermissions(scimContext); + OrganizationScimContext scimContext = getOrganizationScimServer().getScimContext(session, organizationId); + getOrganizationScimServer().verifyPermissions(scimContext); - return organizationScimServer.listSchemas( + return getOrganizationScimServer().listSchemas( scimContext ); } @@ -692,10 +711,10 @@ public Response findOrganizationSchema( @PathParam("id") String id ) { logger.debugf("GET /v2/organizations/%s/Schemas/%s", organizationId, id); - OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); - organizationScimServer.verifyPermissions(scimContext); + OrganizationScimContext scimContext = getOrganizationScimServer().getScimContext(session, organizationId); + getOrganizationScimServer().verifyPermissions(scimContext); - return organizationScimServer.findSchema( + return getOrganizationScimServer().findSchema( scimContext, id ); @@ -711,9 +730,9 @@ public Response getOrganizationServiceProviderConfig( @Context UriInfo uriInfo ) { logger.debugf("GET /v2/organizations/%s/ServiceProviderConfig", organizationId); - OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); - organizationScimServer.verifyPermissions(scimContext); - return organizationScimServer.getServiceProviderConfig(scimContext); + OrganizationScimContext scimContext = getOrganizationScimServer().getScimContext(session, organizationId); + getOrganizationScimServer().verifyPermissions(scimContext); + return getOrganizationScimServer().getServiceProviderConfig(scimContext); } /** diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServer.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServer.java index 7cc75f7..6865fa0 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServer.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServer.java @@ -25,10 +25,8 @@ public abstract class OrganizationScimServer extends AbstractScimServer Date: Thu, 23 Apr 2026 21:06:17 +0100 Subject: [PATCH 18/35] Handle path-less PatchOp shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 7644 §3.5.2 allows PATCH operations to omit 'path' and carry the attribute changes inside a map-valued 'value'. Okta's Deactivate User flow uses this exact shape: {"op":"replace","value":{"active":false}} UsersController.patchUser previously called findByScimPath(null) on this shape, got null back, and threw UnsupportedUserPath, returning HTTP 500 without applying any state change. This change handles the path-less shape by iterating the map-valued 'value' and applying each entry as a separate attribute update, sharing the write logic with the with-path branch via a new private applyPatchValue helper. Test: RealmUserPatchTestsIT#testDeactivateUserPathLessPatchOp covers both deactivate and reactivate through the path-less shape. Fixes #95 --- .../scim/server/users/UsersController.java | 87 +++++++++++++------ .../functional/RealmUserPatchTestsIT.java | 54 ++++++++++++ 2 files changed, 116 insertions(+), 25 deletions(-) diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/users/UsersController.java b/src/main/java/fi/metatavu/keycloak/scim/server/users/UsersController.java index 24c7311..d2c93b0 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/users/UsersController.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/users/UsersController.java @@ -302,36 +302,32 @@ public fi.metatavu.keycloak.scim.server.model.User patchUser( throw new UnsupportedPatchOperation("Unsupported patch operation: " + operation.getOp()); } - UserAttribute userAttribute = userAttributes.findByScimPath(operation.getPath()); + String path = operation.getPath(); Object value = operation.getValue(); - if (userAttribute == null) { - throw new UnsupportedUserPath("Unsupported attribute: " + operation.getPath()); - } - - switch (op) { - case REPLACE, ADD -> { - switch (value) { - case null: - logger.warn("Value is null for patch operation: " + op); - break; - case String s when userAttribute instanceof StringUserAttribute: - ((StringUserAttribute) userAttribute).write(existing, s); - break; - case String s when userAttribute instanceof BooleanUserAttribute: - ((BooleanUserAttribute) userAttribute).write(existing, Boolean.parseBoolean(s)); - break; - case Boolean b when userAttribute instanceof BooleanUserAttribute: - ((BooleanUserAttribute) userAttribute).write(existing, b); - break; - default: - logger.warn("Unsupported value type for patch operation: " + value.getClass() + " for SCIM path " + userAttribute.getScimPath()); - break; + // RFC 7644 §3.5.2: when "path" is omitted, "value" carries a map of + // attribute -> value to apply to the resource. Okta's Deactivate User + // emits this shape: {"op":"replace","value":{"active":false}}. + if (path == null) { + if (!(value instanceof Map valueMap)) { + throw new UnsupportedUserPath("PatchOp without 'path' requires a map-valued 'value'"); + } + for (Map.Entry entry : valueMap.entrySet()) { + String attrPath = String.valueOf(entry.getKey()); + UserAttribute ua = userAttributes.findByScimPath(attrPath); + if (ua == null) { + throw new UnsupportedUserPath("Unsupported attribute: " + attrPath); } - + applyPatchValue(op, ua, existing, entry.getValue()); } - case REMOVE -> userAttribute.write(existing, null); + continue; } + + UserAttribute userAttribute = userAttributes.findByScimPath(path); + if (userAttribute == null) { + throw new UnsupportedUserPath("Unsupported attribute: " + path); + } + applyPatchValue(op, userAttribute, existing, value); } dispatchUserUpdateEvent(scimContext, existing); @@ -351,6 +347,47 @@ public fi.metatavu.keycloak.scim.server.model.User patchUser( return patchedUser; } + /** + * Apply a single PATCH operation (REPLACE/ADD/REMOVE) against one + * resolved user attribute. Extracted so the path-less PatchOp shape + * (RFC 7644 §3.5.2, map-valued "value") and the with-path shape share + * the same write semantics. + * + * @param op patch operation kind + * @param attr resolved user attribute target + * @param existing user being patched + * @param value raw operation value + */ + private void applyPatchValue( + PatchOperation op, + UserAttribute attr, + UserModel existing, + Object value + ) { + switch (op) { + case REPLACE, ADD -> { + switch (value) { + case null: + logger.warn("Value is null for patch operation: " + op); + break; + case String s when attr instanceof StringUserAttribute: + ((StringUserAttribute) attr).write(existing, s); + break; + case String s when attr instanceof BooleanUserAttribute: + ((BooleanUserAttribute) attr).write(existing, Boolean.parseBoolean(s)); + break; + case Boolean b when attr instanceof BooleanUserAttribute: + ((BooleanUserAttribute) attr).write(existing, b); + break; + default: + logger.warn("Unsupported value type for patch operation: " + value.getClass() + " for SCIM path " + attr.getScimPath()); + break; + } + } + case REMOVE -> attr.write(existing, null); + } + } + /** * Dispatches user create event * diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserPatchTestsIT.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserPatchTestsIT.java index a4d4b8f..c2c4303 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserPatchTestsIT.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserPatchTestsIT.java @@ -15,6 +15,7 @@ import java.io.IOException; import java.util.List; +import java.util.Map; import static org.junit.jupiter.api.Assertions.*; @@ -189,4 +190,57 @@ void testPatchUserAdminEvents() throws ApiException, IOException { deleteRealmUser(TestConsts.TEST_REALM, created.getId()); } + /** + * Okta's Deactivate User action emits a PATCH without a top-level "path", + * carrying the attribute change inside a map-valued "value" (RFC 7644 + * §3.5.2). This test covers that shape; the other tests only cover the + * with-path form. + */ + @Test + void testDeactivateUserPathLessPatchOp() throws ApiException { + ScimClient scimClient = getAuthenticatedScimClient(); + + // Create an active user + User user = new User(); + user.setUserName("patch-pathless-user"); + user.setActive(true); + user.setSchemas(List.of("urn:ietf:params:scim:schemas:core:2.0:User")); + + User created = scimClient.createUser(user); + assertNotNull(created); + assertTrue(created.getActive()); + + // Okta shape: no "path", value is a map {"active": false} + User deactivated = scimClient.patchUser(created.getId(), new PatchRequest() + .schemas(List.of("urn:ietf:params:scim:api:messages:2.0:PatchOp")) + .operations(List.of( + new PatchRequestOperationsInner() + .op("replace") + .value(Map.of("active", Boolean.FALSE)) + ))); + + assertNotNull(deactivated); + assertNotNull(deactivated.getActive()); + assertFalse(deactivated.getActive()); + + UserRepresentation deactivatedRealmUser = findRealmUser(TestConsts.TEST_REALM, created.getId()); + assertNotNull(deactivatedRealmUser); + assertFalse(deactivatedRealmUser.isEnabled()); + + // Re-activate via the same shape to confirm the code path is symmetric + User activated = scimClient.patchUser(created.getId(), new PatchRequest() + .schemas(List.of("urn:ietf:params:scim:api:messages:2.0:PatchOp")) + .operations(List.of( + new PatchRequestOperationsInner() + .op("replace") + .value(Map.of("active", Boolean.TRUE)) + ))); + + assertNotNull(activated); + assertTrue(activated.getActive()); + + // Cleanup + deleteRealmUser(TestConsts.TEST_REALM, created.getId()); + } + } \ No newline at end of file From 8852d3347bf39409692e0a4541a439b7192e76fb Mon Sep 17 00:00:00 2001 From: "Mercedes.Segura" Date: Wed, 29 Apr 2026 07:46:31 +0200 Subject: [PATCH 19/35] fix: cleanup after wrong merge --- README.md | 14 ++------------ .../scim/server/test/utils/KeycloakTestUtils.java | 1 - 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 7988571..30d60f3 100644 --- a/README.md +++ b/README.md @@ -96,16 +96,6 @@ All settings can be applied at three levels. Settings at a more specific level o Configuration on instance level is done by defining environment variables on the Keycloak server. -| Setting | Value | -|--------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| SCIM_AUTHENTICATION_MODE | Authentication mode for SCIM API. Possible values are KEYCLOAK and EXTERNAL. If the value is not set the server will respond unauthorzed for all requests. | -| SCIM_EXTERNAL_ISSUER | Issuer for the external authentication. This is used to validate the JWT token. | -| SCIM_EXTERNAL_AUDIENCE | JWKS URI for the external authentication. This is used to validate the JWT token. | -| SCIM_EXTERNAL_JWKS_URI | Audience for the external authentication. This is used to validate the JWT token. | -| SCIM_EXTERNAL_SHARED_SECRET | Shared secret value used for request authentication/validation. | -| SCIM_EXTERNAL_SHARED_SECRET_HASH_ALGORITHM | PHC String Format representing hash algorithms and its parameters, used for request authentication/validation ([must be on of the following](https://www.keycloak.org/docs/26.1.5/server_admin/index.html#hashalgorithm)). | -| SCIM_IDENTITY_PROVIDER_ALIAS | Alias of Identity Provider to be linked to the user. | -### Configuration on Realm level | Setting | Description | |------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `SCIM_AUTHENTICATION_MODE` | Authentication mode for SCIM API. Possible values are `KEYCLOAK` and `EXTERNAL`. If not set the server will respond unauthorized for all requests. | @@ -118,7 +108,7 @@ Configuration on instance level is done by defining environment variables on the | `SCIM_LINK_IDP` | Enables support for linking realm identity provider with user. | | `SCIM_IDENTITY_PROVIDER_ALIAS`| Alias of Identity Provider to be linked to the user. | -### Realm-Level Configuration +### Configuration on Realm level The following REST call can be made through the Keycloak Admin API to store settings as realm attributes. Realm-level settings override instance-level settings. @@ -140,7 +130,7 @@ PUT `/admin/realms/{realm}` } ``` -### Organization-Level Configuration +### Configuration on Organization level Configuration on organization level is done by defining organization attributes in the Keycloak server. Only `EXTERNAL` authentication mode is supported at the organization level. diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/utils/KeycloakTestUtils.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/utils/KeycloakTestUtils.java index a5c8402..ad2ae57 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/utils/KeycloakTestUtils.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/utils/KeycloakTestUtils.java @@ -90,7 +90,6 @@ public static KeycloakContainer createInternalAuthRealmKeycloakContainer(Network .withNetwork(network) .withNetworkAliases("scim-keycloak") .withEnv("SCIM_AUTHENTICATION_MODE", "KEYCLOAK") - .withEnv("SCIM_IDENTITY_PROVIDER_ALIAS", "keycloak-oidc") .withEnv("SCIM_IDENTITY_PROVIDER_ALIAS", TestConsts.TEST_IDP) .withEnv("SCIM_LINK_IDP", "true") .withProviderLibsFrom(KeycloakTestUtils.getBuildProviders()) From 199ce36a714d9b7d649e829c305ceadff444fdf4 Mon Sep 17 00:00:00 2001 From: Danilo Acquaviva Date: Tue, 12 May 2026 14:17:44 +0100 Subject: [PATCH 20/35] Handle path-less PatchOp on Groups + return valid JSON errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three closely-related fixes that together let Okta-driven SCIM Group Push and User provisioning round-trip through the realm and organization-scoped endpoints. 1. Path-less PatchOp on Groups. RFC 7644 §3.5.2 says that when "path" is omitted on a PatchOp, "value" carries a map of attribute -> value to apply to the resource. Okta's Group Push (add / remove members) emits this shape: {"op":"replace","value":{"members":[{"value":""}]}} The previous code called findByScimPath(null), got null, and threw UnsupportedGroupPath, breaking every Okta Group Push. A path-less branch in patchGroup expands the map into one logical operation per entry. 2. Valid JSON error bodies on every patch / create / update / delete path. Per RFC 7644 §3.12 a SCIM Service Provider error response is a JSON object with schemas, status, and detail. Several handlers were returning plain-text strings, which Okta surfaces in its System Log as a malformed JSON parsing error and which other SCIM clients reject before showing the operator anything useful. A new ScimErrors helper centralises the envelope and is wired into the Groups, Users, OrganizationUser, RealmScimServer, and OrganizationScimServer entry points. 3. Silently ignore read-only / structural attrs on PATCH. Per RFC 7644 §3.5.2 and the SCIM core schema (RFC 7643 §3.1), servers MUST not error on read-only / structural attributes appearing in PATCH payloads. Okta and other clients echo "id", "meta", and "schemas" inside the PatchOp value when it is constructed from a prior GET. isReadOnlyOrStructural lives on AbstractController and is consulted in both path-less and path-based branches before attribute resolution. externalId is intentionally NOT in the list per RFC 7643 §3.1 (client-settable). Test assertions in *ListTestsIT switched from assertEquals on the exact pre-change plain-text error string to assertTrue substring on "Invalid filter", so they remain correct regardless of whether the body is plain text or JSON. --- .../scim/server/AbstractController.java | 30 +++++ .../keycloak/scim/server/ScimErrors.java | 64 +++++++++++ .../keycloak/scim/server/ScimResources.java | 8 +- .../scim/server/groups/GroupsController.java | 105 ++++++++++++++++++ .../organization/OrganizationScimServer.java | 21 ++-- .../OrganizationUserController.java | 85 ++++++++++---- .../scim/server/realm/RealmScimServer.java | 36 +++--- .../scim/server/users/UsersController.java | 10 ++ .../OrganizationUserListTestsIT.java | 12 +- .../functional/RealmGroupListTestsIT.java | 9 +- .../functional/RealmUserListTestsIT.java | 12 +- 11 files changed, 328 insertions(+), 64 deletions(-) create mode 100644 src/main/java/fi/metatavu/keycloak/scim/server/ScimErrors.java diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/AbstractController.java b/src/main/java/fi/metatavu/keycloak/scim/server/AbstractController.java index 9a36923..2ed10d1 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/AbstractController.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/AbstractController.java @@ -33,6 +33,36 @@ protected fi.metatavu.keycloak.scim.server.model.Meta getMeta( return result; } + /** + * Whether the given SCIM attribute path refers to a read-only or + * structural core attribute that PATCH must ignore per RFC 7644 §3.5.2 + * (and the SCIM core schema, RFC 7643 §3.1). + * + * Concretely: "id" (server-assigned resource identifier), "meta" + * (server-assigned metadata), and "schemas" (structural). Servers MUST + * not error on these in PATCH payloads; clients (notably Okta on Group + * Push and user provisioning) echo them back when a PATCH value is + * constructed from a prior GET. Resource controllers consult this + * before resolving an attribute and silently skip the operation when + * it matches. + * + * Note: "externalId" is intentionally NOT in this list. Per RFC 7643 + * §3.1 externalId is an OPTIONAL, client-settable attribute and a + * legitimate target of PATCH. + * + * @param attrPath attribute path from a PatchOp (path-less or path-based) + * @return true if the attribute is read-only / structural and must be ignored + */ + protected static boolean isReadOnlyOrStructural(String attrPath) { + if (attrPath == null) { + return false; + } + return switch (attrPath) { + case "id", "meta", "schemas" -> true; + default -> false; + }; + } + /** * Returns date based on year, month and date * diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/ScimErrors.java b/src/main/java/fi/metatavu/keycloak/scim/server/ScimErrors.java new file mode 100644 index 0000000..cc53dd0 --- /dev/null +++ b/src/main/java/fi/metatavu/keycloak/scim/server/ScimErrors.java @@ -0,0 +1,64 @@ +package fi.metatavu.keycloak.scim.server; + +import jakarta.ws.rs.core.Response; + +/** + * Helper for building SCIM 2.0 Error responses (RFC 7644 §3.12). + * + * Existing call sites returned plain-text bodies (e.g. "Unsupported group path", + * "Missing userName"), which broke clients that strictly parse error responses + * as JSON (Okta, Entra ID). All error responses go through this helper now and + * return a valid SCIM Error JSON document with the application/scim+json media + * type. + */ +public final class ScimErrors { + + private ScimErrors() { + // utility class + } + + /** + * Build a SCIM 2.0 Error response. + * + * @param status HTTP status (e.g. BAD_REQUEST) + * @param detail human-readable error detail + * @return Response carrying a SCIM Error JSON body and application/scim+json type + */ + public static Response error(Response.Status status, String detail) { + String safeDetail = detail == null + ? "" + : detail.replace("\\", "\\\\").replace("\"", "\\\""); + String body = "{\"schemas\":[\"urn:ietf:params:scim:api:messages:2.0:Error\"]" + + ",\"status\":\"" + status.getStatusCode() + "\"" + + ",\"detail\":\"" + safeDetail + "\"}"; + return Response.status(status).type("application/scim+json").entity(body).build(); + } + + /** + * Convenience for HTTP 400 errors. + */ + public static Response badRequest(String detail) { + return error(Response.Status.BAD_REQUEST, detail); + } + + /** + * Convenience for HTTP 404 errors. + */ + public static Response notFound(String detail) { + return error(Response.Status.NOT_FOUND, detail); + } + + /** + * Convenience for HTTP 409 errors. + */ + public static Response conflict(String detail) { + return error(Response.Status.CONFLICT, detail); + } + + /** + * Convenience for HTTP 403 errors. + */ + public static Response forbidden(String detail) { + return error(Response.Status.FORBIDDEN, detail); + } +} diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/ScimResources.java b/src/main/java/fi/metatavu/keycloak/scim/server/ScimResources.java index fffa0f5..da24c89 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/ScimResources.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/ScimResources.java @@ -89,7 +89,7 @@ public Response listRealmUsers( scimFilter = parseFilter(filter); } catch (Exception e) { logger.warn(String.format("Failed to parse filter: '%s'", filter), e); - return Response.status(Response.Status.BAD_REQUEST).entity("Invalid filter").build(); + return ScimErrors.badRequest("Invalid filter"); } return realmScimServer.listUsers( @@ -213,7 +213,7 @@ public Response listRealmGroups( scimFilter = parseFilter(filter); } catch (Exception e) { logger.warn(String.format("Failed to parse filter: '%s'", filter), e); - return Response.status(Response.Status.BAD_REQUEST).entity("Invalid filter").build(); + return ScimErrors.badRequest("Invalid filter"); } return realmScimServer.listGroups( @@ -424,7 +424,7 @@ public Response listOrganizationUsers( scimFilter = parseFilter(filter); } catch (Exception e) { logger.warn(String.format("Failed to parse filter: '%s'", filter), e); - return Response.status(Response.Status.BAD_REQUEST).entity("Invalid filter").build(); + return ScimErrors.badRequest("Invalid filter"); } return getOrganizationScimServer().listUsers( @@ -554,7 +554,7 @@ public Response listOrganizationGroups( scimFilter = parseFilter(filter); } catch (Exception e) { logger.warn(String.format("Failed to parse filter: '%s'", filter), e); - return Response.status(Response.Status.BAD_REQUEST).entity("Invalid filter").build(); + return ScimErrors.badRequest("Invalid filter"); } return getOrganizationScimServer().listGroups( diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/groups/GroupsController.java b/src/main/java/fi/metatavu/keycloak/scim/server/groups/GroupsController.java index 96adb9c..610c891 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/groups/GroupsController.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/groups/GroupsController.java @@ -171,11 +171,43 @@ public fi.metatavu.keycloak.scim.server.model.Group patchGroup( throw new UnsupportedPatchOperation("Unsupported patch operation: " + operation.getOp()); } + // RFC 7644 §3.5.2: when "path" is omitted, "value" carries a map of + // attribute -> value to apply to the resource. Okta's Group Push + // (add/remove members) emits this shape: + // {"op":"replace","value":{"members":[{"value":""}]}} + // Without this branch the code below would call findByScimPath(null), + // get null, and throw UnsupportedGroupPath, breaking Okta group pushes. + if (path == null) { + if (!(value instanceof Map valueMap)) { + throw new UnsupportedGroupPath("PatchOp without 'path' requires a map-valued 'value'"); + } + for (Map.Entry entry : valueMap.entrySet()) { + String attrPath = String.valueOf(entry.getKey()); + if (isReadOnlyOrStructural(attrPath)) { + // RFC 7644 §3.5.2 / §7.5: ignore read-only and + // structural attributes (id, meta, schemas) + // on PATCH. Okta echoes the resource id back inside + // 'value' on Group Push. + continue; + } + GroupAttribute attr = GroupAttribute.findByScimPath(attrPath); + if (attr == null) { + throw new UnsupportedGroupPath("Unsupported attribute: " + attrPath); + } + applyGroupPatch(scimContext, op, attr, attrPath, entry.getValue(), existing); + } + continue; + } + // Extract base attribute path (e.g., "members" from "members[value eq \"id\"]") String attributePath = path != null && path.contains("[") ? path.substring(0, path.indexOf("[")) : path; + if (isReadOnlyOrStructural(attributePath)) { + continue; + } + GroupAttribute groupAttribute = GroupAttribute.findByScimPath(attributePath); if (groupAttribute == null) { throw new UnsupportedGroupPath("Unsupported patch path: " + path); @@ -258,6 +290,79 @@ public fi.metatavu.keycloak.scim.server.model.Group patchGroup( return translateGroup(scimContext, existing); } + /** + * Apply a single attribute patch to the given group. + * + * Used by the path-less PatchOp branch of {@link #patchGroup} to expand + * each entry of the map-valued 'value' into one logical operation. + */ + private void applyGroupPatch( + ScimContext scimContext, + PatchOperation op, + GroupAttribute attr, + String attrPath, + Object value, + GroupModel existing + ) { + KeycloakSession session = scimContext.getSession(); + RealmModel realm = scimContext.getRealm(); + + switch (op) { + case REPLACE, ADD -> { + switch (attr) { + case DISPLAY_NAME -> { + if (value instanceof String s) { + existing.setName(s); + } + } + case MEMBERS -> { + if (op == PatchOperation.REPLACE) { + session.users().getGroupMembersStream(realm, existing) + .forEach(user -> user.leaveGroup(existing)); + } + if (value instanceof List list) { + for (Object obj : list) { + if (!(obj instanceof Map memberMap)) { + continue; + } + String memberId = (String) memberMap.get("value"); + if (memberId == null) { + continue; + } + UserModel user = session.users().getUserById(realm, memberId); + if (user != null) { + user.joinGroup(existing); + dispatchGroupMembershipJoinEvent(scimContext, existing, user); + } + } + } + } + } + } + case REMOVE -> { + switch (attr) { + case DISPLAY_NAME -> existing.setName(null); + case MEMBERS -> { + if (value instanceof List list) { + for (Object obj : list) { + if (obj instanceof Map memberMap) { + String memberId = (String) memberMap.get("value"); + if (memberId != null) { + UserModel user = session.users().getUserById(realm, memberId); + if (user != null) { + user.leaveGroup(existing); + dispatchGroupMembershipLeaveEvent(scimContext, existing, user); + } + } + } + } + } + } + } + } + } + } + /** * Deletes a group * diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServer.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServer.java index 6865fa0..7b67682 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServer.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServer.java @@ -1,6 +1,7 @@ package fi.metatavu.keycloak.scim.server.organization; import fi.metatavu.keycloak.scim.server.AbstractScimServer; +import fi.metatavu.keycloak.scim.server.ScimErrors; import fi.metatavu.keycloak.scim.server.config.ConfigurationError; import fi.metatavu.keycloak.scim.server.filter.ScimFilter; import fi.metatavu.keycloak.scim.server.jacoco.ExcludeFromJacocoGeneratedReport; @@ -36,12 +37,12 @@ public Response createUser(OrganizationScimContext scimContext, User createReque if (isBlank(createRequest.getUserName())) { logger.warn("Cannot create user: Missing userName"); - return Response.status(Response.Status.BAD_REQUEST).entity("Missing userName").build(); + return ScimErrors.badRequest("Missing userName"); } if (emailAsUsername && !isValidEmail(createRequest.getUserName())) { logger.warn("Cannot create user: Invalid email format for userName"); - return Response.status(Response.Status.BAD_REQUEST).entity("Invalid email format for userName").build(); + return ScimErrors.badRequest("Invalid email format for userName"); } UserAttributes userAttributes = metadataController.getUserAttributes(scimContext); @@ -68,19 +69,19 @@ public Response updateUser(OrganizationScimContext scimContext, String userId, f if (isBlank(username)) { logger.warn("Missing userName"); - return Response.status(Response.Status.BAD_REQUEST).entity("Missing userName").build(); + return ScimErrors.badRequest("Missing userName"); } if (emailAsUsername && !isValidEmail(updateRequest.getUserName())) { logger.warn("Cannot update user: Invalid email format for userName"); - return Response.status(Response.Status.BAD_REQUEST).entity("Invalid email format for userName").build(); + return ScimErrors.badRequest("Invalid email format for userName"); } if (emailAsUsername && updateRequest.getEmails() != null) { for (fi.metatavu.keycloak.scim.server.model.UserEmailsInner email : updateRequest.getEmails()) { if (!Objects.equals(email.getValue(), updateRequest.getUserName())) { logger.warn("Conflicting email and userName when emailAsUsername is enabled"); - return Response.status(Response.Status.BAD_REQUEST).entity("Username and email must match when emailAsUsername is enabled").build(); + return ScimErrors.badRequest("Username and email must match when emailAsUsername is enabled"); } } } @@ -89,7 +90,7 @@ public Response updateUser(OrganizationScimContext scimContext, String userId, f UserModel user = session.users().getUserById(realm, userId); if (user == null) { logger.warn(String.format("User not found: %s", userId)); - return Response.status(Response.Status.NOT_FOUND).entity("User not found").build(); + return ScimErrors.notFound("User not found"); } // Check if username is being changed to an already existing one @@ -102,7 +103,7 @@ public Response updateUser(OrganizationScimContext scimContext, String userId, f if (existing != null && !existing.getId().equals(userId)) { logger.warn(String.format("User name already taken: %s", updateRequest.getUserName())); - return Response.status(Response.Status.CONFLICT).entity("User name already taken").build(); + return ScimErrors.conflict("User name already taken"); } UserAttributes userAttributes = metadataController.getUserAttributes(scimContext); @@ -119,7 +120,7 @@ public Response patchUser(OrganizationScimContext scimContext, String userId, fi UserModel existing = session.users().getUserById(realm, userId); if (existing == null) { logger.warn(String.format("User not found: %s", userId)); - return Response.status(Response.Status.NOT_FOUND).entity("User not found").build(); + return ScimErrors.notFound("User not found"); } UserAttributes userAttributes = metadataController.getUserAttributes(scimContext); @@ -128,7 +129,7 @@ public Response patchUser(OrganizationScimContext scimContext, String userId, fi fi.metatavu.keycloak.scim.server.model.User result = organizationUserController.patchOrganizationUser(scimContext, userAttributes, existing, patchRequest); return Response.ok(result).build(); } catch (UnsupportedPatchOperation e) { - return Response.status(Response.Status.BAD_REQUEST).entity("Unsupported patch operation").build(); + return ScimErrors.badRequest("Unsupported patch operation"); } } @@ -173,7 +174,7 @@ public Response deleteUser(OrganizationScimContext scimContext, String userId) { RoleModel scimManagedRole = realm.getRole("scim-managed"); if (scimManagedRole != null && !user.hasRole(scimManagedRole)) { logger.warn(String.format("User is not SCIM-managed: %s", userId)); - return Response.status(Response.Status.FORBIDDEN).entity("User is not managed by SCIM").build(); + return ScimErrors.forbidden("User is not managed by SCIM"); } organizationUserController.deleteOrganizationUser(scimContext, user); diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationUserController.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationUserController.java index 3c3a131..e1b2dd7 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationUserController.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationUserController.java @@ -214,36 +214,44 @@ public fi.metatavu.keycloak.scim.server.model.User patchOrganizationUser( throw new UnsupportedPatchOperation("Unsupported patch operation: " + operation.getOp()); } - UserAttribute userAttribute = userAttributes.findByScimPath(operation.getPath()); + String path = operation.getPath(); Object value = operation.getValue(); - if (userAttribute == null) { - throw new UnsupportedUserPath("Unsupported attribute: " + operation.getPath()); + // RFC 7644 §3.5.2: when "path" is omitted, "value" carries a map of + // attribute -> value to apply to the resource. Same shape Okta uses + // for Deactivate User on the realm scope; mirror the realm-scope + // handling here so org-scope users do not throw UnsupportedUserPath. + if (path == null) { + if (!(value instanceof java.util.Map valueMap)) { + throw new UnsupportedUserPath("PatchOp without 'path' requires a map-valued 'value'"); + } + for (java.util.Map.Entry entry : valueMap.entrySet()) { + String attrPath = String.valueOf(entry.getKey()); + if (isReadOnlyOrStructural(attrPath)) { + // RFC 7644 §3.5.2 / §7.5: ignore read-only and + // structural attributes (id, meta, schemas). + continue; + } + UserAttribute ua = userAttributes.findByScimPath(attrPath); + if (ua == null) { + throw new UnsupportedUserPath("Unsupported attribute: " + attrPath); + } + applyOrgPatchValue(op, ua, existing, entry.getValue()); + } + continue; } - switch (op) { - case REPLACE, ADD -> { - switch (value) { - case null: - logger.warn("Value is null for patch operation: " + op); - break; - case String s when userAttribute instanceof StringUserAttribute: - ((StringUserAttribute) userAttribute).write(existing, s); - break; - case String s when userAttribute instanceof BooleanUserAttribute: - ((BooleanUserAttribute) userAttribute).write(existing, Boolean.parseBoolean(s)); - break; - case Boolean b when userAttribute instanceof BooleanUserAttribute: - ((BooleanUserAttribute) userAttribute).write(existing, b); - break; - default: - logger.warn("Unsupported value type for patch operation: " + value.getClass()); - break; - } + if (isReadOnlyOrStructural(path)) { + continue; + } - } - case REMOVE -> userAttribute.write(existing, null); + UserAttribute userAttribute = userAttributes.findByScimPath(path); + + if (userAttribute == null) { + throw new UnsupportedUserPath("Unsupported attribute: " + path); } + + applyOrgPatchValue(op, userAttribute, existing, value); } fi.metatavu.keycloak.scim.server.model.User patchedUser = translateUser( @@ -264,6 +272,35 @@ public fi.metatavu.keycloak.scim.server.model.User patchOrganizationUser( return patchedUser; } + /** + * Apply a single attribute patch to the given org-scope user. + * Used by both the path-based and path-less branches of patchOrganizationUser. + */ + private void applyOrgPatchValue(PatchOperation op, UserAttribute ua, UserModel existing, Object value) { + switch (op) { + case REPLACE, ADD -> { + switch (value) { + case null: + logger.warn("Value is null for patch operation: " + op); + break; + case String s when ua instanceof StringUserAttribute: + ((StringUserAttribute) ua).write(existing, s); + break; + case String s when ua instanceof BooleanUserAttribute: + ((BooleanUserAttribute) ua).write(existing, Boolean.parseBoolean(s)); + break; + case Boolean b when ua instanceof BooleanUserAttribute: + ((BooleanUserAttribute) ua).write(existing, b); + break; + default: + logger.warn("Unsupported value type for patch operation: " + value.getClass()); + break; + } + } + case REMOVE -> ua.write(existing, null); + } + } + /** * Finds a user * diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimServer.java b/src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimServer.java index 19d087c..aa4870a 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimServer.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimServer.java @@ -1,6 +1,7 @@ package fi.metatavu.keycloak.scim.server.realm; import fi.metatavu.keycloak.scim.server.AbstractScimServer; +import fi.metatavu.keycloak.scim.server.ScimErrors; import fi.metatavu.keycloak.scim.server.config.ConfigurationError; import fi.metatavu.keycloak.scim.server.filter.ScimFilter; import fi.metatavu.keycloak.scim.server.groups.UnsupportedGroupPath; @@ -37,12 +38,12 @@ public Response createUser( if (isBlank(createRequest.getUserName())) { logger.warn("Cannot create user: Missing userName"); - return Response.status(Response.Status.BAD_REQUEST).entity("Missing userName").build(); + return ScimErrors.badRequest("Missing userName"); } UserModel existing = session.users().getUserByUsername(realm, createRequest.getUserName()); if (existing != null) { - return Response.status(Response.Status.CONFLICT).entity("User already exists").build(); + return ScimErrors.conflict("User already exists"); } UserAttributes userAttributes = metadataController.getUserAttributes(scimContext); @@ -68,19 +69,19 @@ public Response updateUser(RealmScimContext scimContext, String userId, fi.metat if (isBlank(updateRequest.getUserName())) { logger.warn("Missing userName"); - return Response.status(Response.Status.BAD_REQUEST).entity("Missing userName").build(); + return ScimErrors.badRequest("Missing userName"); } if (emailAsUsername && !isValidEmail(updateRequest.getUserName())) { logger.warn("Cannot update user: Invalid email format for userName"); - return Response.status(Response.Status.BAD_REQUEST).entity("Invalid email format for userName").build(); + return ScimErrors.badRequest("Invalid email format for userName"); } if (emailAsUsername && updateRequest.getEmails() != null) { for (fi.metatavu.keycloak.scim.server.model.UserEmailsInner email : updateRequest.getEmails()) { if (!Objects.equals(email.getValue(), updateRequest.getUserName())) { logger.warn("Conflicting email and userName when emailAsUsername is enabled"); - return Response.status(Response.Status.BAD_REQUEST).entity("Username and email must match when emailAsUsername is enabled").build(); + return ScimErrors.badRequest("Username and email must match when emailAsUsername is enabled"); } } } @@ -89,7 +90,7 @@ public Response updateUser(RealmScimContext scimContext, String userId, fi.metat UserModel user = session.users().getUserById(realm, userId); if (user == null) { logger.warn(String.format("User not found: %s", userId)); - return Response.status(Response.Status.NOT_FOUND).entity("User not found").build(); + return ScimErrors.notFound("User not found"); } // Check if username is being changed to an already existing one @@ -102,7 +103,7 @@ public Response updateUser(RealmScimContext scimContext, String userId, fi.metat if (existing != null && !existing.getId().equals(userId)) { logger.warn(String.format("User name already taken: %s", updateRequest.getUserName())); - return Response.status(Response.Status.CONFLICT).entity("User name already taken").build(); + return ScimErrors.conflict("User name already taken"); } UserAttributes userAttributes = metadataController.getUserAttributes(scimContext); @@ -119,7 +120,7 @@ public Response patchUser(RealmScimContext scimContext, String userId, fi.metata UserModel existing = session.users().getUserById(realm, userId); if (existing == null) { logger.warn(String.format("User not found: %s", userId)); - return Response.status(Response.Status.NOT_FOUND).entity("User not found").build(); + return scimError(Response.Status.NOT_FOUND, "User not found"); } UserAttributes userAttributes = metadataController.getUserAttributes(scimContext); @@ -128,7 +129,7 @@ public Response patchUser(RealmScimContext scimContext, String userId, fi.metata fi.metatavu.keycloak.scim.server.model.User result = usersController.patchUser(scimContext, userAttributes, existing, patchRequest); return Response.ok(result).build(); } catch (UnsupportedPatchOperation e) { - return Response.status(Response.Status.BAD_REQUEST).entity("Unsupported patch operation").build(); + return scimError(Response.Status.BAD_REQUEST, "Unsupported patch operation"); } } @@ -173,7 +174,7 @@ public Response deleteUser(RealmScimContext scimContext, String userId) { RoleModel scimManagedRole = realm.getRole("scim-managed"); if (scimManagedRole != null && !user.hasRole(scimManagedRole)) { logger.warn(String.format("User is not SCIM-managed: %s", userId)); - return Response.status(Response.Status.FORBIDDEN).entity("User is not managed by SCIM").build(); + return ScimErrors.forbidden("User is not managed by SCIM"); } usersController.deleteUser(scimContext, user); @@ -187,7 +188,7 @@ public Response createGroup(RealmScimContext scimContext, fi.metatavu.keycloak.s if (isBlank(createRequest.getDisplayName())) { logger.warn("Cannot create group: Missing displayName"); - return Response.status(Response.Status.BAD_REQUEST).entity("Missing displayName").build(); + return ScimErrors.badRequest("Missing displayName"); } fi.metatavu.keycloak.scim.server.model.Group created = groupsController.createGroup(scimContext, createRequest); @@ -225,7 +226,7 @@ public Response updateGroup(RealmScimContext scimContext, String id, fi.metatavu } if (!id.equals(existing.getId())) { - return Response.status(Response.Status.BAD_REQUEST).entity("Group ID mismatch").build(); + return ScimErrors.badRequest("Group ID mismatch"); } fi.metatavu.keycloak.scim.server.model.Group updated = groupsController.updateGroup( @@ -247,19 +248,24 @@ public Response patchGroup(RealmScimContext scimContext, String groupId, fi.meta } if (!groupId.equals(existing.getId())) { - return Response.status(Response.Status.BAD_REQUEST).entity("Group ID mismatch").build(); + return scimError(Response.Status.BAD_REQUEST, "Group ID mismatch"); } try { fi.metatavu.keycloak.scim.server.model.Group updated = groupsController.patchGroup(scimContext, existing, patchRequest); return Response.ok(updated).build(); } catch (UnsupportedGroupPath e) { - return Response.status(Response.Status.BAD_REQUEST).entity("Unsupported group path").build(); + return scimError(Response.Status.BAD_REQUEST, e.getMessage() != null ? e.getMessage() : "Unsupported group path"); } catch (UnsupportedPatchOperation e) { - return Response.status(Response.Status.BAD_REQUEST).entity("Unsupported patch operation").build(); + return scimError(Response.Status.BAD_REQUEST, "Unsupported patch operation"); } } + /** Backwards-compat shim around {@link ScimErrors#error(Response.Status, String)}. */ + private static Response scimError(Response.Status status, String detail) { + return ScimErrors.error(status, detail); + } + @Override public Response deleteGroup(RealmScimContext scimContext, String id) { KeycloakSession session = scimContext.getSession(); diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/users/UsersController.java b/src/main/java/fi/metatavu/keycloak/scim/server/users/UsersController.java index 97aa439..f8fdece 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/users/UsersController.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/users/UsersController.java @@ -314,6 +314,12 @@ public fi.metatavu.keycloak.scim.server.model.User patchUser( } for (Map.Entry entry : valueMap.entrySet()) { String attrPath = String.valueOf(entry.getKey()); + if (isReadOnlyOrStructural(attrPath)) { + // RFC 7644 §3.5.2 / §7.5: ignore read-only and + // structural attributes (id, meta, schemas) + // on PATCH. Clients (Okta) echo them back from a prior GET. + continue; + } UserAttribute ua = userAttributes.findByScimPath(attrPath); if (ua == null) { throw new UnsupportedUserPath("Unsupported attribute: " + attrPath); @@ -323,6 +329,10 @@ public fi.metatavu.keycloak.scim.server.model.User patchUser( continue; } + if (isReadOnlyOrStructural(path)) { + continue; + } + UserAttribute userAttribute = userAttributes.findByScimPath(path); if (userAttribute == null) { throw new UnsupportedUserPath("Unsupported attribute: " + path); diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/OrganizationUserListTestsIT.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/OrganizationUserListTestsIT.java index 4c60e7e..f6e3bf4 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/OrganizationUserListTestsIT.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/OrganizationUserListTestsIT.java @@ -228,7 +228,8 @@ void testInvalidFilterMissingOperator() { scimClient.listUsers("userName \"bob\"", 0, 10) ); - assertEquals("listUsers call failed with: 400 - Invalid filter", exception.getMessage()); + assertTrue(exception.getMessage().contains("Invalid filter"), + "Expected error message to contain 'Invalid filter'; got: " + exception.getMessage()); } @Test @@ -239,7 +240,8 @@ void testInvalidFilterUnsupportedOperator() { scimClient.listUsers("userName gt \"bob\"", 0, 10) ); - assertEquals("listUsers call failed with: 400 - Invalid filter", exception.getMessage()); + assertTrue(exception.getMessage().contains("Invalid filter"), + "Expected error message to contain 'Invalid filter'; got: " + exception.getMessage()); } @Test @@ -250,7 +252,8 @@ void testInvalidFilterUnquotedString() { scimClient.listUsers("userName eq bob", 0, 10) ); - assertEquals("listUsers call failed with: 400 - Invalid filter", exception.getMessage()); + assertTrue(exception.getMessage().contains("Invalid filter"), + "Expected error message to contain 'Invalid filter'; got: " + exception.getMessage()); } @Test @@ -261,7 +264,8 @@ void testInvalidFilterBadLogicalStructure() { scimClient.listUsers("userName eq \"a\" and", 0, 10) ); - assertEquals("listUsers call failed with: 400 - Invalid filter", exception.getMessage()); + assertTrue(exception.getMessage().contains("Invalid filter"), + "Expected error message to contain 'Invalid filter'; got: " + exception.getMessage()); } @Test diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmGroupListTestsIT.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmGroupListTestsIT.java index c7f72b2..879536e 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmGroupListTestsIT.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmGroupListTestsIT.java @@ -200,7 +200,8 @@ void testInvalidFilterMissingOperator() throws ApiException { scimClient.listGroups("displayName \"test\"", 0, 10) ); - assertEquals("listGroups call failed with: 400 - Invalid filter", exception.getMessage()); + assertTrue(exception.getMessage().contains("Invalid filter"), + "Expected error message to contain 'Invalid filter'; got: " + exception.getMessage()); } finally { deleteGroup(scimClient, group.getId()); } @@ -218,7 +219,8 @@ void testInvalidFilterUnquotedString() throws ApiException { scimClient.listGroups("displayName eq test", 0, 10) ); - assertEquals("listGroups call failed with: 400 - Invalid filter", exception.getMessage()); + assertTrue(exception.getMessage().contains("Invalid filter"), + "Expected error message to contain 'Invalid filter'; got: " + exception.getMessage()); } finally { deleteGroup(scimClient, group.getId()); } @@ -236,7 +238,8 @@ void testInvalidFilterBadLogicalStructure() throws ApiException { scimClient.listGroups("displayName eq \"test\" and", 0, 10) ); - assertEquals("listGroups call failed with: 400 - Invalid filter", exception.getMessage()); + assertTrue(exception.getMessage().contains("Invalid filter"), + "Expected error message to contain 'Invalid filter'; got: " + exception.getMessage()); } finally { deleteGroup(scimClient, group.getId()); } diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserListTestsIT.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserListTestsIT.java index 5f18358..b350554 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserListTestsIT.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserListTestsIT.java @@ -211,7 +211,8 @@ void testInvalidFilterMissingOperator() { scimClient.listUsers("userName \"bob\"", 0, 10) ); - assertEquals("listUsers call failed with: 400 - Invalid filter", exception.getMessage()); + assertTrue(exception.getMessage().contains("Invalid filter"), + "Expected error message to contain 'Invalid filter'; got: " + exception.getMessage()); } @Test @@ -222,7 +223,8 @@ void testInvalidFilterUnsupportedOperator() { scimClient.listUsers("userName gt \"bob\"", 0, 10) ); - assertEquals("listUsers call failed with: 400 - Invalid filter", exception.getMessage()); + assertTrue(exception.getMessage().contains("Invalid filter"), + "Expected error message to contain 'Invalid filter'; got: " + exception.getMessage()); } @Test @@ -233,7 +235,8 @@ void testInvalidFilterUnquotedString() { scimClient.listUsers("userName eq bob", 0, 10) ); - assertEquals("listUsers call failed with: 400 - Invalid filter", exception.getMessage()); + assertTrue(exception.getMessage().contains("Invalid filter"), + "Expected error message to contain 'Invalid filter'; got: " + exception.getMessage()); } @Test @@ -244,7 +247,8 @@ void testInvalidFilterBadLogicalStructure() { scimClient.listUsers("userName eq \"a\" and", 0, 10) ); - assertEquals("listUsers call failed with: 400 - Invalid filter", exception.getMessage()); + assertTrue(exception.getMessage().contains("Invalid filter"), + "Expected error message to contain 'Invalid filter'; got: " + exception.getMessage()); } @Test From 25ffea98d4e9cc53062df818207ff5b4de51df6b Mon Sep 17 00:00:00 2001 From: Nicola Date: Tue, 19 May 2026 11:29:17 +0200 Subject: [PATCH 21/35] fix: guard null email in group membership leave admin event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GroupsController.dispatchGroupMembershipLeaveEvent passed user.getEmail() directly into Map.of(), which rejects null values and threw NullPointerException whenever the removed member had no email set in Keycloak. PATCH /Groups remove-member then returned HTTP 500 to the client. The sibling join event already coalesces null to "" — apply the same guard to the leave event so the two paths stay symmetric. Adds a regression test that creates a user, clears their email via the Keycloak admin client, then removes them from a group via SCIM PATCH and asserts success. Closes #103 --- .../scim/server/groups/GroupsController.java | 2 +- .../functional/RealmGroupPatchTestsIT.java | 48 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/groups/GroupsController.java b/src/main/java/fi/metatavu/keycloak/scim/server/groups/GroupsController.java index 96adb9c..424c58c 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/groups/GroupsController.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/groups/GroupsController.java @@ -411,7 +411,7 @@ protected void dispatchGroupMembershipLeaveEvent( groupRepresentation, Map.of( UserModel.USERNAME, user.getUsername(), - UserModel.EMAIL, user.getEmail() + UserModel.EMAIL, user.getEmail() == null ? "" : user.getEmail() ) ); } diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmGroupPatchTestsIT.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmGroupPatchTestsIT.java index 671533e..093b04e 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmGroupPatchTestsIT.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmGroupPatchTestsIT.java @@ -9,6 +9,7 @@ import org.keycloak.events.admin.AdminEvent; import org.keycloak.events.admin.OperationType; import org.keycloak.events.admin.ResourceType; +import org.keycloak.representations.idm.UserRepresentation; import org.testcontainers.junit.jupiter.Testcontainers; import java.io.IOException; @@ -208,4 +209,51 @@ void testRemoveGroupMemberAdminEvents() throws ApiException, IOException { deleteRealmUser(TestConsts.TEST_REALM, user.getId()); deleteRealmGroup(TestConsts.TEST_REALM, group.getId()); } + + /** + * Regression: removing a group member whose email is null must not 500. + * Map.of() rejects null values, so the admin event dispatch previously + * NPE'd when the user had no email set. + */ + @Test + void testRemoveGroupMemberWithoutEmail() throws ApiException { + ScimClient scimClient = getAuthenticatedScimClient(); + + User user = createUser(scimClient, "no-email-user", "No", "Email"); + Group group = createGroup(scimClient, "no-email-group"); + + UserRepresentation userRep = findRealmUser(TestConsts.TEST_REALM, user.getId()); + userRep.setEmail(null); + getKeycloakContainer().getKeycloakAdminClient() + .realms() + .realm(TestConsts.TEST_REALM) + .users() + .get(user.getId()) + .update(userRep); + + PatchRequest addRequest = new PatchRequest(); + addRequest.setSchemas(List.of("urn:ietf:params:scim:api:messages:2.0:PatchOp")); + PatchRequestOperationsInner addOperation = new PatchRequestOperationsInner(); + addOperation.setOp("add"); + addOperation.setPath("members"); + GroupMembersInner member = new GroupMembersInner(); + member.setValue(user.getId()); + addOperation.setValue(Collections.singletonList(member)); + addRequest.setOperations(List.of(addOperation)); + scimClient.patchGroup(group.getId(), addRequest); + + PatchRequest removeRequest = new PatchRequest(); + removeRequest.setSchemas(List.of("urn:ietf:params:scim:api:messages:2.0:PatchOp")); + PatchRequestOperationsInner removeOperation = new PatchRequestOperationsInner(); + removeOperation.setOp("remove"); + removeOperation.setPath("members[value eq \"" + user.getId() + "\"]"); + removeRequest.setOperations(List.of(removeOperation)); + + Group patched = scimClient.patchGroup(group.getId(), removeRequest); + + assertTrue(patched.getMembers() == null || patched.getMembers().isEmpty()); + + deleteRealmUser(TestConsts.TEST_REALM, user.getId()); + deleteRealmGroup(TestConsts.TEST_REALM, group.getId()); + } } From 7a3af328cd60cef98a133fb366caa3265a55849a Mon Sep 17 00:00:00 2001 From: Nicola Date: Tue, 19 May 2026 11:41:03 +0200 Subject: [PATCH 22/35] fix: retain omitted name subattributes on PUT /Users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UsersController.updateUser wrote name.givenName and name.familyName unconditionally whenever the parent "name" object was present in the request body, so a payload with only one of the two subattributes silently cleared the other. Sibling top-level attributes (emails, additionalProperties like displayName / preferredLanguage / externalId) are already gated and correctly retained when omitted. Only the name.* subattributes were written through with null. Per RFC 7644 §3.5.1, omitted readWrite attributes MAY be assumed to be unchanged — applies to subattributes too. Guard each name.* write on a non-null subattribute value so the behaviour matches the rest of the resource. Adds a regression test that PUTs with only name.givenName and asserts the existing name.familyName is retained. Closes #105 --- .../scim/server/users/UsersController.java | 8 +++- .../functional/RealmUserUpdateTestsIT.java | 43 +++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/users/UsersController.java b/src/main/java/fi/metatavu/keycloak/scim/server/users/UsersController.java index 97aa439..6bfdc18 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/users/UsersController.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/users/UsersController.java @@ -232,8 +232,12 @@ public fi.metatavu.keycloak.scim.server.model.User updateUser( ((BooleanUserAttribute) userAttributes.findByScimPath("active")).write(existing, scimUser.getActive() == null || Boolean.TRUE.equals(scimUser.getActive())); if (scimUser.getName() != null) { - ((StringUserAttribute) userAttributes.findByScimPath("name.givenName")).write(existing, scimUser.getName().getGivenName()); - ((StringUserAttribute) userAttributes.findByScimPath("name.familyName")).write(existing, scimUser.getName().getFamilyName()); + if (scimUser.getName().getGivenName() != null) { + ((StringUserAttribute) userAttributes.findByScimPath("name.givenName")).write(existing, scimUser.getName().getGivenName()); + } + if (scimUser.getName().getFamilyName() != null) { + ((StringUserAttribute) userAttributes.findByScimPath("name.familyName")).write(existing, scimUser.getName().getFamilyName()); + } } if (scimUser.getEmails() != null && !scimUser.getEmails().isEmpty()) { diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserUpdateTestsIT.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserUpdateTestsIT.java index 881f4f2..b01f48d 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserUpdateTestsIT.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserUpdateTestsIT.java @@ -5,6 +5,7 @@ import fi.metatavu.keycloak.scim.server.test.TestConsts; import fi.metatavu.keycloak.scim.server.test.client.ApiException; import fi.metatavu.keycloak.scim.server.test.client.model.User; +import fi.metatavu.keycloak.scim.server.test.client.model.UserName; import org.junit.jupiter.api.Test; import org.keycloak.events.admin.AdminEvent; import org.keycloak.events.admin.OperationType; @@ -183,4 +184,46 @@ void testUpdateUserAdminEvents() throws ApiException, IOException { // Cleanup deleteRealmUser(TestConsts.TEST_REALM, created.getId()); } + + /** + * Regression: omitted "name.familyName" in a PUT body must retain the existing value. + * Previously the request unconditionally wrote null when the parent "name" object was + * present, clearing the existing family name. RFC 7644 §3.5.1 says omitted readWrite + * attributes MAY be assumed to be unchanged — applies to subattributes too. + */ + @Test + void testReplaceUserRetainsOmittedNameSubattributes() throws ApiException { + ScimClient scimClient = getAuthenticatedScimClient(); + + User user = new User(); + user.setUserName("omitted-name-sub"); + user.setActive(true); + user.setSchemas(List.of("urn:ietf:params:scim:schemas:core:2.0:User")); + user.setName(getName("Original", "Lastname")); + user.setEmails(getEmails("omitted.name.sub@example.com")); + + User created = scimClient.createUser(user); + String userId = created.getId(); + + // PUT with only givenName under name; familyName omitted + User replacement = new User(); + replacement.setUserName(user.getUserName()); + replacement.setActive(true); + replacement.setSchemas(List.of("urn:ietf:params:scim:schemas:core:2.0:User")); + UserName partialName = new UserName(); + partialName.setGivenName("NewGiven"); + replacement.setName(partialName); + + User updated = scimClient.updateUser(userId, replacement); + + assertNotNull(updated.getName()); + assertEquals("NewGiven", updated.getName().getGivenName()); + assertEquals("Lastname", updated.getName().getFamilyName(), "familyName must be retained when omitted in PUT"); + + UserRepresentation realmUser = findRealmUser(TestConsts.TEST_REALM, userId); + assertEquals("NewGiven", realmUser.getFirstName()); + assertEquals("Lastname", realmUser.getLastName()); + + deleteRealmUser(TestConsts.TEST_REALM, userId); + } } \ No newline at end of file From 02a11e926267e6d38ffe36afbb9a007cf9620e85 Mon Sep 17 00:00:00 2001 From: Nicola Date: Tue, 19 May 2026 13:16:59 +0200 Subject: [PATCH 23/35] fix: surface duplicate-username and duplicate-email conflicts on POST /Users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related problems on POST /Users when the request collides with an existing user: - The realm-scope createUser pre-checked only username, returning a generic "User already exists" body that did not identify the offending field. A duplicate email was not pre-checked, so the create reached UserModel.setEmail and surfaced as Keycloak's generic {"error":"unknown_error"} response. - The organization-scope createUser had no pre-check at all — both collisions surfaced as unknown_error. Add a getUserByUsername + getUserByEmail pre-check on both scopes and return a 409 with a body that identifies the duplicated field and value: User already exists with username: User already exists with email: The email pre-check is gated on RealmModel.isDuplicateEmailsAllowed so realms configured to permit duplicate emails still create successfully. Adds regression tests for both the duplicate-username and the duplicate-email cases on both scopes; existing duplicate-username tests are strengthened to assert the message identifies the field. Out of scope: Keycloak's user-profile email validator can still reject duplicates even when the realm flag is on. That path would need a ModelDuplicateException catch in the controller layer and is left for a follow-up. Closes #109 --- .../organization/OrganizationScimServer.java | 21 ++++++++++ .../scim/server/realm/RealmScimServer.java | 15 +++++++- .../OrganizationUserCreateTestsIT.java | 38 ++++++++++++++++++- .../functional/RealmUserCreateTestsIT.java | 38 ++++++++++++++++++- 4 files changed, 109 insertions(+), 3 deletions(-) diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServer.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServer.java index 6865fa0..72a8e34 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServer.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServer.java @@ -44,6 +44,27 @@ public Response createUser(OrganizationScimContext scimContext, User createReque return Response.status(Response.Status.BAD_REQUEST).entity("Invalid email format for userName").build(); } + KeycloakSession session = scimContext.getSession(); + RealmModel realm = scimContext.getRealm(); + + UserModel existingByUsername = session.users().getUserByUsername(realm, createRequest.getUserName()); + if (existingByUsername != null) { + return Response.status(Response.Status.CONFLICT) + .entity(String.format("User already exists with username: %s", createRequest.getUserName())) + .build(); + } + + String requestedEmail = createRequest.getEmails() != null && !createRequest.getEmails().isEmpty() + ? createRequest.getEmails().getFirst().getValue() + : null; + if (requestedEmail != null + && !realm.isDuplicateEmailsAllowed() + && session.users().getUserByEmail(realm, requestedEmail) != null) { + return Response.status(Response.Status.CONFLICT) + .entity(String.format("User already exists with email: %s", requestedEmail)) + .build(); + } + UserAttributes userAttributes = metadataController.getUserAttributes(scimContext); User user = organizationUserController.createOrganizationUser( diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimServer.java b/src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimServer.java index 19d087c..835d204 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimServer.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimServer.java @@ -42,7 +42,20 @@ public Response createUser( UserModel existing = session.users().getUserByUsername(realm, createRequest.getUserName()); if (existing != null) { - return Response.status(Response.Status.CONFLICT).entity("User already exists").build(); + return Response.status(Response.Status.CONFLICT) + .entity(String.format("User already exists with username: %s", createRequest.getUserName())) + .build(); + } + + String requestedEmail = createRequest.getEmails() != null && !createRequest.getEmails().isEmpty() + ? createRequest.getEmails().getFirst().getValue() + : null; + if (requestedEmail != null + && !realm.isDuplicateEmailsAllowed() + && session.users().getUserByEmail(realm, requestedEmail) != null) { + return Response.status(Response.Status.CONFLICT) + .entity(String.format("User already exists with email: %s", requestedEmail)) + .build(); } UserAttributes userAttributes = metadataController.getUserAttributes(scimContext); diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/OrganizationUserCreateTestsIT.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/OrganizationUserCreateTestsIT.java index 961e85e..df58358 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/OrganizationUserCreateTestsIT.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/OrganizationUserCreateTestsIT.java @@ -118,17 +118,53 @@ void testCreateDuplicateUserReturnsConflict() throws ApiException { User created = scimClient.createUser(user); assertNotNull(created); - // Second creation should fail with 409 Conflict + // Second creation should fail with 409 Conflict and identify the duplicated field ApiException exception = assertThrows(ApiException.class, () -> scimClient.createUser(user) ); assertEquals(409, exception.getCode()); + assertTrue(exception.getMessage().contains("username"), + "Expected conflict message to mention 'username'; got: " + exception.getMessage()); + assertTrue(exception.getMessage().contains("dupe-user"), + "Expected conflict message to include the offending username; got: " + exception.getMessage()); // Clean up deleteRealmUser(TestConsts.ORGANIZATIONS_REALM, created.getId()); } + @Test + void testCreateDuplicateEmailReturnsConflict() throws ApiException { + ScimClient scimClient = getAuthenticatedScimClient(TestConsts.ORGANIZATION_1_ID); + + User first = new User(); + first.setUserName("org-dupe-email-first"); + first.setActive(true); + first.setSchemas(List.of("urn:ietf:params:scim:schemas:core:2.0:User")); + first.setEmails(getEmails("org.dupe.email@example.com")); + + User created = scimClient.createUser(first); + assertNotNull(created); + + User second = new User(); + second.setUserName("org-dupe-email-second"); + second.setActive(true); + second.setSchemas(List.of("urn:ietf:params:scim:schemas:core:2.0:User")); + second.setEmails(getEmails("org.dupe.email@example.com")); + + ApiException exception = assertThrows(ApiException.class, () -> + scimClient.createUser(second) + ); + + assertEquals(409, exception.getCode()); + assertTrue(exception.getMessage().contains("email"), + "Expected conflict message to mention 'email'; got: " + exception.getMessage()); + assertTrue(exception.getMessage().contains("org.dupe.email@example.com"), + "Expected conflict message to include the offending email; got: " + exception.getMessage()); + + deleteRealmUser(TestConsts.ORGANIZATIONS_REALM, created.getId()); + } + @Test void testCreateEmailAsUsername() throws ApiException { ScimClient scimClient = getAuthenticatedScimClient(TestConsts.ORGANIZATION_EMAIL_AS_USERNAME_ID); diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserCreateTestsIT.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserCreateTestsIT.java index 89b20ad..6f1a3f5 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserCreateTestsIT.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserCreateTestsIT.java @@ -112,17 +112,53 @@ void testCreateDuplicateUserReturnsConflict() throws ApiException { User created = scimClient.createUser(user); assertNotNull(created); - // Second creation should fail with 409 Conflict + // Second creation should fail with 409 Conflict and identify the duplicated field ApiException exception = assertThrows(ApiException.class, () -> scimClient.createUser(user) ); assertEquals(409, exception.getCode()); + assertTrue(exception.getMessage().contains("username"), + "Expected conflict message to mention 'username'; got: " + exception.getMessage()); + assertTrue(exception.getMessage().contains("dupe-user"), + "Expected conflict message to include the offending username; got: " + exception.getMessage()); // Clean up deleteRealmUser(TestConsts.TEST_REALM, created.getId()); } + @Test + void testCreateDuplicateEmailReturnsConflict() throws ApiException { + ScimClient scimClient = getAuthenticatedScimClient(); + + User first = new User(); + first.setUserName("dupe-email-first"); + first.setActive(true); + first.setSchemas(List.of("urn:ietf:params:scim:schemas:core:2.0:User")); + first.setEmails(getEmails("dupe.email@example.com")); + + User created = scimClient.createUser(first); + assertNotNull(created); + + User second = new User(); + second.setUserName("dupe-email-second"); + second.setActive(true); + second.setSchemas(List.of("urn:ietf:params:scim:schemas:core:2.0:User")); + second.setEmails(getEmails("dupe.email@example.com")); + + ApiException exception = assertThrows(ApiException.class, () -> + scimClient.createUser(second) + ); + + assertEquals(409, exception.getCode()); + assertTrue(exception.getMessage().contains("email"), + "Expected conflict message to mention 'email'; got: " + exception.getMessage()); + assertTrue(exception.getMessage().contains("dupe.email@example.com"), + "Expected conflict message to include the offending email; got: " + exception.getMessage()); + + deleteRealmUser(TestConsts.TEST_REALM, created.getId()); + } + @Test void testCreateUserAdminEvents() throws ApiException, IOException { ScimClient scimClient = getAuthenticatedScimClient(); From a34e3e0aa3c08f70aa34c34039c3a74446842f58 Mon Sep 17 00:00:00 2001 From: Danilo Acquaviva Date: Mon, 25 May 2026 17:24:38 +0100 Subject: [PATCH 24/35] Build SCIM Error JSON via Jackson so detail is safely escaped --- .../keycloak/scim/server/ScimErrors.java | 33 +++++++++++---- .../test/tests/unit/ScimErrorsTest.java | 40 +++++++++++++++++++ 2 files changed, 66 insertions(+), 7 deletions(-) create mode 100644 src/test/java/fi/metatavu/keycloak/scim/server/test/tests/unit/ScimErrorsTest.java diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/ScimErrors.java b/src/main/java/fi/metatavu/keycloak/scim/server/ScimErrors.java index cc53dd0..6d6ca40 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/ScimErrors.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/ScimErrors.java @@ -1,5 +1,8 @@ package fi.metatavu.keycloak.scim.server; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import jakarta.ws.rs.core.Response; /** @@ -10,9 +13,18 @@ * as JSON (Okta, Entra ID). All error responses go through this helper now and * return a valid SCIM Error JSON document with the application/scim+json media * type. + * + * The body is built via Jackson so any control character or quote that arrives + * in {@code detail} (typically from a user-supplied attribute path interpolated + * into an error message) is escaped correctly. A hand-rolled escape used to + * cover only `\\` and `"` and reintroduced the JSON parse failure on the client + * side as soon as a `\\n` or `\\t` reached `detail`. */ public final class ScimErrors { + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error"; + private ScimErrors() { // utility class } @@ -21,16 +33,23 @@ private ScimErrors() { * Build a SCIM 2.0 Error response. * * @param status HTTP status (e.g. BAD_REQUEST) - * @param detail human-readable error detail + * @param detail human-readable error detail; null is rendered as the empty string * @return Response carrying a SCIM Error JSON body and application/scim+json type */ public static Response error(Response.Status status, String detail) { - String safeDetail = detail == null - ? "" - : detail.replace("\\", "\\\\").replace("\"", "\\\""); - String body = "{\"schemas\":[\"urn:ietf:params:scim:api:messages:2.0:Error\"]" - + ",\"status\":\"" + status.getStatusCode() + "\"" - + ",\"detail\":\"" + safeDetail + "\"}"; + ObjectNode node = MAPPER.createObjectNode(); + node.putArray("schemas").add(ERROR_SCHEMA); + node.put("status", Integer.toString(status.getStatusCode())); + node.put("detail", detail == null ? "" : detail); + String body; + try { + body = MAPPER.writeValueAsString(node); + } catch (JsonProcessingException e) { + // ObjectNode is always serializable; fall back to a static body if Jackson + // somehow fails so we still return SCIM-shaped JSON. + body = "{\"schemas\":[\"" + ERROR_SCHEMA + "\"],\"status\":\"" + + status.getStatusCode() + "\",\"detail\":\"\"}"; + } return Response.status(status).type("application/scim+json").entity(body).build(); } diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/unit/ScimErrorsTest.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/unit/ScimErrorsTest.java new file mode 100644 index 0000000..0289546 --- /dev/null +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/unit/ScimErrorsTest.java @@ -0,0 +1,40 @@ +package fi.metatavu.keycloak.scim.server.test.tests.unit; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import fi.metatavu.keycloak.scim.server.ScimErrors; +import jakarta.ws.rs.core.Response; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ScimErrorsTest { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Test + void encodesPlainDetail() throws Exception { + Response r = ScimErrors.badRequest("Unsupported attribute: members.value"); + assertEquals(400, r.getStatus()); + assertEquals("application/scim+json", r.getMediaType().toString()); + JsonNode body = MAPPER.readTree((String) r.getEntity()); + assertTrue(body.get("schemas").toString().contains("urn:ietf:params:scim:api:messages:2.0:Error")); + assertEquals("400", body.get("status").asText()); + assertEquals("Unsupported attribute: members.value", body.get("detail").asText()); + } + + @Test + void encodesDetailWithControlChars() throws Exception { + // Reviewer point #2: a newline / tab in detail must not produce malformed JSON. + Response r = ScimErrors.badRequest("Unsupported attribute: weird\n\tpath\"with\\quotes"); + JsonNode body = MAPPER.readTree((String) r.getEntity()); + assertEquals("Unsupported attribute: weird\n\tpath\"with\\quotes", body.get("detail").asText()); + } + + @Test + void nullDetailIsEmptyString() throws Exception { + Response r = ScimErrors.notFound(null); + JsonNode body = MAPPER.readTree((String) r.getEntity()); + assertEquals("", body.get("detail").asText()); + } +} From 0e76edadf00efa9bf55a52cc768d22bade5453a6 Mon Sep 17 00:00:00 2001 From: Danilo Acquaviva Date: Mon, 25 May 2026 17:35:34 +0100 Subject: [PATCH 25/35] Inline RealmScimServer.scimError shim through ScimErrors directly --- .../scim/server/realm/RealmScimServer.java | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimServer.java b/src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimServer.java index aa4870a..110343a 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimServer.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimServer.java @@ -120,7 +120,7 @@ public Response patchUser(RealmScimContext scimContext, String userId, fi.metata UserModel existing = session.users().getUserById(realm, userId); if (existing == null) { logger.warn(String.format("User not found: %s", userId)); - return scimError(Response.Status.NOT_FOUND, "User not found"); + return ScimErrors.notFound("User not found"); } UserAttributes userAttributes = metadataController.getUserAttributes(scimContext); @@ -129,7 +129,7 @@ public Response patchUser(RealmScimContext scimContext, String userId, fi.metata fi.metatavu.keycloak.scim.server.model.User result = usersController.patchUser(scimContext, userAttributes, existing, patchRequest); return Response.ok(result).build(); } catch (UnsupportedPatchOperation e) { - return scimError(Response.Status.BAD_REQUEST, "Unsupported patch operation"); + return ScimErrors.badRequest("Unsupported patch operation"); } } @@ -248,24 +248,19 @@ public Response patchGroup(RealmScimContext scimContext, String groupId, fi.meta } if (!groupId.equals(existing.getId())) { - return scimError(Response.Status.BAD_REQUEST, "Group ID mismatch"); + return ScimErrors.badRequest("Group ID mismatch"); } try { fi.metatavu.keycloak.scim.server.model.Group updated = groupsController.patchGroup(scimContext, existing, patchRequest); return Response.ok(updated).build(); } catch (UnsupportedGroupPath e) { - return scimError(Response.Status.BAD_REQUEST, e.getMessage() != null ? e.getMessage() : "Unsupported group path"); + return ScimErrors.badRequest(e.getMessage() != null ? e.getMessage() : "Unsupported group path"); } catch (UnsupportedPatchOperation e) { - return scimError(Response.Status.BAD_REQUEST, "Unsupported patch operation"); + return ScimErrors.badRequest("Unsupported patch operation"); } } - /** Backwards-compat shim around {@link ScimErrors#error(Response.Status, String)}. */ - private static Response scimError(Response.Status status, String detail) { - return ScimErrors.error(status, detail); - } - @Override public Response deleteGroup(RealmScimContext scimContext, String id) { KeycloakSession session = scimContext.getSession(); From b39aa18fcd973587a849886ac46fefadab04dc2c Mon Sep 17 00:00:00 2001 From: Danilo Acquaviva Date: Mon, 25 May 2026 17:51:59 +0100 Subject: [PATCH 26/35] Share applyPatchValue between realm + org controllers; fix REMOVE NPE on USER_PROFILE attrs Promote UsersController.applyPatchValue to protected and delete the near-identical OrganizationUserController.applyOrgPatchValue, routing both controllers through the single shared method. Add UserAttribute.clear(UserModel) with an optional Consumer remover registered at construction time. USER_PROFILE-backed attributes (externalId, displayName, etc.) register user -> user.removeAttribute(name) so REMOVE operations no longer pass null into List.of(value), which threw NPE on Java 9+ and returned HTTP 500. --- .../server/metadata/BooleanUserAttribute.java | 24 ++++++++- .../server/metadata/MetadataController.java | 6 ++- .../server/metadata/StringUserAttribute.java | 21 +++++++- .../scim/server/metadata/UserAttribute.java | 53 ++++++++++++++++++- .../OrganizationUserController.java | 33 +----------- .../scim/server/users/UsersController.java | 4 +- .../OrganizationUserPatchTestsIT.java | 31 +++++++++++ .../functional/RealmUserPatchTestsIT.java | 31 +++++++++++ 8 files changed, 164 insertions(+), 39 deletions(-) diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/metadata/BooleanUserAttribute.java b/src/main/java/fi/metatavu/keycloak/scim/server/metadata/BooleanUserAttribute.java index 190936f..a2bf3be 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/metadata/BooleanUserAttribute.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/metadata/BooleanUserAttribute.java @@ -4,6 +4,7 @@ import org.keycloak.models.UserModel; import java.util.function.BiConsumer; +import java.util.function.Consumer; import java.util.function.Function; /** @@ -12,7 +13,7 @@ public class BooleanUserAttribute extends UserAttribute { /** - * Constructor + * Constructor without explicit remover. Defaults to {@code write(user, null)}. * * @param source source * @param sourceId source id @@ -27,5 +28,24 @@ public class BooleanUserAttribute extends UserAttribute { public BooleanUserAttribute(Source source, String sourceId, String scimPath, String description, SchemaAttribute.TypeEnum type, SchemaAttribute.MutabilityEnum mutability, SchemaAttribute.UniquenessEnum uniqueness, Function reader, BiConsumer writer) { super(source, sourceId, scimPath, description, type, mutability, uniqueness, reader, writer); } - + + /** + * Constructor with an explicit remover. Use when {@code write(user, null)} would be unsafe + * (e.g. boolean attributes backed by a primitive setter which would NPE on auto-unboxing null). + * + * @param source source + * @param sourceId source id + * @param scimPath SCIM path + * @param description description + * @param type type + * @param mutability mutability + * @param uniqueness uniqueness + * @param reader reader + * @param writer writer + * @param remover remover + */ + public BooleanUserAttribute(Source source, String sourceId, String scimPath, String description, SchemaAttribute.TypeEnum type, SchemaAttribute.MutabilityEnum mutability, SchemaAttribute.UniquenessEnum uniqueness, Function reader, BiConsumer writer, Consumer remover) { + super(source, sourceId, scimPath, description, type, mutability, uniqueness, reader, writer, remover); + } + } diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/metadata/MetadataController.java b/src/main/java/fi/metatavu/keycloak/scim/server/metadata/MetadataController.java index 6ee21bf..6a3de0e 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/metadata/MetadataController.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/metadata/MetadataController.java @@ -275,7 +275,8 @@ private List> getUserAttributeMappingList(ScimContext scimConte SchemaAttribute.MutabilityEnum.READWRITE, SchemaAttribute.UniquenessEnum.NONE, UserModel::isEnabled, - UserModel::setEnabled + UserModel::setEnabled, + user -> user.setEnabled(false) ) ); @@ -302,7 +303,8 @@ private List> getUserAttributeMappingList(ScimContext scimConte SchemaAttribute.MutabilityEnum.READWRITE, SchemaAttribute.UniquenessEnum.NONE, user -> user.getFirstAttribute(userProfileAttribute.getName()), - (user, value) -> user.setAttribute(userProfileAttribute.getName(), List.of(value)) + (user, value) -> user.setAttribute(userProfileAttribute.getName(), List.of(value)), + user -> user.removeAttribute(userProfileAttribute.getName()) )); } } diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/metadata/StringUserAttribute.java b/src/main/java/fi/metatavu/keycloak/scim/server/metadata/StringUserAttribute.java index deedf44..7d65fec 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/metadata/StringUserAttribute.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/metadata/StringUserAttribute.java @@ -4,6 +4,7 @@ import org.keycloak.models.UserModel; import java.util.function.BiConsumer; +import java.util.function.Consumer; import java.util.function.Function; /** @@ -12,7 +13,7 @@ public class StringUserAttribute extends UserAttribute { /** - * Constructor + * Constructor without explicit remover. Defaults to {@code write(user, null)}. * * @param source source * @param sourceId source id @@ -28,4 +29,22 @@ public StringUserAttribute(Source source, String sourceId, String scimPath, Stri super(source, sourceId, scimPath, description, type, mutability, uniqueness, reader, writer); } + /** + * Constructor with an explicit remover. + * + * @param source source + * @param sourceId source id + * @param scimPath SCIM path + * @param description description + * @param type type + * @param mutability mutability + * @param uniqueness uniqueness + * @param reader reader + * @param writer writer + * @param remover remover + */ + public StringUserAttribute(Source source, String sourceId, String scimPath, String description, SchemaAttribute.TypeEnum type, SchemaAttribute.MutabilityEnum mutability, SchemaAttribute.UniquenessEnum uniqueness, Function reader, BiConsumer writer, Consumer remover) { + super(source, sourceId, scimPath, description, type, mutability, uniqueness, reader, writer, remover); + } + } diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/metadata/UserAttribute.java b/src/main/java/fi/metatavu/keycloak/scim/server/metadata/UserAttribute.java index 484bb38..4d43333 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/metadata/UserAttribute.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/metadata/UserAttribute.java @@ -4,6 +4,7 @@ import org.keycloak.models.UserModel; import java.util.function.BiConsumer; +import java.util.function.Consumer; import java.util.function.Function; /** @@ -32,9 +33,10 @@ public enum Source { private final SchemaAttribute.UniquenessEnum uniqueness; private final Function reader; private final BiConsumer writer; + private final Consumer remover; /** - * Constructor + * Constructor without explicit remover. Defaults to {@code write(user, null)}. * * @param source attribute source * @param sourceId attribute source id @@ -56,6 +58,37 @@ public enum Source { SchemaAttribute.UniquenessEnum uniqueness, Function reader, BiConsumer writer + ) { + this(source, sourceId, scimPath, description, type, mutability, uniqueness, reader, writer, null); + } + + /** + * Constructor with an explicit remover. Use when {@code write(user, null)} would be unsafe + * (e.g. USER_PROFILE attributes backed by {@code user.setAttribute(name, List.of(value))} + * which throws NPE on {@code List.of(null)}). + * + * @param source attribute source + * @param sourceId attribute source id + * @param scimPath SCIM path + * @param description attribute description + * @param type attribute type + * @param mutability attribute mutability + * @param uniqueness attribute uniqueness + * @param reader attribute reader + * @param writer attribute writer + * @param remover attribute remover (nullable; falls back to {@code write(user, null)} when null) + */ + UserAttribute( + Source source, + String sourceId, + String scimPath, + String description, + SchemaAttribute.TypeEnum type, + SchemaAttribute.MutabilityEnum mutability, + SchemaAttribute.UniquenessEnum uniqueness, + Function reader, + BiConsumer writer, + Consumer remover ) { this.source = source; this.sourceId = sourceId; @@ -66,6 +99,7 @@ public enum Source { this.uniqueness = uniqueness; this.reader = reader; this.writer = writer; + this.remover = remover; } /** @@ -151,4 +185,21 @@ public void write(UserModel user, T value) { writer.accept(user, value); } + /** + * Removes this attribute from the user. + *

+ * Uses the explicit remover when one was provided at construction time; otherwise falls back + * to {@code write(user, null)}. USER_PROFILE attributes must supply an explicit remover + * because their writer uses {@code List.of(value)}, which throws NPE when value is null. + * + * @param user user + */ + public void clear(UserModel user) { + if (remover != null) { + remover.accept(user); + } else { + write(user, null); + } + } + } diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationUserController.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationUserController.java index e1b2dd7..5a0f470 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationUserController.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationUserController.java @@ -236,7 +236,7 @@ public fi.metatavu.keycloak.scim.server.model.User patchOrganizationUser( if (ua == null) { throw new UnsupportedUserPath("Unsupported attribute: " + attrPath); } - applyOrgPatchValue(op, ua, existing, entry.getValue()); + applyPatchValue(op, ua, existing, entry.getValue()); } continue; } @@ -251,7 +251,7 @@ public fi.metatavu.keycloak.scim.server.model.User patchOrganizationUser( throw new UnsupportedUserPath("Unsupported attribute: " + path); } - applyOrgPatchValue(op, userAttribute, existing, value); + applyPatchValue(op, userAttribute, existing, value); } fi.metatavu.keycloak.scim.server.model.User patchedUser = translateUser( @@ -272,35 +272,6 @@ public fi.metatavu.keycloak.scim.server.model.User patchOrganizationUser( return patchedUser; } - /** - * Apply a single attribute patch to the given org-scope user. - * Used by both the path-based and path-less branches of patchOrganizationUser. - */ - private void applyOrgPatchValue(PatchOperation op, UserAttribute ua, UserModel existing, Object value) { - switch (op) { - case REPLACE, ADD -> { - switch (value) { - case null: - logger.warn("Value is null for patch operation: " + op); - break; - case String s when ua instanceof StringUserAttribute: - ((StringUserAttribute) ua).write(existing, s); - break; - case String s when ua instanceof BooleanUserAttribute: - ((BooleanUserAttribute) ua).write(existing, Boolean.parseBoolean(s)); - break; - case Boolean b when ua instanceof BooleanUserAttribute: - ((BooleanUserAttribute) ua).write(existing, b); - break; - default: - logger.warn("Unsupported value type for patch operation: " + value.getClass()); - break; - } - } - case REMOVE -> ua.write(existing, null); - } - } - /** * Finds a user * diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/users/UsersController.java b/src/main/java/fi/metatavu/keycloak/scim/server/users/UsersController.java index f8fdece..06adc09 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/users/UsersController.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/users/UsersController.java @@ -368,7 +368,7 @@ public fi.metatavu.keycloak.scim.server.model.User patchUser( * @param existing user being patched * @param value raw operation value */ - private void applyPatchValue( + protected void applyPatchValue( PatchOperation op, UserAttribute attr, UserModel existing, @@ -394,7 +394,7 @@ private void applyPatchValue( break; } } - case REMOVE -> attr.write(existing, null); + case REMOVE -> attr.clear(existing); } } diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/OrganizationUserPatchTestsIT.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/OrganizationUserPatchTestsIT.java index 9a79662..547dd5c 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/OrganizationUserPatchTestsIT.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/OrganizationUserPatchTestsIT.java @@ -207,6 +207,37 @@ void testPatchAttributes() throws ApiException { deleteRealmUser(TestConsts.ORGANIZATIONS_REALM, created.getId()); } + /** + * Regression: SCIM REMOVE on a USER_PROFILE-backed attribute in org-scope previously threw NPE + * because applyOrgPatchValue called attr.write(user, null) -> List.of(null). After the fix both + * realm and org controllers share applyPatchValue which routes REMOVE through attr.clear(user). + */ + @Test + void testRemoveExternalIdDoesNotNpe() throws ApiException { + ScimClient scimClient = getAuthenticatedScimClient(TestConsts.ORGANIZATION_1_ID); + + User created = new User(); + created.setUserName("org-remove-extid-test"); + created.setActive(true); + created.putAdditionalProperty("externalId", "00uORGREMOVETEST"); + User u = scimClient.createUser(created); + + try { + PatchRequest patch = new PatchRequest(); + patch.setSchemas(List.of("urn:ietf:params:scim:api:messages:2.0:PatchOp")); + PatchRequestOperationsInner op = new PatchRequestOperationsInner(); + op.setOp("remove"); + op.setPath("externalId"); + patch.setOperations(List.of(op)); + + // Before this fix: applyOrgPatchValue called attr.write(user, null) -> NPE -> HTTP 500. + User after = scimClient.patchUser(u.getId(), patch); + assertNull(after.getAdditionalProperty("externalId")); + } finally { + deleteRealmUser(TestConsts.ORGANIZATIONS_REALM, u.getId()); + } + } + @Test void testPatchUsernameEmailAsUsername() throws ApiException { ScimClient scimClient = getAuthenticatedScimClient(TestConsts.ORGANIZATION_EMAIL_AS_USERNAME_ID); diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserPatchTestsIT.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserPatchTestsIT.java index c2c4303..33a749c 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserPatchTestsIT.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserPatchTestsIT.java @@ -190,6 +190,37 @@ void testPatchUserAdminEvents() throws ApiException, IOException { deleteRealmUser(TestConsts.TEST_REALM, created.getId()); } + /** + * Regression: SCIM REMOVE on a USER_PROFILE-backed attribute (externalId, displayName, etc.) + * previously called attr.write(user, null) which passed null into List.of(value) and threw NPE, + * returning HTTP 500 instead of a clean removal. + */ + @Test + void testRemoveExternalIdDoesNotNpe() throws ApiException { + ScimClient scimClient = getAuthenticatedScimClient(); + + User created = new User(); + created.setUserName("remove-extid-test"); + created.setActive(true); + created.putAdditionalProperty("externalId", "00uREMOVETEST"); + User u = scimClient.createUser(created); + + try { + PatchRequest patch = new PatchRequest(); + patch.setSchemas(List.of("urn:ietf:params:scim:api:messages:2.0:PatchOp")); + PatchRequestOperationsInner op = new PatchRequestOperationsInner(); + op.setOp("remove"); + op.setPath("externalId"); + patch.setOperations(List.of(op)); + + // Before this fix: attr.write(user, null) -> List.of(null) -> NPE -> HTTP 500. + User after = scimClient.patchUser(u.getId(), patch); + assertNull(after.getAdditionalProperty("externalId")); + } finally { + deleteRealmUser(TestConsts.TEST_REALM, u.getId()); + } + } + /** * Okta's Deactivate User action emits a PATCH without a top-level "path", * carrying the attribute change inside a map-valued "value" (RFC 7644 From 5d7adc0fe087948aa72e934a68daea7bc384dd5b Mon Sep 17 00:00:00 2001 From: Danilo Acquaviva Date: Mon, 25 May 2026 18:15:33 +0100 Subject: [PATCH 27/35] Unify group MEMBERS handling, resolve atomically, reject non-String displayName --- .../scim/server/groups/GroupsController.java | 197 ++++++++-------- .../groups/InvalidGroupMemberReference.java | 16 ++ .../scim/server/realm/RealmScimServer.java | 3 + .../functional/RealmGroupPatchTestsIT.java | 218 ++++++++++++++++++ 4 files changed, 330 insertions(+), 104 deletions(-) create mode 100644 src/main/java/fi/metatavu/keycloak/scim/server/groups/InvalidGroupMemberReference.java diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/groups/GroupsController.java b/src/main/java/fi/metatavu/keycloak/scim/server/groups/GroupsController.java index 610c891..e4dc04e 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/groups/GroupsController.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/groups/GroupsController.java @@ -22,6 +22,7 @@ import org.keycloak.models.utils.ModelToRepresentation; import org.keycloak.representations.idm.GroupRepresentation; +import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -157,10 +158,7 @@ public fi.metatavu.keycloak.scim.server.model.Group patchGroup( ScimContext scimContext, GroupModel existing, fi.metatavu.keycloak.scim.server.model.PatchRequest patchRequest - ) throws UnsupportedGroupPath, UnsupportedPatchOperation { - KeycloakSession session = scimContext.getSession(); - RealmModel realm = scimContext.getRealm(); - + ) throws UnsupportedGroupPath, UnsupportedPatchOperation, InvalidGroupMemberReference { for (var operation : patchRequest.getOperations()) { PatchOperation op = PatchOperation.fromString(operation.getOp()); String path = operation.getPath(); @@ -200,7 +198,7 @@ public fi.metatavu.keycloak.scim.server.model.Group patchGroup( } // Extract base attribute path (e.g., "members" from "members[value eq \"id\"]") - String attributePath = path != null && path.contains("[") + String attributePath = path.contains("[") ? path.substring(0, path.indexOf("[")) : path; @@ -219,82 +217,39 @@ public fi.metatavu.keycloak.scim.server.model.Group patchGroup( break; } - switch (op) { - case REPLACE, ADD -> { - switch (groupAttribute) { - case DISPLAY_NAME -> existing.setName((String) value); - case MEMBERS -> { - // Clear current members if REPLACE, just add if ADD - if (op == PatchOperation.REPLACE) { - session.users().getGroupMembersStream(realm, existing) - .forEach(user -> user.leaveGroup(existing)); - } - - for (Object obj : (List) value) { - if (!(obj instanceof Map memberMap)) { - logger.warn("Invalid member object: " + obj); - continue; - } - - String memberId = (String) memberMap.get("value"); - if (memberId == null) { - logger.warn("Member value missing: " + obj); - continue; - } - - UserModel user = scimContext.getSession().users().getUserById(scimContext.getRealm(), memberId); - if (user != null) { - user.joinGroup(existing); - dispatchGroupMembershipJoinEvent(scimContext, existing, user); - } - } - } - } - } - - case REMOVE -> { - switch (groupAttribute) { - case DISPLAY_NAME -> existing.setName(null); - case MEMBERS -> { - // Handle path filter (e.g., "members[value eq \"user-id\"]") - if (path != null && path.contains("[")) { - String memberId = extractValueFromFilter(path); - if (memberId != null) { - UserModel user = session.users().getUserById(realm, memberId); - if (user != null) { - user.leaveGroup(existing); - dispatchGroupMembershipLeaveEvent(scimContext, existing, user); - } - } - } else if (value instanceof List list) { - // Handle direct value list - for (Object obj : list) { - if (obj instanceof Map memberMap) { - String memberId = (String) memberMap.get("value"); - if (memberId != null) { - UserModel user = session.users().getUserById(realm, memberId); - if (user != null) { - user.leaveGroup(existing); - dispatchGroupMembershipLeaveEvent(scimContext, existing, user); - } - } - } - } - } - } - } + // For REMOVE with a path filter (e.g. members[value eq "id"]), extract + // the member ID from the filter and wrap it in list form so applyGroupPatch + // can handle it uniformly. + Object effectiveValue = value; + if (op == PatchOperation.REMOVE && groupAttribute == GroupAttribute.MEMBERS && path.contains("[")) { + String memberId = extractValueFromFilter(path); + if (memberId != null) { + effectiveValue = List.of(Map.of("value", memberId)); + } else { + throw new UnsupportedGroupPath("Unsupported members filter: " + path); } } + + applyGroupPatch(scimContext, op, groupAttribute, path, effectiveValue, existing); } return translateGroup(scimContext, existing); } /** - * Apply a single attribute patch to the given group. + * Apply a single SCIM PatchOp on a Group. + * + *

Atomicity scope: per operation. If a PatchRequest contains multiple + * operations, each is applied independently in order. An earlier + * successful operation is NOT rolled back if a later one fails. * - * Used by the path-less PatchOp branch of {@link #patchGroup} to expand - * each entry of the map-valued 'value' into one logical operation. + *

For MEMBERS modifications (ADD/REPLACE/REMOVE), every incoming member + * ID is resolved via {@link #resolveMembers} before any mutation. The + * first unresolved ID raises {@link InvalidGroupMemberReference}, so the + * group's membership stays intact for that operation. + * + *

Used by both the path-less and path-based PatchOp branches of + * {@link #patchGroup}. */ private void applyGroupPatch( ScimContext scimContext, @@ -303,7 +258,7 @@ private void applyGroupPatch( String attrPath, Object value, GroupModel existing - ) { + ) throws InvalidGroupMemberReference, UnsupportedGroupPath { KeycloakSession session = scimContext.getSession(); RealmModel realm = scimContext.getRealm(); @@ -311,30 +266,23 @@ private void applyGroupPatch( case REPLACE, ADD -> { switch (attr) { case DISPLAY_NAME -> { - if (value instanceof String s) { - existing.setName(s); + if (!(value instanceof String s)) { + throw new UnsupportedGroupPath("displayName requires a string value"); } + existing.setName(s); } case MEMBERS -> { + List resolved = resolveMembers(session, realm, value); if (op == PatchOperation.REPLACE) { session.users().getGroupMembersStream(realm, existing) - .forEach(user -> user.leaveGroup(existing)); + .forEach(u -> { + u.leaveGroup(existing); + dispatchGroupMembershipLeaveEvent(scimContext, existing, u); + }); } - if (value instanceof List list) { - for (Object obj : list) { - if (!(obj instanceof Map memberMap)) { - continue; - } - String memberId = (String) memberMap.get("value"); - if (memberId == null) { - continue; - } - UserModel user = session.users().getUserById(realm, memberId); - if (user != null) { - user.joinGroup(existing); - dispatchGroupMembershipJoinEvent(scimContext, existing, user); - } - } + for (UserModel u : resolved) { + u.joinGroup(existing); + dispatchGroupMembershipJoinEvent(scimContext, existing, u); } } } @@ -343,19 +291,14 @@ private void applyGroupPatch( switch (attr) { case DISPLAY_NAME -> existing.setName(null); case MEMBERS -> { - if (value instanceof List list) { - for (Object obj : list) { - if (obj instanceof Map memberMap) { - String memberId = (String) memberMap.get("value"); - if (memberId != null) { - UserModel user = session.users().getUserById(realm, memberId); - if (user != null) { - user.leaveGroup(existing); - dispatchGroupMembershipLeaveEvent(scimContext, existing, user); - } - } - } - } + // REMOVE shares the strict resolution path with REPLACE/ADD: an unknown + // member id surfaces as 400 InvalidGroupMemberReference rather than a + // silent no-op. SCIM clients with stale state get an actionable error + // instead of believing the membership change went through. + List resolved = resolveMembers(session, realm, value); + for (UserModel u : resolved) { + u.leaveGroup(existing); + dispatchGroupMembershipLeaveEvent(scimContext, existing, u); } } } @@ -363,6 +306,52 @@ private void applyGroupPatch( } } + /** + * Resolve every member-id-shaped entry in {@code value} into a UserModel, + * failing with {@link InvalidGroupMemberReference} on the first unknown ID + * before any mutation. Accepts a List of Maps each carrying a "value" key. + * Returns an empty list for any non-list input (tolerates null/empty values + * on REMOVE operations). + * + *

Atomicity scope: within a single operation only. Resolution runs in + * full before any group membership is modified, so a bad ID aborts the + * operation without partially applying changes. It does NOT span multiple + * operations in the same PatchRequest (see {@link #applyGroupPatch}). + * + *

REMOVE uses this same path: an unknown member ID returns 400 rather + * than silently no-oping, so SCIM clients with stale state receive an + * actionable error instead of a false success. + */ + private List resolveMembers( + KeycloakSession session, + RealmModel realm, + Object value + ) throws InvalidGroupMemberReference { + List out = new ArrayList<>(); + if (!(value instanceof List list)) { + return out; + } + for (Object obj : list) { + if (!(obj instanceof Map memberMap)) { + continue; + } + Object idObj = memberMap.get("value"); + if (!(idObj instanceof String rawMemberId)) { + continue; + } + String memberId = rawMemberId.strip(); + if (memberId.isEmpty()) { + continue; + } + UserModel user = session.users().getUserById(realm, memberId); + if (user == null) { + throw new InvalidGroupMemberReference(memberId); + } + out.add(user); + } + return out; + } + /** * Deletes a group * diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/groups/InvalidGroupMemberReference.java b/src/main/java/fi/metatavu/keycloak/scim/server/groups/InvalidGroupMemberReference.java new file mode 100644 index 0000000..f83022b --- /dev/null +++ b/src/main/java/fi/metatavu/keycloak/scim/server/groups/InvalidGroupMemberReference.java @@ -0,0 +1,16 @@ +package fi.metatavu.keycloak.scim.server.groups; + +/** + * Thrown when a SCIM PATCH on a Group references one or more member IDs that + * do not resolve to a user in the realm. The entire patch is rejected without + * mutating membership, so the client receives an actionable 400 instead of an + * empty / truncated group with HTTP 200. + */ +public class InvalidGroupMemberReference extends Exception { + + private static final long serialVersionUID = 1L; + + public InvalidGroupMemberReference(String memberId) { + super("Unknown group member: " + memberId); + } +} diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimServer.java b/src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimServer.java index 110343a..38e9695 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimServer.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimServer.java @@ -4,6 +4,7 @@ import fi.metatavu.keycloak.scim.server.ScimErrors; import fi.metatavu.keycloak.scim.server.config.ConfigurationError; import fi.metatavu.keycloak.scim.server.filter.ScimFilter; +import fi.metatavu.keycloak.scim.server.groups.InvalidGroupMemberReference; import fi.metatavu.keycloak.scim.server.groups.UnsupportedGroupPath; import fi.metatavu.keycloak.scim.server.metadata.UserAttributes; import fi.metatavu.keycloak.scim.server.model.User; @@ -254,6 +255,8 @@ public Response patchGroup(RealmScimContext scimContext, String groupId, fi.meta try { fi.metatavu.keycloak.scim.server.model.Group updated = groupsController.patchGroup(scimContext, existing, patchRequest); return Response.ok(updated).build(); + } catch (InvalidGroupMemberReference e) { + return ScimErrors.badRequest(e.getMessage()); } catch (UnsupportedGroupPath e) { return ScimErrors.badRequest(e.getMessage() != null ? e.getMessage() : "Unsupported group path"); } catch (UnsupportedPatchOperation e) { diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmGroupPatchTestsIT.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmGroupPatchTestsIT.java index 671533e..d86762b 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmGroupPatchTestsIT.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmGroupPatchTestsIT.java @@ -14,6 +14,7 @@ import java.io.IOException; import java.util.Collections; import java.util.List; +import java.util.Map; import static org.junit.jupiter.api.Assertions.*; @@ -208,4 +209,221 @@ void testRemoveGroupMemberAdminEvents() throws ApiException, IOException { deleteRealmUser(TestConsts.TEST_REALM, user.getId()); deleteRealmGroup(TestConsts.TEST_REALM, group.getId()); } + + /** + * A REPLACE operation that includes one valid and one unknown member ID must + * be rejected atomically: HTTP 400 with an error body naming the bad ID, and + * the group's original membership must be unchanged. + */ + @Test + void testReplaceMembersRejectsUnknownIdWithoutMutation() throws ApiException, IOException { + ScimClient scimClient = getAuthenticatedScimClient(); + + User user = createUser(scimClient, "atomic-1", "Atomic", "One"); + Group group = createGroup(scimClient, "atomic-group"); + + try { + // Seed with a known member + GroupMembersInner known = new GroupMembersInner(); + known.setValue(user.getId()); + PatchRequest seed = new PatchRequest(); + seed.setSchemas(List.of("urn:ietf:params:scim:api:messages:2.0:PatchOp")); + PatchRequestOperationsInner addOp = new PatchRequestOperationsInner(); + addOp.setOp("add"); + addOp.setPath("members"); + addOp.setValue(List.of(known)); + seed.setOperations(List.of(addOp)); + scimClient.patchGroup(group.getId(), seed); + + // REPLACE with [known, unknown] -- must fail atomically with HTTP 400. + GroupMembersInner unknown = new GroupMembersInner(); + unknown.setValue("00000000-0000-0000-0000-000000000000"); + PatchRequest replace = new PatchRequest(); + replace.setSchemas(List.of("urn:ietf:params:scim:api:messages:2.0:PatchOp")); + PatchRequestOperationsInner replaceOp = new PatchRequestOperationsInner(); + replaceOp.setOp("replace"); + replaceOp.setPath("members"); + replaceOp.setValue(List.of(known, unknown)); + replace.setOperations(List.of(replaceOp)); + + ApiException ex = assertThrows(ApiException.class, + () -> scimClient.patchGroup(group.getId(), replace)); + assertEquals(400, ex.getCode()); + + com.fasterxml.jackson.databind.JsonNode body = + new com.fasterxml.jackson.databind.ObjectMapper().readTree(ex.getResponseBody()); + assertEquals("400", body.get("status").asText()); + assertTrue(body.get("detail").asText().contains("00000000-0000-0000-0000-000000000000"), + "detail should name the unknown member id; got: " + body.get("detail").asText()); + + // Group state must be unchanged: original known member still present. + Group after = scimClient.findGroup(group.getId()); + assertNotNull(after.getMembers()); + assertEquals(1, after.getMembers().size()); + assertEquals(user.getId(), after.getMembers().get(0).getValue()); + } finally { + deleteRealmUser(TestConsts.TEST_REALM, user.getId()); + deleteRealmGroup(TestConsts.TEST_REALM, group.getId()); + } + } + + /** + * Same atomicity guarantee for the path-less PatchOp shape (Okta Group Push): + * {"op":"replace","value":{"members":[...]}}. An unknown member ID must yield + * HTTP 400 without mutating the group. + */ + @Test + void testReplaceMembersPathLessRejectsUnknownIdWithoutMutation() throws ApiException, IOException { + ScimClient scimClient = getAuthenticatedScimClient(); + + User user = createUser(scimClient, "atomic-pathless-1", "Atomic", "Pathless"); + Group group = createGroup(scimClient, "atomic-pathless-group"); + + try { + // Seed with a known member via path-based ADD + GroupMembersInner known = new GroupMembersInner(); + known.setValue(user.getId()); + PatchRequest seed = new PatchRequest(); + seed.setSchemas(List.of("urn:ietf:params:scim:api:messages:2.0:PatchOp")); + PatchRequestOperationsInner addOp = new PatchRequestOperationsInner(); + addOp.setOp("add"); + addOp.setPath("members"); + addOp.setValue(List.of(known)); + seed.setOperations(List.of(addOp)); + scimClient.patchGroup(group.getId(), seed); + + // Path-less REPLACE with one known + one unknown member. + // Shape: {"op":"replace","value":{"members":[{"value":""}, {"value":""}]}} + Map knownMap = Map.of("value", user.getId()); + Map unknownMap = Map.of("value", "00000000-0000-0000-0000-000000000000"); + PatchRequest replace = new PatchRequest(); + replace.setSchemas(List.of("urn:ietf:params:scim:api:messages:2.0:PatchOp")); + PatchRequestOperationsInner replaceOp = new PatchRequestOperationsInner(); + replaceOp.setOp("replace"); + // No path -- value is a map of attribute -> list, Okta Group Push shape + replaceOp.setValue(Map.of("members", List.of(knownMap, unknownMap))); + replace.setOperations(List.of(replaceOp)); + + ApiException ex = assertThrows(ApiException.class, + () -> scimClient.patchGroup(group.getId(), replace)); + assertEquals(400, ex.getCode()); + + com.fasterxml.jackson.databind.JsonNode body = + new com.fasterxml.jackson.databind.ObjectMapper().readTree(ex.getResponseBody()); + assertEquals("400", body.get("status").asText()); + assertTrue(body.get("detail").asText().contains("00000000-0000-0000-0000-000000000000"), + "detail should name the unknown member id; got: " + body.get("detail").asText()); + + // Group state must be unchanged: original known member still present. + Group after = scimClient.findGroup(group.getId()); + assertNotNull(after.getMembers()); + assertEquals(1, after.getMembers().size()); + assertEquals(user.getId(), after.getMembers().get(0).getValue()); + } finally { + deleteRealmUser(TestConsts.TEST_REALM, user.getId()); + deleteRealmGroup(TestConsts.TEST_REALM, group.getId()); + } + } + + @Test + void testRemoveUnknownMemberIsRejected() throws ApiException, java.io.IOException { + ScimClient scimClient = getAuthenticatedScimClient(); + + User user = createUser(scimClient, "remove-unknown-1", "Remove", "Unknown"); + Group group = createGroup(scimClient, "remove-unknown-group"); + + try { + // Seed with the known member + GroupMembersInner known = new GroupMembersInner(); + known.setValue(user.getId()); + PatchRequest seed = new PatchRequest(); + seed.setSchemas(List.of("urn:ietf:params:scim:api:messages:2.0:PatchOp")); + PatchRequestOperationsInner addOp = new PatchRequestOperationsInner(); + addOp.setOp("add"); + addOp.setPath("members"); + addOp.setValue(List.of(known)); + seed.setOperations(List.of(addOp)); + scimClient.patchGroup(group.getId(), seed); + + // REMOVE [unknown] should fail 400 atomically; the known member stays. + GroupMembersInner unknown = new GroupMembersInner(); + unknown.setValue("11111111-1111-1111-1111-111111111111"); + PatchRequest remove = new PatchRequest(); + remove.setSchemas(List.of("urn:ietf:params:scim:api:messages:2.0:PatchOp")); + PatchRequestOperationsInner removeOp = new PatchRequestOperationsInner(); + removeOp.setOp("remove"); + removeOp.setPath("members"); + removeOp.setValue(List.of(unknown)); + remove.setOperations(List.of(removeOp)); + + ApiException ex = assertThrows(ApiException.class, + () -> scimClient.patchGroup(group.getId(), remove)); + assertEquals(400, ex.getCode()); + com.fasterxml.jackson.databind.JsonNode body = + new com.fasterxml.jackson.databind.ObjectMapper().readTree(ex.getResponseBody()); + assertTrue(body.get("detail").asText().contains("11111111-1111-1111-1111-111111111111"), + "detail should name the unknown member id; got: " + body.get("detail").asText()); + + // Known member untouched + Group after = scimClient.findGroup(group.getId()); + assertEquals(1, after.getMembers().size()); + assertEquals(user.getId(), after.getMembers().get(0).getValue()); + } finally { + deleteRealmUser(TestConsts.TEST_REALM, user.getId()); + deleteRealmGroup(TestConsts.TEST_REALM, group.getId()); + } + } + + /** + * REMOVE with an unquoted (malformed) filter value must return HTTP 400 + * with a SCIM error body, not silently succeed. + * Example malformed path: members[value eq abc] (no quotes around the id) + */ + @Test + void testRemoveMemberWithMalformedFilterReturns400() throws ApiException, IOException { + ScimClient scimClient = getAuthenticatedScimClient(); + + User user = createUser(scimClient, "malformed-filter-user", "Malformed", "Filter"); + Group group = createGroup(scimClient, "malformed-filter-group"); + + try { + // Seed with a known member so the group is non-empty + GroupMembersInner member = new GroupMembersInner(); + member.setValue(user.getId()); + PatchRequest seed = new PatchRequest(); + seed.setSchemas(List.of("urn:ietf:params:scim:api:messages:2.0:PatchOp")); + PatchRequestOperationsInner addOp = new PatchRequestOperationsInner(); + addOp.setOp("add"); + addOp.setPath("members"); + addOp.setValue(List.of(member)); + seed.setOperations(List.of(addOp)); + scimClient.patchGroup(group.getId(), seed); + + // REMOVE with unquoted filter value -- must be rejected with 400. + PatchRequest remove = new PatchRequest(); + remove.setSchemas(List.of("urn:ietf:params:scim:api:messages:2.0:PatchOp")); + PatchRequestOperationsInner removeOp = new PatchRequestOperationsInner(); + removeOp.setOp("remove"); + // Intentionally malformed: value not quoted + removeOp.setPath("members[value eq " + user.getId() + "]"); + remove.setOperations(List.of(removeOp)); + + ApiException ex = assertThrows(ApiException.class, + () -> scimClient.patchGroup(group.getId(), remove)); + assertEquals(400, ex.getCode()); + + com.fasterxml.jackson.databind.JsonNode body = + new com.fasterxml.jackson.databind.ObjectMapper().readTree(ex.getResponseBody()); + assertEquals("400", body.get("status").asText()); + + // Group state must be unchanged: original member still present. + Group after = scimClient.findGroup(group.getId()); + assertNotNull(after.getMembers()); + assertEquals(1, after.getMembers().size()); + assertEquals(user.getId(), after.getMembers().get(0).getValue()); + } finally { + deleteRealmUser(TestConsts.TEST_REALM, user.getId()); + deleteRealmGroup(TestConsts.TEST_REALM, group.getId()); + } + } } From 673fac3dcf5d34f0cf7007a16a18a5e95018262e Mon Sep 17 00:00:00 2001 From: Danilo Acquaviva Date: Mon, 25 May 2026 18:42:02 +0100 Subject: [PATCH 28/35] Assert SCIM Error status + body structure in list-filter tests --- .../OrganizationUserListTestsIT.java | 13 +++---- .../functional/RealmGroupListTestsIT.java | 10 ++--- .../functional/RealmUserListTestsIT.java | 13 +++---- .../test/utils/ScimErrorAssertions.java | 37 +++++++++++++++++++ 4 files changed, 51 insertions(+), 22 deletions(-) create mode 100644 src/test/java/fi/metatavu/keycloak/scim/server/test/utils/ScimErrorAssertions.java diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/OrganizationUserListTestsIT.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/OrganizationUserListTestsIT.java index f6e3bf4..d0890e5 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/OrganizationUserListTestsIT.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/OrganizationUserListTestsIT.java @@ -14,6 +14,7 @@ import java.util.ArrayList; import java.util.List; +import fi.metatavu.keycloak.scim.server.test.utils.ScimErrorAssertions; import static org.junit.jupiter.api.Assertions.*; /** @@ -228,8 +229,7 @@ void testInvalidFilterMissingOperator() { scimClient.listUsers("userName \"bob\"", 0, 10) ); - assertTrue(exception.getMessage().contains("Invalid filter"), - "Expected error message to contain 'Invalid filter'; got: " + exception.getMessage()); + ScimErrorAssertions.assertScimError(exception, 400, "Invalid filter"); } @Test @@ -240,8 +240,7 @@ void testInvalidFilterUnsupportedOperator() { scimClient.listUsers("userName gt \"bob\"", 0, 10) ); - assertTrue(exception.getMessage().contains("Invalid filter"), - "Expected error message to contain 'Invalid filter'; got: " + exception.getMessage()); + ScimErrorAssertions.assertScimError(exception, 400, "Invalid filter"); } @Test @@ -252,8 +251,7 @@ void testInvalidFilterUnquotedString() { scimClient.listUsers("userName eq bob", 0, 10) ); - assertTrue(exception.getMessage().contains("Invalid filter"), - "Expected error message to contain 'Invalid filter'; got: " + exception.getMessage()); + ScimErrorAssertions.assertScimError(exception, 400, "Invalid filter"); } @Test @@ -264,8 +262,7 @@ void testInvalidFilterBadLogicalStructure() { scimClient.listUsers("userName eq \"a\" and", 0, 10) ); - assertTrue(exception.getMessage().contains("Invalid filter"), - "Expected error message to contain 'Invalid filter'; got: " + exception.getMessage()); + ScimErrorAssertions.assertScimError(exception, 400, "Invalid filter"); } @Test diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmGroupListTestsIT.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmGroupListTestsIT.java index 879536e..2641535 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmGroupListTestsIT.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmGroupListTestsIT.java @@ -13,6 +13,7 @@ import java.util.Collections; import java.util.List; +import fi.metatavu.keycloak.scim.server.test.utils.ScimErrorAssertions; import static org.junit.jupiter.api.Assertions.*; /** @@ -200,8 +201,7 @@ void testInvalidFilterMissingOperator() throws ApiException { scimClient.listGroups("displayName \"test\"", 0, 10) ); - assertTrue(exception.getMessage().contains("Invalid filter"), - "Expected error message to contain 'Invalid filter'; got: " + exception.getMessage()); + ScimErrorAssertions.assertScimError(exception, 400, "Invalid filter"); } finally { deleteGroup(scimClient, group.getId()); } @@ -219,8 +219,7 @@ void testInvalidFilterUnquotedString() throws ApiException { scimClient.listGroups("displayName eq test", 0, 10) ); - assertTrue(exception.getMessage().contains("Invalid filter"), - "Expected error message to contain 'Invalid filter'; got: " + exception.getMessage()); + ScimErrorAssertions.assertScimError(exception, 400, "Invalid filter"); } finally { deleteGroup(scimClient, group.getId()); } @@ -238,8 +237,7 @@ void testInvalidFilterBadLogicalStructure() throws ApiException { scimClient.listGroups("displayName eq \"test\" and", 0, 10) ); - assertTrue(exception.getMessage().contains("Invalid filter"), - "Expected error message to contain 'Invalid filter'; got: " + exception.getMessage()); + ScimErrorAssertions.assertScimError(exception, 400, "Invalid filter"); } finally { deleteGroup(scimClient, group.getId()); } diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserListTestsIT.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserListTestsIT.java index b350554..acf5585 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserListTestsIT.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserListTestsIT.java @@ -12,6 +12,7 @@ import java.util.ArrayList; import java.util.List; +import fi.metatavu.keycloak.scim.server.test.utils.ScimErrorAssertions; import static org.junit.jupiter.api.Assertions.*; /** @@ -211,8 +212,7 @@ void testInvalidFilterMissingOperator() { scimClient.listUsers("userName \"bob\"", 0, 10) ); - assertTrue(exception.getMessage().contains("Invalid filter"), - "Expected error message to contain 'Invalid filter'; got: " + exception.getMessage()); + ScimErrorAssertions.assertScimError(exception, 400, "Invalid filter"); } @Test @@ -223,8 +223,7 @@ void testInvalidFilterUnsupportedOperator() { scimClient.listUsers("userName gt \"bob\"", 0, 10) ); - assertTrue(exception.getMessage().contains("Invalid filter"), - "Expected error message to contain 'Invalid filter'; got: " + exception.getMessage()); + ScimErrorAssertions.assertScimError(exception, 400, "Invalid filter"); } @Test @@ -235,8 +234,7 @@ void testInvalidFilterUnquotedString() { scimClient.listUsers("userName eq bob", 0, 10) ); - assertTrue(exception.getMessage().contains("Invalid filter"), - "Expected error message to contain 'Invalid filter'; got: " + exception.getMessage()); + ScimErrorAssertions.assertScimError(exception, 400, "Invalid filter"); } @Test @@ -247,8 +245,7 @@ void testInvalidFilterBadLogicalStructure() { scimClient.listUsers("userName eq \"a\" and", 0, 10) ); - assertTrue(exception.getMessage().contains("Invalid filter"), - "Expected error message to contain 'Invalid filter'; got: " + exception.getMessage()); + ScimErrorAssertions.assertScimError(exception, 400, "Invalid filter"); } @Test diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/utils/ScimErrorAssertions.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/utils/ScimErrorAssertions.java new file mode 100644 index 0000000..d656a35 --- /dev/null +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/utils/ScimErrorAssertions.java @@ -0,0 +1,37 @@ +package fi.metatavu.keycloak.scim.server.test.utils; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import fi.metatavu.keycloak.scim.server.test.client.ApiException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public final class ScimErrorAssertions { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error"; + + private ScimErrorAssertions() {} + + /** + * Assert the ApiException carries a SCIM 2.0 Error JSON body with the + * given HTTP status and a detail field that contains {@code detailSubstring}. + */ + public static void assertScimError(ApiException ex, int expectedStatus, String detailSubstring) { + assertEquals(expectedStatus, ex.getCode(), + "HTTP status mismatch; body=" + ex.getResponseBody()); + JsonNode body; + try { + body = MAPPER.readTree(ex.getResponseBody()); + } catch (Exception e) { + throw new AssertionError("ApiException body is not valid JSON: " + ex.getResponseBody(), e); + } + assertTrue(body.path("schemas").toString().contains(ERROR_SCHEMA), + "schemas should contain Error URN; got: " + body.path("schemas")); + assertEquals(Integer.toString(expectedStatus), body.path("status").asText(), + "SCIM Error status field mismatch; body=" + body); + assertTrue(body.path("detail").asText().contains(detailSubstring), + "detail should contain '" + detailSubstring + "'; got: " + body.path("detail")); + } +} From c966217872d5146f6d96d4e921f47a0900f2e167 Mon Sep 17 00:00:00 2001 From: Danilo Acquaviva Date: Mon, 25 May 2026 18:57:18 +0100 Subject: [PATCH 29/35] Cover path-less Group PatchOp add-member with an integration test --- .../functional/RealmGroupPatchTestsIT.java | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmGroupPatchTestsIT.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmGroupPatchTestsIT.java index d86762b..75087b5 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmGroupPatchTestsIT.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmGroupPatchTestsIT.java @@ -426,4 +426,47 @@ void testRemoveMemberWithMalformedFilterReturns400() throws ApiException, IOExce deleteRealmGroup(TestConsts.TEST_REALM, group.getId()); } } + + /** + * Okta Group Push wire shape: path-less PatchOp where members are nested under the value map. + * Example body: {"op":"replace","value":{"members":[{"value":""}]}} + */ + @Test + void testAddMemberPathLessPatchOp() throws ApiException { + ScimClient scimClient = getAuthenticatedScimClient(); + + User user = createUser(scimClient, "pathless-add-1", "Pathless", "Add"); + Group group = createGroup(scimClient, "pathless-add-group"); + + try { + // Okta Group Push wire shape: no "path", members nested under the value map. + PatchRequest patchRequest = new PatchRequest(); + patchRequest.setSchemas(List.of("urn:ietf:params:scim:api:messages:2.0:PatchOp")); + + PatchRequestOperationsInner op = new PatchRequestOperationsInner(); + // Okta's Group Push uses op=replace (not add) for the path-less wire shape, + // even when populating an empty group for the first time. The members list + // inside `value` is the new authoritative set. + op.setOp("replace"); + op.setValue(Map.of("members", List.of(Map.of("value", user.getId())))); + + patchRequest.setOperations(List.of(op)); + + Group patched = scimClient.patchGroup(group.getId(), patchRequest); + + assertNotNull(patched); + assertNotNull(patched.getMembers()); + assertEquals(1, patched.getMembers().size()); + assertEquals(user.getId(), patched.getMembers().get(0).getValue()); + + // Verify via a fresh GET as well. + Group after = scimClient.findGroup(group.getId()); + assertNotNull(after.getMembers()); + assertEquals(1, after.getMembers().size()); + assertEquals(user.getId(), after.getMembers().get(0).getValue()); + } finally { + deleteRealmUser(TestConsts.TEST_REALM, user.getId()); + deleteRealmGroup(TestConsts.TEST_REALM, group.getId()); + } + } } From 0cca738beb07b18007247b4187a663ce1af4087d Mon Sep 17 00:00:00 2001 From: Danilo Acquaviva Date: Mon, 25 May 2026 19:17:33 +0100 Subject: [PATCH 30/35] Extract seed + assert helpers in group-patch atomic tests --- .../functional/RealmGroupPatchTestsIT.java | 97 ++++++++----------- 1 file changed, 42 insertions(+), 55 deletions(-) diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmGroupPatchTestsIT.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmGroupPatchTestsIT.java index 7267575..4001f5f 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmGroupPatchTestsIT.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmGroupPatchTestsIT.java @@ -225,18 +225,11 @@ void testReplaceMembersRejectsUnknownIdWithoutMutation() throws ApiException, IO try { // Seed with a known member - GroupMembersInner known = new GroupMembersInner(); - known.setValue(user.getId()); - PatchRequest seed = new PatchRequest(); - seed.setSchemas(List.of("urn:ietf:params:scim:api:messages:2.0:PatchOp")); - PatchRequestOperationsInner addOp = new PatchRequestOperationsInner(); - addOp.setOp("add"); - addOp.setPath("members"); - addOp.setValue(List.of(known)); - seed.setOperations(List.of(addOp)); - scimClient.patchGroup(group.getId(), seed); + seedGroupWithMember(scimClient, group, user); // REPLACE with [known, unknown] -- must fail atomically with HTTP 400. + GroupMembersInner known = new GroupMembersInner(); + known.setValue(user.getId()); GroupMembersInner unknown = new GroupMembersInner(); unknown.setValue("00000000-0000-0000-0000-000000000000"); PatchRequest replace = new PatchRequest(); @@ -258,10 +251,7 @@ void testReplaceMembersRejectsUnknownIdWithoutMutation() throws ApiException, IO "detail should name the unknown member id; got: " + body.get("detail").asText()); // Group state must be unchanged: original known member still present. - Group after = scimClient.findGroup(group.getId()); - assertNotNull(after.getMembers()); - assertEquals(1, after.getMembers().size()); - assertEquals(user.getId(), after.getMembers().get(0).getValue()); + assertGroupHasOnlyMember(scimClient, group, user); } finally { deleteRealmUser(TestConsts.TEST_REALM, user.getId()); deleteRealmGroup(TestConsts.TEST_REALM, group.getId()); @@ -282,16 +272,7 @@ void testReplaceMembersPathLessRejectsUnknownIdWithoutMutation() throws ApiExcep try { // Seed with a known member via path-based ADD - GroupMembersInner known = new GroupMembersInner(); - known.setValue(user.getId()); - PatchRequest seed = new PatchRequest(); - seed.setSchemas(List.of("urn:ietf:params:scim:api:messages:2.0:PatchOp")); - PatchRequestOperationsInner addOp = new PatchRequestOperationsInner(); - addOp.setOp("add"); - addOp.setPath("members"); - addOp.setValue(List.of(known)); - seed.setOperations(List.of(addOp)); - scimClient.patchGroup(group.getId(), seed); + seedGroupWithMember(scimClient, group, user); // Path-less REPLACE with one known + one unknown member. // Shape: {"op":"replace","value":{"members":[{"value":""}, {"value":""}]}} @@ -316,10 +297,7 @@ void testReplaceMembersPathLessRejectsUnknownIdWithoutMutation() throws ApiExcep "detail should name the unknown member id; got: " + body.get("detail").asText()); // Group state must be unchanged: original known member still present. - Group after = scimClient.findGroup(group.getId()); - assertNotNull(after.getMembers()); - assertEquals(1, after.getMembers().size()); - assertEquals(user.getId(), after.getMembers().get(0).getValue()); + assertGroupHasOnlyMember(scimClient, group, user); } finally { deleteRealmUser(TestConsts.TEST_REALM, user.getId()); deleteRealmGroup(TestConsts.TEST_REALM, group.getId()); @@ -335,16 +313,7 @@ void testRemoveUnknownMemberIsRejected() throws ApiException, java.io.IOExceptio try { // Seed with the known member - GroupMembersInner known = new GroupMembersInner(); - known.setValue(user.getId()); - PatchRequest seed = new PatchRequest(); - seed.setSchemas(List.of("urn:ietf:params:scim:api:messages:2.0:PatchOp")); - PatchRequestOperationsInner addOp = new PatchRequestOperationsInner(); - addOp.setOp("add"); - addOp.setPath("members"); - addOp.setValue(List.of(known)); - seed.setOperations(List.of(addOp)); - scimClient.patchGroup(group.getId(), seed); + seedGroupWithMember(scimClient, group, user); // REMOVE [unknown] should fail 400 atomically; the known member stays. GroupMembersInner unknown = new GroupMembersInner(); @@ -366,9 +335,7 @@ void testRemoveUnknownMemberIsRejected() throws ApiException, java.io.IOExceptio "detail should name the unknown member id; got: " + body.get("detail").asText()); // Known member untouched - Group after = scimClient.findGroup(group.getId()); - assertEquals(1, after.getMembers().size()); - assertEquals(user.getId(), after.getMembers().get(0).getValue()); + assertGroupHasOnlyMember(scimClient, group, user); } finally { deleteRealmUser(TestConsts.TEST_REALM, user.getId()); deleteRealmGroup(TestConsts.TEST_REALM, group.getId()); @@ -389,16 +356,7 @@ void testRemoveMemberWithMalformedFilterReturns400() throws ApiException, IOExce try { // Seed with a known member so the group is non-empty - GroupMembersInner member = new GroupMembersInner(); - member.setValue(user.getId()); - PatchRequest seed = new PatchRequest(); - seed.setSchemas(List.of("urn:ietf:params:scim:api:messages:2.0:PatchOp")); - PatchRequestOperationsInner addOp = new PatchRequestOperationsInner(); - addOp.setOp("add"); - addOp.setPath("members"); - addOp.setValue(List.of(member)); - seed.setOperations(List.of(addOp)); - scimClient.patchGroup(group.getId(), seed); + seedGroupWithMember(scimClient, group, user); // REMOVE with unquoted filter value -- must be rejected with 400. PatchRequest remove = new PatchRequest(); @@ -418,10 +376,7 @@ void testRemoveMemberWithMalformedFilterReturns400() throws ApiException, IOExce assertEquals("400", body.get("status").asText()); // Group state must be unchanged: original member still present. - Group after = scimClient.findGroup(group.getId()); - assertNotNull(after.getMembers()); - assertEquals(1, after.getMembers().size()); - assertEquals(user.getId(), after.getMembers().get(0).getValue()); + assertGroupHasOnlyMember(scimClient, group, user); } finally { deleteRealmUser(TestConsts.TEST_REALM, user.getId()); deleteRealmGroup(TestConsts.TEST_REALM, group.getId()); @@ -517,4 +472,36 @@ void testRemoveGroupMemberWithoutEmail() throws ApiException { deleteRealmUser(TestConsts.TEST_REALM, user.getId()); deleteRealmGroup(TestConsts.TEST_REALM, group.getId()); } + + // --- helpers shared by the atomic-resolution tests --- + + /** + * Seed a group with a single member via a path-based ADD members PatchOp. + * Mirrors what an upstream IdP would push to establish initial membership + * before the test exercises a subsequent REPLACE/REMOVE op. + */ + private void seedGroupWithMember(ScimClient scimClient, Group group, User user) throws ApiException { + PatchRequest seed = new PatchRequest(); + seed.setSchemas(List.of("urn:ietf:params:scim:api:messages:2.0:PatchOp")); + PatchRequestOperationsInner addOp = new PatchRequestOperationsInner(); + addOp.setOp("add"); + addOp.setPath("members"); + GroupMembersInner known = new GroupMembersInner(); + known.setValue(user.getId()); + addOp.setValue(List.of(known)); + seed.setOperations(List.of(addOp)); + scimClient.patchGroup(group.getId(), seed); + } + + /** + * Re-fetch the group and assert it has exactly one member, whose value + * matches the given user's id. Used to confirm membership is unchanged + * after a failed atomic PatchOp. + */ + private void assertGroupHasOnlyMember(ScimClient scimClient, Group group, User user) throws ApiException { + Group after = scimClient.findGroup(group.getId()); + assertNotNull(after.getMembers()); + assertEquals(1, after.getMembers().size()); + assertEquals(user.getId(), after.getMembers().get(0).getValue()); + } } From 936a5436134bce62ad203d309f77b1b120aecbb3 Mon Sep 17 00:00:00 2001 From: Danilo Acquaviva Date: Mon, 25 May 2026 19:27:54 +0100 Subject: [PATCH 31/35] Extract applyPatchOperations to share PATCH loop between realm + org controllers --- .../OrganizationUserController.java | 47 +---------------- .../scim/server/users/UsersController.java | 51 +++++++++++++------ 2 files changed, 36 insertions(+), 62 deletions(-) diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationUserController.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationUserController.java index 5a0f470..6debf60 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationUserController.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationUserController.java @@ -207,52 +207,7 @@ public fi.metatavu.keycloak.scim.server.model.User patchOrganizationUser( RealmModel realm = scimContext.getRealm(); ScimConfig config = scimContext.getConfig(); - for (var operation : patchRequest.getOperations()) { - PatchOperation op = PatchOperation.fromString(operation.getOp()); - if (op == null) { - logger.warn("Invalid patch operation: " + operation.getOp()); - throw new UnsupportedPatchOperation("Unsupported patch operation: " + operation.getOp()); - } - - String path = operation.getPath(); - Object value = operation.getValue(); - - // RFC 7644 §3.5.2: when "path" is omitted, "value" carries a map of - // attribute -> value to apply to the resource. Same shape Okta uses - // for Deactivate User on the realm scope; mirror the realm-scope - // handling here so org-scope users do not throw UnsupportedUserPath. - if (path == null) { - if (!(value instanceof java.util.Map valueMap)) { - throw new UnsupportedUserPath("PatchOp without 'path' requires a map-valued 'value'"); - } - for (java.util.Map.Entry entry : valueMap.entrySet()) { - String attrPath = String.valueOf(entry.getKey()); - if (isReadOnlyOrStructural(attrPath)) { - // RFC 7644 §3.5.2 / §7.5: ignore read-only and - // structural attributes (id, meta, schemas). - continue; - } - UserAttribute ua = userAttributes.findByScimPath(attrPath); - if (ua == null) { - throw new UnsupportedUserPath("Unsupported attribute: " + attrPath); - } - applyPatchValue(op, ua, existing, entry.getValue()); - } - continue; - } - - if (isReadOnlyOrStructural(path)) { - continue; - } - - UserAttribute userAttribute = userAttributes.findByScimPath(path); - - if (userAttribute == null) { - throw new UnsupportedUserPath("Unsupported attribute: " + path); - } - - applyPatchValue(op, userAttribute, existing, value); - } + applyPatchOperations(userAttributes, existing, patchRequest); fi.metatavu.keycloak.scim.server.model.User patchedUser = translateUser( scimContext, diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/users/UsersController.java b/src/main/java/fi/metatavu/keycloak/scim/server/users/UsersController.java index 06adc09..4ed2e1e 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/users/UsersController.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/users/UsersController.java @@ -294,6 +294,41 @@ public fi.metatavu.keycloak.scim.server.model.User patchUser( UserAttributes userAttributes, UserModel existing, fi.metatavu.keycloak.scim.server.model.PatchRequest patchRequest + ) throws UnsupportedPatchOperation { + applyPatchOperations(userAttributes, existing, patchRequest); + + dispatchUserUpdateEvent(scimContext, existing); + + final User patchedUser = translateUser(scimContext, userAttributes, existing); + + if (scimContext.getConfig().getLinkIdp()) { + KeycloakSession session = scimContext.getSession(); + RealmModel realm = scimContext.getRealm(); + String scimUsername = patchedUser.getUserName(); + String externalId = getExternalId(patchedUser); + String idpAlias = scimContext.getConfig().getIdentityProviderAlias(); + linkUserIdp(session, realm, existing, scimUsername, externalId, idpAlias); + } + + + return patchedUser; + } + + /** + * Walk a PatchRequest's operations and apply each one to {@code existing}. + * Shared between {@link #patchUser} and + * {@link fi.metatavu.keycloak.scim.server.organization.OrganizationUserController#patchOrganizationUser} + * so the realm-scope and org-scope SCIM PATCH endpoints handle path-less / + * path-based shapes and read-only / structural attributes identically. + * + * @param userAttributes user attributes metadata + * @param existing user being patched + * @param patchRequest SCIM patch request + */ + protected void applyPatchOperations( + UserAttributes userAttributes, + UserModel existing, + fi.metatavu.keycloak.scim.server.model.PatchRequest patchRequest ) throws UnsupportedPatchOperation { for (var operation : patchRequest.getOperations()) { PatchOperation op = PatchOperation.fromString(operation.getOp()); @@ -339,22 +374,6 @@ public fi.metatavu.keycloak.scim.server.model.User patchUser( } applyPatchValue(op, userAttribute, existing, value); } - - dispatchUserUpdateEvent(scimContext, existing); - - final User patchedUser = translateUser(scimContext, userAttributes, existing); - - if (scimContext.getConfig().getLinkIdp()) { - KeycloakSession session = scimContext.getSession(); - RealmModel realm = scimContext.getRealm(); - String scimUsername = patchedUser.getUserName(); - String externalId = getExternalId(patchedUser); - String idpAlias = scimContext.getConfig().getIdentityProviderAlias(); - linkUserIdp(session, realm, existing, scimUsername, externalId, idpAlias); - } - - - return patchedUser; } /** From 519adc7cea83e1cce94e7991cc211023c7092f21 Mon Sep 17 00:00:00 2001 From: "Mercedes.Segura" Date: Wed, 27 May 2026 16:48:37 +0200 Subject: [PATCH 32/35] feat: enhance user attribute handling with improved error logging and support for custom attributes --- .../server/metadata/MetadataController.java | 50 +++++++++++-------- .../scim/server/users/UsersController.java | 11 ++-- .../functional/RealmUserCreateTestsIT.java | 1 + .../functional/RealmUserPatchTestsIT.java | 1 + .../functional/RealmUserUpdateTestsIT.java | 1 + 5 files changed, 36 insertions(+), 28 deletions(-) diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/metadata/MetadataController.java b/src/main/java/fi/metatavu/keycloak/scim/server/metadata/MetadataController.java index 1eedd82..45af108 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/metadata/MetadataController.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/metadata/MetadataController.java @@ -16,6 +16,7 @@ import fi.metatavu.keycloak.scim.server.model.AuthenticationScheme; import fi.metatavu.keycloak.scim.server.model.ResourceTypeListResponse; import fi.metatavu.keycloak.scim.server.model.SchemaAttribute; +import org.jboss.logging.Logger; import org.keycloak.models.IdentityProviderStorageProvider; import org.keycloak.models.KeycloakSession; import org.keycloak.models.UserModel; @@ -31,6 +32,8 @@ */ public class MetadataController extends AbstractController { + private static final Logger logger = Logger.getLogger(MetadataController.class); + /** * Lists resource types supported by the SCIM server * @@ -314,26 +317,33 @@ private List> getUserAttributeMappingList(ScimContext scimConte if (UPConfig.UnmanagedAttributePolicy.ENABLED.equals(userProfileProvider.getConfiguration().getUnmanagedAttributePolicy())) { String identityProviderAlias = scimContext.getConfig().getIdentityProviderAlias(); if (!StringUtil.isNullOrEmpty(identityProviderAlias)) { - IdentityProviderStorageProvider identityProviderStorageProvider = session.getProvider(IdentityProviderStorageProvider.class); - identityProviderStorageProvider.getMappersByAliasStream(identityProviderAlias).forEach(mapper -> { - String attribute = mapper.getConfig().get(USER_ATTRIBUTE); - if (StringUtil.isNullOrEmpty(attribute)) { - return; - } - if (!builtInAttributeNames.contains(attribute)) { - customAttributes.add(new StringUserAttribute( - UserAttribute.Source.IDP_MAPPER, - attribute, - attribute, - attribute, - SchemaAttribute.TypeEnum.STRING, - SchemaAttribute.MutabilityEnum.READWRITE, - SchemaAttribute.UniquenessEnum.NONE, - user -> user.getFirstAttribute(attribute), - (user, value) -> user.setAttribute(attribute, List.of(value)) - )); - } - }); + try { + IdentityProviderStorageProvider identityProviderStorageProvider = session.getProvider(IdentityProviderStorageProvider.class); + identityProviderStorageProvider.getMappersByAliasStream(identityProviderAlias).forEach(mapper -> { + if (mapper.getConfig() == null) { + return; + } + String attribute = mapper.getConfig().get(USER_ATTRIBUTE); + if (StringUtil.isNullOrEmpty(attribute)) { + return; + } + if (!builtInAttributeNames.contains(attribute) && customAttributes.stream().noneMatch(a -> a.getScimPath().equals(attribute))) { + customAttributes.add(new StringUserAttribute( + UserAttribute.Source.IDP_MAPPER, + attribute, + attribute, + attribute, + SchemaAttribute.TypeEnum.STRING, + SchemaAttribute.MutabilityEnum.READWRITE, + SchemaAttribute.UniquenessEnum.NONE, + user -> user.getFirstAttribute(attribute), + (user, value) -> user.setAttribute(attribute, List.of(value)) + )); + } + }); + } catch (Exception e) { + logger.warnf("Failed to read identity provider mappers for alias %s: %s", identityProviderAlias, e.getMessage()); + } } } } diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/users/UsersController.java b/src/main/java/fi/metatavu/keycloak/scim/server/users/UsersController.java index 5571084..bca97e5 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/users/UsersController.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/users/UsersController.java @@ -522,20 +522,15 @@ protected fi.metatavu.keycloak.scim.server.model.User translateUser( .givenName(user.getFirstName()) ); - List> customAttributes = userAttributes.listBySource(UserAttribute.Source.USER_PROFILE); + List> customAttributes = new ArrayList<>(); + customAttributes.addAll(userAttributes.listBySource(UserAttribute.Source.USER_PROFILE)); + customAttributes.addAll(userAttributes.listBySource(UserAttribute.Source.IDP_MAPPER)); for (UserAttribute userAttribute : customAttributes) { Object value = userAttribute.read(user); if (value != null) { result.putAdditionalProperty(userAttribute.getScimPath(), value); } } - List> mapperAttributes = userAttributes.listBySource(UserAttribute.Source.IDP_MAPPER); - for (UserAttribute userAttribute : mapperAttributes) { - Object value = userAttribute.read(user); - if (value != null) { - result.putAdditionalProperty(userAttribute.getScimPath(), value); - } - } return result; } diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserCreateTestsIT.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserCreateTestsIT.java index 79f9415..13fa9cc 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserCreateTestsIT.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserCreateTestsIT.java @@ -56,6 +56,7 @@ void testCreateUser() throws ApiException { "fi-FI", "The New User" ); + assertEquals("farmer", created.getAdditionalProperty("job")); // Assert that the user was created in Keycloak UserRepresentation realmUser = findRealmUser(TestConsts.TEST_REALM, created.getId()); diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserPatchTestsIT.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserPatchTestsIT.java index 9397129..1e6ac43 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserPatchTestsIT.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserPatchTestsIT.java @@ -153,6 +153,7 @@ void testPatchAttributes() throws ApiException { assertEquals("external-5678", patchedAgain.getAdditionalProperty("externalId")); assertEquals("Updated Display", patchedAgain.getAdditionalProperty("displayName")); assertEquals("en_US", patchedAgain.getAdditionalProperty("preferredLanguage")); + assertEquals("pilot", patchedAgain.getAdditionalProperty("job")); // Also verify state in Keycloak UserRepresentation realmUser = findRealmUser(TestConsts.TEST_REALM, created.getId()); diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserUpdateTestsIT.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserUpdateTestsIT.java index 404e5c9..1a6e4ab 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserUpdateTestsIT.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserUpdateTestsIT.java @@ -72,6 +72,7 @@ void testReplaceUser() throws ApiException { assertEquals("Replaced User", updated.getAdditionalProperty("displayName")); assertEquals("replaced-external-id", updated.getAdditionalProperty("externalId")); assertEquals("fi_FI", updated.getAdditionalProperty("preferredLanguage")); + assertEquals("chef", updated.getAdditionalProperty("job")); assertFalse(updated.getActive()); // Also verify state in Keycloak From 966624810b2f936592c7003bf764fd47795364c2 Mon Sep 17 00:00:00 2001 From: Nicola Date: Thu, 28 May 2026 17:58:22 +0200 Subject: [PATCH 33/35] fix: use ScimErrors.conflict for duplicate user checks in OrganizationScimServer Co-Authored-By: Claude Opus 4.7 (1M context) --- .../organization/OrganizationScimServer.java | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServer.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServer.java index 5979eee..8cae92f 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServer.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServer.java @@ -48,22 +48,16 @@ public Response createUser(OrganizationScimContext scimContext, User createReque KeycloakSession session = scimContext.getSession(); RealmModel realm = scimContext.getRealm(); - UserModel existingByUsername = session.users().getUserByUsername(realm, createRequest.getUserName()); - if (existingByUsername != null) { - return Response.status(Response.Status.CONFLICT) - .entity(String.format("User already exists with username: %s", createRequest.getUserName())) - .build(); + UserModel existing = session.users().getUserByUsername(realm, createRequest.getUserName()); + if (existing != null) { + return ScimErrors.conflict(String.format("User already exists with username: %s", createRequest.getUserName())); } String requestedEmail = createRequest.getEmails() != null && !createRequest.getEmails().isEmpty() ? createRequest.getEmails().getFirst().getValue() : null; - if (requestedEmail != null - && !realm.isDuplicateEmailsAllowed() - && session.users().getUserByEmail(realm, requestedEmail) != null) { - return Response.status(Response.Status.CONFLICT) - .entity(String.format("User already exists with email: %s", requestedEmail)) - .build(); + if (requestedEmail != null && !realm.isDuplicateEmailsAllowed() && session.users().getUserByEmail(realm, requestedEmail) != null) { + return ScimErrors.conflict(String.format("User already exists with email: %s", requestedEmail)); } UserAttributes userAttributes = metadataController.getUserAttributes(scimContext); From a27196d32e9d7fd4076a465e960d89ce63cbfadf Mon Sep 17 00:00:00 2001 From: Nicola Date: Thu, 28 May 2026 18:14:12 +0200 Subject: [PATCH 34/35] fix: add missing assertTrue import in RealmUserCreateTestsIT The duplicate-username and duplicate-email conflict tests added in 02a11e9 used assertTrue without importing it, breaking compileTestJava. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../server/test/tests/functional/RealmUserCreateTestsIT.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserCreateTestsIT.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserCreateTestsIT.java index 66ab0ac..f1ca2bc 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserCreateTestsIT.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserCreateTestsIT.java @@ -21,6 +21,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; /** From ce1f0e4549944fa95cbf8b5bd745f894b288788e Mon Sep 17 00:00:00 2001 From: Nicola Date: Tue, 2 Jun 2026 13:01:14 +0200 Subject: [PATCH 35/35] test: cover ADD members atomicity with mixed valid/unknown IDs Develop already enforces atomic member resolution for ADD/REPLACE/REMOVE via resolveMembers, but there's no explicit test for the ADD case proving that a valid member doesn't sneak in when an unknown ID is in the same operation. Fills the gap left by the REPLACE / REMOVE atomicity tests. Closes #107 --- .../functional/RealmGroupPatchTestsIT.java | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmGroupPatchTestsIT.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmGroupPatchTestsIT.java index 4001f5f..94a76f4 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmGroupPatchTestsIT.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmGroupPatchTestsIT.java @@ -211,6 +211,56 @@ void testRemoveGroupMemberAdminEvents() throws ApiException, IOException { deleteRealmGroup(TestConsts.TEST_REALM, group.getId()); } + /** + * An ADD members operation that includes one valid and one unknown member ID + * must be rejected atomically: HTTP 400 and the valid member must NOT sneak + * into the group. Complements the REPLACE / REMOVE atomicity tests below. + */ + @Test + void testAddMembersRejectsUnknownIdWithoutMutation() throws ApiException, IOException { + ScimClient scimClient = getAuthenticatedScimClient(); + + User seeded = createUser(scimClient, "atomic-add-seeded", "Atomic", "Seeded"); + User candidate = createUser(scimClient, "atomic-add-candidate", "Atomic", "Candidate"); + Group group = createGroup(scimClient, "atomic-add-group"); + + try { + // Seed with a known member so we can verify the group is left exactly + // as it was (and the candidate did not sneak in). + seedGroupWithMember(scimClient, group, seeded); + + // ADD [candidate, unknown] -- must fail atomically with HTTP 400. + GroupMembersInner candidateRef = new GroupMembersInner(); + candidateRef.setValue(candidate.getId()); + GroupMembersInner unknown = new GroupMembersInner(); + unknown.setValue("22222222-2222-2222-2222-222222222222"); + PatchRequest add = new PatchRequest(); + add.setSchemas(List.of("urn:ietf:params:scim:api:messages:2.0:PatchOp")); + PatchRequestOperationsInner addOp = new PatchRequestOperationsInner(); + addOp.setOp("add"); + addOp.setPath("members"); + addOp.setValue(List.of(candidateRef, unknown)); + add.setOperations(List.of(addOp)); + + ApiException ex = assertThrows(ApiException.class, + () -> scimClient.patchGroup(group.getId(), add)); + assertEquals(400, ex.getCode()); + + com.fasterxml.jackson.databind.JsonNode body = + new com.fasterxml.jackson.databind.ObjectMapper().readTree(ex.getResponseBody()); + assertEquals("400", body.get("status").asText()); + assertTrue(body.get("detail").asText().contains("22222222-2222-2222-2222-222222222222"), + "detail should name the unknown member id; got: " + body.get("detail").asText()); + + // Group must be unchanged: only the seeded member, candidate did NOT sneak in. + assertGroupHasOnlyMember(scimClient, group, seeded); + } finally { + deleteRealmUser(TestConsts.TEST_REALM, candidate.getId()); + deleteRealmUser(TestConsts.TEST_REALM, seeded.getId()); + deleteRealmGroup(TestConsts.TEST_REALM, group.getId()); + } + } + /** * A REPLACE operation that includes one valid and one unknown member ID must * be rejected atomically: HTTP 400 with an error body naming the bad ID, and