diff --git a/common/src/main/java/org/keycloak/common/Profile.java b/common/src/main/java/org/keycloak/common/Profile.java
index a7bbcea726f..577f8552236 100755
--- a/common/src/main/java/org/keycloak/common/Profile.java
+++ b/common/src/main/java/org/keycloak/common/Profile.java
@@ -125,6 +125,8 @@ public class Profile {
CACHE_EMBEDDED_REMOTE_STORE("Support for remote-store in embedded Infinispan caches", Type.EXPERIMENTAL),
USER_EVENT_METRICS("Collect metrics based on user events", Type.PREVIEW),
+
+ IPA_TUURA_FEDERATION("IPA-Tuura user federation provider", Type.EXPERIMENTAL)
;
private final Type type;
diff --git a/dependencies/server-all/pom.xml b/dependencies/server-all/pom.xml
index c25827c2a37..40825859ef6 100755
--- a/dependencies/server-all/pom.xml
+++ b/dependencies/server-all/pom.xml
@@ -86,6 +86,10 @@
org.keycloak
keycloak-sssd-federation
+
+ org.keycloak
+ keycloak-ipatuura-federation
+
diff --git a/federation/ipatuura/pom.xml b/federation/ipatuura/pom.xml
new file mode 100644
index 00000000000..5153d42c3cb
--- /dev/null
+++ b/federation/ipatuura/pom.xml
@@ -0,0 +1,64 @@
+
+
+
+
+ keycloak-parent
+ org.keycloak
+ 999.0.0-SNAPSHOT
+ ../../pom.xml
+
+ 4.0.0
+
+ keycloak-ipatuura-federation
+ Keycloak Ipatuura Federation
+
+
+
+
+ org.keycloak
+ keycloak-core
+ provided
+
+
+ org.keycloak
+ keycloak-server-spi
+ provided
+
+
+ org.keycloak
+ keycloak-server-spi-private
+ provided
+
+
+ org.keycloak
+ keycloak-model-storage-private
+ provided
+
+
+ org.jboss.logging
+ jboss-logging
+ provided
+
+
+ org.keycloak
+ keycloak-services
+
+
+
+
diff --git a/federation/ipatuura/src/main/java/org/keycloak/ipatuura_user_spi/Ipatuura.java b/federation/ipatuura/src/main/java/org/keycloak/ipatuura_user_spi/Ipatuura.java
new file mode 100644
index 00000000000..00c148d3be3
--- /dev/null
+++ b/federation/ipatuura/src/main/java/org/keycloak/ipatuura_user_spi/Ipatuura.java
@@ -0,0 +1,414 @@
+/*
+ * Copyright 2024 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.ipatuura_user_spi;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import org.jboss.logging.Logger;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.keycloak.component.ComponentModel;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.broker.provider.util.SimpleHttp;
+import org.keycloak.broker.provider.util.SimpleHttp.Response;
+
+import org.keycloak.ipatuura_user_spi.schemas.SCIMSearchRequest;
+import org.keycloak.ipatuura_user_spi.schemas.SCIMUser;
+import org.keycloak.models.UserModel;
+
+public class Ipatuura {
+ private static final Logger logger = Logger.getLogger(Ipatuura.class);
+
+ private final ComponentModel model;
+ public static final String SCHEMA_CORE_USER = "urn:ietf:params:scim:schemas:core:2.0:User";
+ public static final String SCHEMA_API_MESSAGES_SEARCHREQUEST = "urn:ietf:params:scim:api:messages:2.0:SearchRequest";
+
+ String sessionid_cookie;
+ String csrf_cookie;
+ String csrf_value;
+ Boolean logged_in = false;
+
+ private final KeycloakSession session;
+
+ public Ipatuura(KeycloakSession session, ComponentModel model) {
+ this.model = model;
+ this.session = session;
+ }
+
+ private void parseSetCookie(Response response) throws IOException {
+ List setCookieHeaders = response.getHeader("Set-Cookie");
+
+ for (String h : setCookieHeaders) {
+ String[] kv = h.split(";");
+ for (String s : kv) {
+ if (s.contains("csrftoken")) {
+ /* key=value */
+ csrf_cookie = s;
+ csrf_value = s.substring(s.lastIndexOf("=") + 1);
+ } else if (s.contains("sessionid")) {
+ /* key=value */
+ sessionid_cookie = s;
+ csrf_cookie += String.format("; %s", sessionid_cookie);
+ }
+ }
+ }
+ }
+
+ public Integer csrfAuthLogin() {
+
+ Response response;
+
+ /* Get inputs */
+ String server = model.getConfig().getFirst("scimurl");
+ String username = model.getConfig().getFirst("loginusername");
+ String password = model.getConfig().getFirst("loginpassword");
+
+ /* Execute GET to get initial csrftoken */
+ String url = String.format("https://%s%s", server, "/admin/login/");
+
+ try {
+ response = SimpleHttp.doGet(url, session).asResponse();
+ parseSetCookie(response);
+ response.close();
+ } catch (Exception e) {
+ logger.errorv("Error: {0}", e.getMessage());
+ throw new RuntimeException(e);
+ }
+
+ /* Perform login POST */
+ try {
+ /* Here we retrieve the Response sessionid and csrftoken cookie */
+ response = SimpleHttp.doPost(url, session).header("X-CSRFToken", csrf_value).header("Cookie", csrf_cookie)
+ .header("referer", url).param("username", username).param("password", password).asResponse();
+
+ parseSetCookie(response);
+ response.close();
+ } catch (Exception e) {
+ logger.error("Error: " + e.getMessage());
+ throw new RuntimeException(e);
+ }
+
+ this.logged_in = true;
+ return 0;
+ }
+
+ public boolean isValid(String username, String password) {
+
+ if (!this.logged_in) {
+ this.csrfAuthLogin();
+ }
+
+ /* Build URL */
+ String server = model.getConfig().getFirst("scimurl");
+ String endpointurl = String.format("https://%s/creds/simple_pwd", server);
+
+ logger.debugv("Sending POST request to {0}", endpointurl);
+ SimpleHttp simpleHttp = SimpleHttp.doPost(endpointurl, session).header("X-CSRFToken", this.csrf_value)
+ .header("Cookie", this.csrf_cookie).header("SessionId", sessionid_cookie).header("referer", endpointurl)
+ .param("username", username).param("password", password);
+ try (Response response = simpleHttp.asResponse()){
+ JsonNode result = response.asJson();
+ return (result.get("result").get("validated").asBoolean());
+ } catch (Exception e) {
+ logger.debugv("Failed to authenticate user {0}: {1}", username, e);
+ return false;
+ }
+
+ }
+
+ public String gssAuth(String spnegoToken) {
+
+ String server = model.getConfig().getFirst("scimurl");
+ String endpointurl = String.format("https://%s/bridge/login_kerberos/", server);
+
+ logger.debugv("Sending POST request to {0}", endpointurl);
+ SimpleHttp simpleHttp = SimpleHttp.doPost(endpointurl, session).header("Authorization", "Negotiate " + spnegoToken)
+ .param("username", "");
+ try (Response response = simpleHttp.asResponse()) {
+ logger.debugv("Response status is {0}", response.getStatus());
+ return response.getFirstHeader("Remote-User");
+ } catch (Exception e) {
+ logger.debugv("Failed to authenticate user with GSSAPI: {0}", e.toString());
+ return null;
+ }
+ }
+
+ public Response clientRequest(String endpoint, String method, T entity) throws Exception {
+ Response response = null;
+
+ if (!this.logged_in) {
+ this.csrfAuthLogin();
+ }
+
+ /* Build URL */
+ String server = model.getConfig().getFirst("scimurl");
+ String endpointurl;
+ if (endpoint.contains("domain")) {
+ endpointurl = String.format("https://%s/domains/v1/%s/", server, endpoint);
+ } else {
+ endpointurl = String.format("https://%s/scim/v2/%s", server, endpoint);
+ }
+
+ logger.debugv("Sending {0} request to {1}", method, endpointurl);
+
+ try {
+ switch (method) {
+ case "GET":
+ response = SimpleHttp.doGet(endpointurl, session).header("X-CSRFToken", csrf_value)
+ .header("Cookie", csrf_cookie).header("SessionId", sessionid_cookie).asResponse();
+ break;
+ case "DELETE":
+ response = SimpleHttp.doDelete(endpointurl, session).header("X-CSRFToken", csrf_value)
+ .header("Cookie", csrf_cookie).header("SessionId", sessionid_cookie).header("referer", endpointurl)
+ .asResponse();
+ break;
+ case "POST":
+ /* Header is needed for domains endpoint only, but use it here anyway */
+ response = SimpleHttp.doPost(endpointurl, session).header("X-CSRFToken", this.csrf_value)
+ .header("Cookie", this.csrf_cookie).header("SessionId", sessionid_cookie)
+ .header("referer", endpointurl).json(entity).asResponse();
+ break;
+ case "PUT":
+ response = SimpleHttp.doPut(endpointurl, session).header("X-CSRFToken", this.csrf_value)
+ .header("SessionId", sessionid_cookie).header("Cookie", this.csrf_cookie).json(entity).asResponse();
+ break;
+ default:
+ logger.warn("Unknown HTTP method, skipping");
+ break;
+ }
+ } catch (Exception e) {
+ throw new Exception();
+ }
+
+ /* Caller is responsible for executing .close() */
+ return response;
+ }
+
+ private SCIMSearchRequest setupSearch(String username, String attribute) {
+ List schemas = new ArrayList();
+ SCIMSearchRequest search = new SCIMSearchRequest();
+ String filter;
+
+ schemas.add(SCHEMA_API_MESSAGES_SEARCHREQUEST);
+ search.setSchemas(schemas);
+
+ filter = String.format("%s eq \"%s\"", attribute, username);
+ search.setFilter(filter);
+ logger.debugv("filter: {0}", filter);
+ logger.debugv("Schema: {0}", SCHEMA_API_MESSAGES_SEARCHREQUEST);
+
+ return search;
+ }
+
+ private SCIMUser getUserByAttr(String username, String attribute) {
+ SCIMSearchRequest newSearch = setupSearch(username, attribute);
+
+ String usersSearchUrl = "Users/.search";
+ SCIMUser user = null;
+
+ Response response;
+ try {
+ response = clientRequest(usersSearchUrl, "POST", newSearch);
+ user = response.asJson(SCIMUser.class);
+ response.close();
+ } catch (Exception e) {
+ logger.errorv("Error: {0}", e.getMessage());
+ throw new RuntimeException(e);
+ }
+
+ return user;
+ }
+
+ public SCIMUser getUserByUsername(String username) {
+ String attribute = "userName";
+ return getUserByAttr(username, attribute);
+ }
+
+ public SCIMUser getUserByEmail(String username) {
+ String attribute = "emails.value";
+ return getUserByAttr(username, attribute);
+ }
+
+ public SCIMUser getUserByFirstName(String username) {
+ String attribute = "name.givenName";
+ return getUserByAttr(username, attribute);
+ }
+
+ public SCIMUser getUserByLastName(String username) {
+ String attribute = "name.familyName";
+ return getUserByAttr(username, attribute);
+ }
+
+ public Response deleteUser(String username) {
+ SCIMUser userobj = getUserByUsername(username);
+ SCIMUser.Resource user = userobj.getResources().get(0);
+
+ String userIdUrl = String.format("Users/%s", user.getId());
+
+ Response response;
+ try {
+ response = clientRequest(userIdUrl, "DELETE", null);
+ } catch (Exception e) {
+ logger.errorv("Error: {0}", e.getMessage());
+ throw new RuntimeException(e);
+ }
+
+ return response;
+ }
+
+ /*
+ * Keycloak UserRegistrationProvider addUser() method only provides username as input, here we provide mostly dummy values
+ * which will be replaced by actual user input via appropriate setter methods once in the returned UserModel
+ */
+ private SCIMUser.Resource setupUser(String username) {
+ SCIMUser.Resource user = new SCIMUser.Resource();
+ SCIMUser.Resource.Name name = new SCIMUser.Resource.Name();
+ SCIMUser.Resource.Email email = new SCIMUser.Resource.Email();
+ List schemas = new ArrayList();
+ List emails = new ArrayList();
+ List groups = new ArrayList();
+
+ schemas.add(SCHEMA_CORE_USER);
+ user.setSchemas(schemas);
+ user.setUserName(username);
+ user.setActive(true);
+ user.setGroups(groups);
+
+ name.setGivenName("dummyfirstname");
+ name.setMiddleName("");
+ name.setFamilyName("dummylastname");
+ user.setName(name);
+
+ email.setPrimary(true);
+ email.setType("work");
+ email.setValue("dummy@example.com");
+ emails.add(email);
+ user.setEmails(emails);
+
+ return user;
+ }
+
+ public Response createUser(String username) {
+ String usersUrl = "Users";
+
+ SCIMUser.Resource newUser = setupUser(username);
+
+ Response response;
+ try {
+ response = clientRequest(usersUrl, "POST", newUser);
+ } catch (Exception e) {
+ logger.errorv("Error: {0}", e.getMessage());
+ return null;
+ }
+
+ return response;
+ }
+
+ private void setUserAttr(SCIMUser.Resource user, String attr, String value) {
+ SCIMUser.Resource.Name name = user.getName();
+ SCIMUser.Resource.Email email = new SCIMUser.Resource.Email();
+ List emails = new ArrayList();
+
+ switch (attr) {
+ case UserModel.FIRST_NAME:
+ name.setGivenName(value);
+ user.setName(name);
+ break;
+ case UserModel.LAST_NAME:
+ name.setFamilyName(value);
+ user.setName(name);
+ break;
+ case UserModel.EMAIL:
+ email.setValue(value);
+ emails.add(email);
+ user.setEmails(emails);
+ break;
+ case UserModel.USERNAME:
+ /* Changing username not supported */
+ break;
+ default:
+ logger.debug("Unknown user attribute to set: " + attr);
+ break;
+ }
+ }
+
+ public Response updateUser(Ipatuura ipatuura, String username, String attr, List values) {
+ logger.debug(String.format("Updating %s attribute for %s", attr, username));
+ /* Get existing user */
+ if (ipatuura.csrfAuthLogin() == null) {
+ logger.error("Error during login");
+ }
+
+ SCIMUser userobj = getUserByUsername(username);
+ SCIMUser.Resource user = userobj.getResources().get(0);
+
+ /* Modify attributes */
+ setUserAttr(user, attr, values.get(0));
+
+ /* Update user in SCIM */
+ String modifyUrl = String.format("Users/%s", user.getId());
+
+ Response response;
+ try {
+ response = clientRequest(modifyUrl, "PUT", user);
+ } catch (Exception e) {
+ logger.errorv("Error: {0}", e.getMessage());
+ throw new RuntimeException(e);
+ }
+
+ return response;
+ }
+
+ public boolean getActive(SCIMUser user) {
+ return user.getResources().get(0).getActive();
+ }
+
+ public String getEmail(SCIMUser user) {
+ return user.getResources().get(0).getEmails().get(0).getValue();
+ }
+
+ public String getFirstName(SCIMUser user) {
+ return user.getResources().get(0).getName().getGivenName();
+ }
+
+ public String getLastName(SCIMUser user) {
+ return user.getResources().get(0).getName().getFamilyName();
+ }
+
+ public String getUserName(SCIMUser user) {
+ return user.getResources().get(0).getUserName();
+ }
+
+ public String getId(SCIMUser user) {
+ return user.getResources().get(0).getId();
+ }
+
+ public List getGroupsList(SCIMUser user) {
+ List groups = user.getResources().get(0).getGroups();
+ List groupnames = new ArrayList();
+
+ for (SCIMUser.Resource.Group group : groups) {
+ logger.debug("Retrieving group: " + group.getDisplay());
+ groupnames.add(group.getDisplay());
+ }
+
+ return groupnames;
+ }
+}
diff --git a/federation/ipatuura/src/main/java/org/keycloak/ipatuura_user_spi/IpatuuraUserModelDelegate.java b/federation/ipatuura/src/main/java/org/keycloak/ipatuura_user_spi/IpatuuraUserModelDelegate.java
new file mode 100644
index 00000000000..1c5ac232bc1
--- /dev/null
+++ b/federation/ipatuura/src/main/java/org/keycloak/ipatuura_user_spi/IpatuuraUserModelDelegate.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright 2024 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.ipatuura_user_spi;
+
+import org.jboss.logging.Logger;
+import org.keycloak.broker.provider.util.SimpleHttp;
+import org.keycloak.component.ComponentModel;
+import org.keycloak.models.UserModel;
+import org.keycloak.models.utils.UserModelDelegate;
+
+import java.io.IOException;
+import java.util.List;
+
+import org.apache.http.HttpStatus;
+
+public class IpatuuraUserModelDelegate extends UserModelDelegate {
+
+ private static final Logger logger = Logger.getLogger(IpatuuraUserModelDelegate.class);
+
+ private ComponentModel model;
+
+ private final Ipatuura ipatuura;
+
+ public IpatuuraUserModelDelegate(Ipatuura ipatuura, UserModel delegate, ComponentModel model) {
+ super(delegate);
+ this.model = model;
+ this.ipatuura = ipatuura;
+ }
+
+ @Override
+ public void setAttribute(String attr, List values) {
+ SimpleHttp.Response resp = this.ipatuura.updateUser(ipatuura, this.getUsername(), attr, values);
+ try {
+ if (resp.getStatus() != HttpStatus.SC_OK && resp.getStatus() != HttpStatus.SC_NO_CONTENT) {
+ logger.warn("Unexpected PUT status code returned");
+ resp.close();
+ return;
+ }
+ resp.close();
+ } catch (IOException e) {
+ logger.errorv("Error: {0}", e.getMessage());
+ throw new RuntimeException(e);
+ }
+ super.setAttribute(attr, values);
+ }
+
+ @Override
+ public void setSingleAttribute(String name, String value) {
+ super.setSingleAttribute(name, value);
+ }
+
+ @Override
+ public void setUsername(String username) {
+ super.setUsername(username);
+ }
+
+ @Override
+ public void setLastName(String lastName) {
+ super.setLastName(lastName);
+ }
+
+ @Override
+ public void setFirstName(String first) {
+ super.setFirstName(first);
+ }
+
+ @Override
+ public void setEmail(String email) {
+ super.setFirstName(email);
+ }
+
+ @Override
+ public void setEnabled(boolean enabled) {
+ super.setEnabled(enabled);
+ }
+}
diff --git a/federation/ipatuura/src/main/java/org/keycloak/ipatuura_user_spi/IpatuuraUserStorageProvider.java b/federation/ipatuura/src/main/java/org/keycloak/ipatuura_user_spi/IpatuuraUserStorageProvider.java
new file mode 100755
index 00000000000..9b24f0e99ff
--- /dev/null
+++ b/federation/ipatuura/src/main/java/org/keycloak/ipatuura_user_spi/IpatuuraUserStorageProvider.java
@@ -0,0 +1,329 @@
+/*
+ * Copyright 2024 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.ipatuura_user_spi;
+
+import org.jboss.logging.Logger;
+
+import org.keycloak.component.ComponentModel;
+import org.keycloak.credential.CredentialAuthentication;
+import org.keycloak.credential.CredentialInput;
+import org.keycloak.credential.CredentialInputValidator;
+import org.keycloak.credential.UserCredentialManager;
+import org.keycloak.models.CredentialValidationOutput;
+import org.keycloak.models.GroupModel;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.RealmModel;
+import org.keycloak.models.UserCredentialModel;
+import org.keycloak.models.UserModel;
+import org.keycloak.models.credential.PasswordCredentialModel;
+import org.keycloak.storage.StorageId;
+import org.keycloak.storage.UserStorageProvider;
+import org.keycloak.storage.UserStoragePrivateUtil;
+import org.keycloak.storage.user.ImportedUserValidation;
+import org.keycloak.storage.user.UserLookupProvider;
+import org.keycloak.storage.user.UserRegistrationProvider;
+import org.keycloak.broker.provider.util.SimpleHttp;
+
+import org.keycloak.ipatuura_user_spi.authenticator.IpatuuraAuthenticator;
+import org.keycloak.ipatuura_user_spi.schemas.SCIMError;
+import org.keycloak.ipatuura_user_spi.schemas.SCIMUser;
+
+import org.keycloak.storage.user.UserQueryProvider;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Stream;
+
+import org.apache.http.HttpStatus;
+
+/**
+ * @author Justin Stephenson
+ * @version $Revision: 1 $
+ */
+public class IpatuuraUserStorageProvider implements UserStorageProvider, UserLookupProvider, CredentialInputValidator,
+ CredentialAuthentication, UserRegistrationProvider, UserQueryProvider, ImportedUserValidation {
+ protected KeycloakSession session;
+ protected ComponentModel model;
+ protected Ipatuura ipatuura;
+ private static final Logger logger = Logger.getLogger(IpatuuraUserStorageProvider.class);
+ protected final Set supportedCredentialTypes = new HashSet<>();
+ protected IpatuuraUserStorageProviderFactory factory;
+
+ public IpatuuraUserStorageProvider(KeycloakSession session, ComponentModel model, Ipatuura ipatuura,
+ IpatuuraUserStorageProviderFactory factory) {
+ this.session = session;
+ this.model = model;
+ this.ipatuura = ipatuura;
+ this.factory = factory;
+
+ supportedCredentialTypes.add(PasswordCredentialModel.TYPE);
+ }
+
+ @Override
+ public UserModel getUserByEmail(RealmModel realm, String email) {
+ return null;
+ }
+
+ @Override
+ public UserModel getUserById(RealmModel realm, String id) {
+ StorageId storageId = new StorageId(id);
+ String username = storageId.getExternalId();
+ return getUserByUsername(realm, username);
+ }
+
+ @Override
+ public UserModel getUserByUsername(RealmModel realm, String username) {
+ /*
+ * Remove @realm, this is needed as GSSAPI auth users reach here as user@realm
+ */
+ int idx = username.indexOf("@");
+ if (idx != -1) {
+ username = username.substring(0, idx);
+ }
+
+ UserModel user = UserStoragePrivateUtil.userLocalStorage(session).getUserByUsername(realm, username);
+ if (user != null) {
+ logger.debug("User already exists in keycloak");
+ return user;
+ } else {
+ return createUserInKeycloak(realm, username);
+ }
+ }
+
+ protected UserModel createUserInKeycloak(RealmModel realm, String username) {
+ SCIMUser scimuser = ipatuura.getUserByUsername(username);
+ if (scimuser.getTotalResults() == 0) {
+ return null;
+ }
+ UserModel user = UserStoragePrivateUtil.userLocalStorage(session).addUser(realm, username);
+ user.setEmail(ipatuura.getEmail(scimuser));
+ user.setFirstName(ipatuura.getFirstName(scimuser));
+ user.setLastName(ipatuura.getLastName(scimuser));
+ user.setFederationLink(model.getId());
+ user.setEnabled(ipatuura.getActive(scimuser));
+
+ for (String name : ipatuura.getGroupsList(scimuser)) {
+ Stream groupsStream = session.groups().searchForGroupByNameStream(realm, name, false, null, null);
+ GroupModel group = groupsStream.findFirst().orElse(null);
+
+ if (group == null) {
+ logger.debugv("No group found, creating group: {0}", name);
+ group = session.groups().createGroup(realm, name);
+ }
+ user.joinGroup(group);
+ }
+
+ logger.debugv("Creating SCIM user {0} in keycloak", username);
+ return new IpatuuraUserModelDelegate(ipatuura, user, model);
+ }
+
+ @Override
+ public void close() {
+
+ }
+
+ public Set getSupportedCredentialTypes() {
+ return new HashSet(this.supportedCredentialTypes);
+ }
+
+ @Override
+ public boolean isConfiguredFor(RealmModel realm, UserModel user, String credentialType) {
+ return getSupportedCredentialTypes().contains(credentialType);
+ }
+
+ @Override
+ public boolean supportsCredentialType(String credentialType) {
+ return getSupportedCredentialTypes().contains(credentialType);
+ }
+
+ @Override
+ public boolean isValid(RealmModel realm, UserModel user, CredentialInput input) {
+ if (!supportsCredentialType(input.getType()) || !(input instanceof UserCredentialModel))
+ return false;
+
+ /*
+ * The password can either be validated locally in keycloak (tried first) or in the SCIM server
+ */
+ if (((UserCredentialManager) user.credentialManager()).isConfiguredLocally(input.getType())) {
+ logger.debugv("Local password validation for {0}", user.getUsername());
+ /* return false in order to fallback to the next validator */
+ return false;
+ } else {
+ logger.debugv("Delegated password validation for {0}", user.getUsername());
+ Ipatuura ipatuura = this.ipatuura;
+ return ipatuura.isValid(user.getUsername(), input.getChallengeResponse());
+ }
+ }
+
+ @Override
+ public UserModel validate(RealmModel realm, UserModel local) {
+ Ipatuura ipatuura = this.ipatuura;
+
+ SCIMUser scimuser = ipatuura.getUserByUsername(local.getUsername());
+ String fname = ipatuura.getFirstName(scimuser);
+ String lname = ipatuura.getLastName(scimuser);
+ String email = ipatuura.getEmail(scimuser);
+
+ if (!local.getFirstName().equals(fname)) {
+ local.setFirstName(fname);
+ }
+ if (!local.getLastName().equals(lname)) {
+ local.setLastName(lname);
+ }
+ if (!local.getEmail().equals(email)) {
+ local.setEmail(email);
+ }
+
+ return new IpatuuraUserModelDelegate(this.ipatuura, local, model);
+ }
+
+ @Override
+ public UserModel addUser(RealmModel realm, String username) {
+ Ipatuura ipatuura = this.ipatuura;
+
+ SimpleHttp.Response resp = ipatuura.createUser(username);
+
+ try {
+ if (resp.getStatus() != HttpStatus.SC_CREATED) {
+ logger.warn("Unexpected create status code returned");
+ SCIMError error = resp.asJson(SCIMError.class);
+ logger.warn(error.getDetail());
+ resp.close();
+ return null;
+ }
+ resp.close();
+ } catch (IOException e) {
+ logger.errorv("Error: {0}", e.getMessage());
+ throw new RuntimeException(e);
+ }
+
+ return createUserInKeycloak(realm, username);
+ }
+
+ @Override
+ public boolean removeUser(RealmModel realm, UserModel user) {
+ logger.debugv("Removing user: {0}", user.getUsername());
+ Ipatuura ipatuura = this.ipatuura;
+
+ SimpleHttp.Response resp = ipatuura.deleteUser(user.getUsername());
+ Boolean status = false;
+ try {
+ status = resp.getStatus() == HttpStatus.SC_NO_CONTENT;
+ resp.close();
+ } catch (IOException e) {
+ logger.errorv("Error: {0}", e.getMessage());
+ throw new RuntimeException(e);
+ }
+ return status;
+ }
+
+ private Stream performSearch(RealmModel realm, String search) {
+ List users = new LinkedList<>();
+ Ipatuura ipatuura = this.ipatuura;
+
+ SCIMUser scimuser = ipatuura.getUserByUsername(search);
+ if (scimuser.getTotalResults() > 0) {
+ logger.debug("User found by username!");
+ if (UserStoragePrivateUtil.userLocalStorage(session).getUserByUsername(realm, search) == null) {
+ UserModel user = getUserByUsername(realm, ipatuura.getUserName(scimuser));
+ users.add(user);
+ } else {
+ logger.debug("User exists!");
+ }
+
+ return users.stream();
+ }
+
+ return users.stream();
+ }
+
+ @Override
+ public Stream getGroupMembersStream(RealmModel arg0, GroupModel arg1, Integer arg2, Integer arg3) {
+ return Stream.empty();
+ }
+
+ @Override
+ public int getUsersCount(RealmModel realm) {
+ Ipatuura ipatuura = this.ipatuura;
+
+ SCIMUser user = null;
+ SimpleHttp.Response response;
+ try {
+ response = ipatuura.clientRequest("/Users", "GET", null);
+ user = response.asJson(SCIMUser.class);
+ response.close();
+ } catch (Exception e) {
+ logger.errorv("Error: {0}", e.getMessage());
+ throw new RuntimeException(e);
+ }
+
+ return user.getTotalResults();
+ }
+
+ @Override
+ public Stream searchForUserByUserAttributeStream(RealmModel realm, String attrName, String attrValue) {
+ return Stream.empty();
+ }
+
+ @Override
+ public Stream searchForUserStream(RealmModel realm, Map params, Integer firstResult,
+ Integer maxResults) {
+ String search = params.get(UserModel.SEARCH);
+ /* only supports searching by username */
+ if (search == null)
+ return Stream.empty();
+ return performSearch(realm, search);
+ }
+
+ @Override
+ public boolean supportsCredentialAuthenticationFor(String type) {
+ return UserCredentialModel.KERBEROS.equals(type);
+ }
+
+ @Override
+ public CredentialValidationOutput authenticate(RealmModel realm, CredentialInput input) {
+ Map state = new HashMap<>();
+ String username = null;
+ IpatuuraAuthenticator ipatuuraAuthenticator = factory.createSCIMAuthenticator();
+
+ String token = ipatuuraAuthenticator.getToken(session);
+ if (token != null) {
+ username = ipatuura.gssAuth(token);
+
+ /* Remove realm */
+ int idx = username.indexOf("@");
+ if (idx != -1) {
+ username = username.substring(0, idx);
+ }
+ logger.debug("GSSAPI authenticating with user " + username);
+ }
+
+ UserModel user = getUserByUsername(realm, username);
+ if (user == null) {
+ logger.debug("CredentialValidationOutput failed");
+ return CredentialValidationOutput.failed();
+ }
+ logger.debug("CredentialValidationOutput success!");
+ return new CredentialValidationOutput(user, CredentialValidationOutput.Status.AUTHENTICATED, state);
+ }
+}
diff --git a/federation/ipatuura/src/main/java/org/keycloak/ipatuura_user_spi/IpatuuraUserStorageProviderFactory.java b/federation/ipatuura/src/main/java/org/keycloak/ipatuura_user_spi/IpatuuraUserStorageProviderFactory.java
new file mode 100755
index 00000000000..411677d2fd1
--- /dev/null
+++ b/federation/ipatuura/src/main/java/org/keycloak/ipatuura_user_spi/IpatuuraUserStorageProviderFactory.java
@@ -0,0 +1,104 @@
+/*
+ * Copyright 2024 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.ipatuura_user_spi;
+
+import org.jboss.logging.Logger;
+import org.keycloak.Config;
+import org.keycloak.broker.provider.util.SimpleHttp;
+import org.keycloak.common.Profile;
+import org.keycloak.component.ComponentModel;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.RealmModel;
+import org.keycloak.component.ComponentValidationException;
+import org.keycloak.provider.EnvironmentDependentProviderFactory;
+import org.keycloak.storage.UserStorageProviderFactory;
+import org.keycloak.provider.ProviderConfigProperty;
+import org.keycloak.provider.ProviderConfigurationBuilder;
+import org.keycloak.ipatuura_user_spi.authenticator.IpatuuraAuthenticator;
+
+import java.util.LinkedList;
+import java.util.List;
+
+/**
+ * @author Justin Stephenson
+ * @version $Revision: 1 $
+ */
+public class IpatuuraUserStorageProviderFactory implements UserStorageProviderFactory, EnvironmentDependentProviderFactory {
+
+ private static final Logger logger = Logger.getLogger(IpatuuraUserStorageProviderFactory.class);
+ public static final String PROVIDER_NAME = "ipatuura";
+ protected static final List PROVIDERS = new LinkedList<>();
+ protected static final List configMetadata;
+
+ static {
+ PROVIDERS.add("ipa");
+ PROVIDERS.add("ad");
+ PROVIDERS.add("ldap");
+
+ configMetadata = ProviderConfigurationBuilder.create()
+ /* SCIMv2 server url */
+ .property().name("scimurl").type(ProviderConfigProperty.STRING_TYPE).label("Ipatuura Server URL")
+ .helpText("Backend ipatuura server URL in the format: server.example.com:8080").add()
+ /* Login username, used to auth to make HTTP requests */
+ .property().name("loginusername").type(ProviderConfigProperty.STRING_TYPE).label("Login username")
+ .helpText("username to authenticate through the login page").add()
+ /* Login password, used to auth to make HTTP requests */
+ .property().name("loginpassword").type(ProviderConfigProperty.STRING_TYPE).label("Login password")
+ .helpText("password to authenticate through the login page").add().build();
+ }
+
+ @Override
+ public List getConfigProperties() {
+ return configMetadata;
+ }
+
+ @Override
+ public void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel config)
+ throws ComponentValidationException {
+ Ipatuura ipatuura = new Ipatuura(session, config);
+
+ SimpleHttp.Response response;
+
+ try {
+ response = ipatuura.clientRequest("", "GET", null);
+ response.close();
+ } catch (Exception e) {
+ throw new ComponentValidationException("Cannot connect to provided URL!", e);
+ }
+ }
+
+ @Override
+ public String getId() {
+ return PROVIDER_NAME;
+ }
+
+ @Override
+ public IpatuuraUserStorageProvider create(KeycloakSession session, ComponentModel model) {
+ Ipatuura ipatuura = new Ipatuura(session, model);
+ return new IpatuuraUserStorageProvider(session, model, ipatuura, this);
+ }
+
+ protected IpatuuraAuthenticator createSCIMAuthenticator() {
+ return new IpatuuraAuthenticator();
+ }
+
+ @Override
+ public boolean isSupported(Config.Scope config) {
+ return Profile.isFeatureEnabled(Profile.Feature.IPA_TUURA_FEDERATION);
+ }
+}
diff --git a/federation/ipatuura/src/main/java/org/keycloak/ipatuura_user_spi/authenticator/IpatuuraAuthenticator.java b/federation/ipatuura/src/main/java/org/keycloak/ipatuura_user_spi/authenticator/IpatuuraAuthenticator.java
new file mode 100755
index 00000000000..1ca6a15c14d
--- /dev/null
+++ b/federation/ipatuura/src/main/java/org/keycloak/ipatuura_user_spi/authenticator/IpatuuraAuthenticator.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2024 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.ipatuura_user_spi.authenticator;
+
+import jakarta.ws.rs.core.HttpHeaders;
+
+import org.jboss.logging.Logger;
+import org.keycloak.common.constants.KerberosConstants;
+import org.keycloak.models.KeycloakSession;
+
+/**
+ * @author Justin Stephenson
+ * @version $Revision: 1 $
+ */
+public class IpatuuraAuthenticator {
+
+ private static final Logger logger = Logger.getLogger(IpatuuraAuthenticator.class);
+
+ public String getToken(KeycloakSession session) {
+ HttpHeaders headers = session.getContext().getHttpRequest().getHttpHeaders();
+
+ String authHeader = headers.getRequestHeaders().getFirst(HttpHeaders.AUTHORIZATION);
+
+ if (authHeader == null) {
+ logger.debug("authHeader == NULL()!");
+ return null;
+ }
+ String[] tokens = authHeader.split(" ");
+
+ if (tokens.length == 0) { // assume not supported
+ logger.debug("Invalid length of tokens: " + tokens.length);
+ return null;
+ }
+ if (!KerberosConstants.NEGOTIATE.equalsIgnoreCase(tokens[0])) {
+ logger.debug("Unknown scheme " + tokens[0]);
+ return null;
+ }
+ if (tokens.length != 2) {
+ logger.debug("Invalid credentials tokens.length != 2");
+ return null;
+ }
+
+ String spnegoToken = tokens[1];
+
+ return spnegoToken;
+ }
+}
diff --git a/federation/ipatuura/src/main/java/org/keycloak/ipatuura_user_spi/schemas/SCIMError.java b/federation/ipatuura/src/main/java/org/keycloak/ipatuura_user_spi/schemas/SCIMError.java
new file mode 100644
index 00000000000..fcc78d52a18
--- /dev/null
+++ b/federation/ipatuura/src/main/java/org/keycloak/ipatuura_user_spi/schemas/SCIMError.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2024 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.ipatuura_user_spi.schemas;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import javax.annotation.Generated;
+
+import com.fasterxml.jackson.annotation.JsonAnyGetter;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonPropertyOrder;
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonPropertyOrder({ "schemas", "detail", "status" })
+@Generated("jsonschema2pojo")
+public class SCIMError {
+
+ @JsonProperty("schemas")
+ private List schemas = null;
+ @JsonProperty("detail")
+ private String detail;
+ @JsonProperty("status")
+ private Integer status;
+ @JsonIgnore
+ private Map additionalProperties = new HashMap();
+
+ @JsonProperty("schemas")
+ public List getSchemas() {
+ return schemas;
+ }
+
+ @JsonProperty("schemas")
+ public void setSchemas(List schemas) {
+ this.schemas = schemas;
+ }
+
+ @JsonProperty("detail")
+ public String getDetail() {
+ return detail;
+ }
+
+ @JsonProperty("detail")
+ public void setDetail(String detail) {
+ this.detail = detail;
+ }
+
+ @JsonProperty("status")
+ public Integer getStatus() {
+ return status;
+ }
+
+ @JsonProperty("status")
+ public void setStatus(Integer status) {
+ this.status = status;
+ }
+
+ @JsonAnyGetter
+ public Map getAdditionalProperties() {
+ return this.additionalProperties;
+ }
+
+ @JsonAnySetter
+ public void setAdditionalProperty(String name, Object value) {
+ this.additionalProperties.put(name, value);
+ }
+
+}
diff --git a/federation/ipatuura/src/main/java/org/keycloak/ipatuura_user_spi/schemas/SCIMSearchRequest.java b/federation/ipatuura/src/main/java/org/keycloak/ipatuura_user_spi/schemas/SCIMSearchRequest.java
new file mode 100644
index 00000000000..52da04559fc
--- /dev/null
+++ b/federation/ipatuura/src/main/java/org/keycloak/ipatuura_user_spi/schemas/SCIMSearchRequest.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright 2024 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.ipatuura_user_spi.schemas;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import javax.annotation.Generated;
+
+import com.fasterxml.jackson.annotation.JsonAnyGetter;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonPropertyOrder;
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonPropertyOrder({ "schemas", "filter" })
+@Generated("jsonschema2pojo")
+public class SCIMSearchRequest {
+
+ @JsonProperty("schemas")
+ private List schemas = null;
+ @JsonProperty("filter")
+ private String filter;
+ @JsonIgnore
+ private Map additionalProperties = new HashMap();
+
+ @JsonProperty("schemas")
+ public List getSchemas() {
+ return schemas;
+ }
+
+ @JsonProperty("schemas")
+ public void setSchemas(List schemas) {
+ this.schemas = schemas;
+ }
+
+ @JsonProperty("filter")
+ public String getFilter() {
+ return filter;
+ }
+
+ @JsonProperty("filter")
+ public void setFilter(String filter) {
+ this.filter = filter;
+ }
+
+ @JsonAnyGetter
+ public Map getAdditionalProperties() {
+ return this.additionalProperties;
+ }
+
+ @JsonAnySetter
+ public void setAdditionalProperty(String name, Object value) {
+ this.additionalProperties.put(name, value);
+ }
+
+}
diff --git a/federation/ipatuura/src/main/java/org/keycloak/ipatuura_user_spi/schemas/SCIMUser.java b/federation/ipatuura/src/main/java/org/keycloak/ipatuura_user_spi/schemas/SCIMUser.java
new file mode 100644
index 00000000000..f8c3ba8aec6
--- /dev/null
+++ b/federation/ipatuura/src/main/java/org/keycloak/ipatuura_user_spi/schemas/SCIMUser.java
@@ -0,0 +1,434 @@
+/*
+ * Copyright 2024 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.ipatuura_user_spi.schemas;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import javax.annotation.Generated;
+
+import com.fasterxml.jackson.annotation.JsonAnyGetter;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonPropertyOrder;
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonPropertyOrder({ "Resources", "itemsPerPage", "schemas", "startIndex", "totalResults" })
+@Generated("jsonschema2pojo")
+public class SCIMUser {
+
+ @JsonProperty("Resources")
+ private List resources = null;
+ @JsonProperty("itemsPerPage")
+ private Integer itemsPerPage;
+ @JsonProperty("schemas")
+ private List schemas = null;
+ @JsonProperty("startIndex")
+ private Integer startIndex;
+ @JsonProperty("totalResults")
+ private Integer totalResults;
+ @JsonIgnore
+ private Map additionalProperties = new HashMap();
+
+ @JsonProperty("Resources")
+ public List getResources() {
+ return resources;
+ }
+
+ @JsonProperty("Resources")
+ public void setResources(List resources) {
+ this.resources = resources;
+ }
+
+ @JsonProperty("itemsPerPage")
+ public Integer getItemsPerPage() {
+ return itemsPerPage;
+ }
+
+ @JsonProperty("itemsPerPage")
+ public void setItemsPerPage(Integer itemsPerPage) {
+ this.itemsPerPage = itemsPerPage;
+ }
+
+ @JsonProperty("schemas")
+ public List getSchemas() {
+ return schemas;
+ }
+
+ @JsonProperty("schemas")
+ public void setSchemas(List schemas) {
+ this.schemas = schemas;
+ }
+
+ @JsonProperty("startIndex")
+ public Integer getStartIndex() {
+ return startIndex;
+ }
+
+ @JsonProperty("startIndex")
+ public void setStartIndex(Integer startIndex) {
+ this.startIndex = startIndex;
+ }
+
+ @JsonProperty("totalResults")
+ public Integer getTotalResults() {
+ return totalResults;
+ }
+
+ @JsonProperty("totalResults")
+ public void setTotalResults(Integer totalResults) {
+ this.totalResults = totalResults;
+ }
+
+ @JsonAnyGetter
+ public Map getAdditionalProperties() {
+ return this.additionalProperties;
+ }
+
+ @JsonAnySetter
+ public void setAdditionalProperty(String name, Object value) {
+ this.additionalProperties.put(name, value);
+ }
+
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ @JsonPropertyOrder({ "active", "emails", "groups", "id", "meta", "name", "schemas", "userName" })
+ @Generated("jsonschema2pojo")
+ public static class Resource {
+
+ @JsonProperty("active")
+ private Boolean active;
+ @JsonProperty("emails")
+ private List emails = null;
+ @JsonProperty("groups")
+ private List groups = null;
+ @JsonProperty("id")
+ private String id;
+ @JsonProperty("meta")
+ private Meta meta;
+ @JsonProperty("name")
+ private Name name;
+ @JsonProperty("schemas")
+ private List schemas = null;
+ @JsonProperty("userName")
+ private String userName;
+ @JsonIgnore
+ private Map additionalProperties = new HashMap();
+
+ @JsonProperty("active")
+ public Boolean getActive() {
+ return active;
+ }
+
+ @JsonProperty("active")
+ public void setActive(boolean b) {
+ this.active = b;
+ }
+
+ @JsonProperty("emails")
+ public List getEmails() {
+ return emails;
+ }
+
+ @JsonProperty("emails")
+ public void setEmails(List emails) {
+ this.emails = emails;
+ }
+
+ @JsonProperty("groups")
+ public List getGroups() {
+ return groups;
+ }
+
+ @JsonProperty("groups")
+ public void setGroups(List groups) {
+ this.groups = groups;
+ }
+
+ @JsonProperty("id")
+ public String getId() {
+ return id;
+ }
+
+ @JsonProperty("id")
+ public void setId(String id) {
+ this.id = id;
+ }
+
+ @JsonProperty("meta")
+ public Meta getMeta() {
+ return meta;
+ }
+
+ @JsonProperty("meta")
+ public void setMeta(Meta meta) {
+ this.meta = meta;
+ }
+
+ @JsonProperty("name")
+ public Name getName() {
+ return name;
+ }
+
+ @JsonProperty("name")
+ public void setName(Name name) {
+ this.name = name;
+ }
+
+ @JsonProperty("schemas")
+ public List getSchemas() {
+ return schemas;
+ }
+
+ @JsonProperty("schemas")
+ public void setSchemas(List schemas) {
+ this.schemas = schemas;
+ }
+
+ @JsonProperty("userName")
+ public String getUserName() {
+ return userName;
+ }
+
+ @JsonProperty("userName")
+ public void setUserName(String userName) {
+ this.userName = userName;
+ }
+
+ @JsonAnyGetter
+ public Map getAdditionalProperties() {
+ return this.additionalProperties;
+ }
+
+ @JsonAnySetter
+ public void setAdditionalProperty(String name, Object value) {
+ this.additionalProperties.put(name, value);
+ }
+
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ @JsonPropertyOrder({ "familyName", "givenName", "middleName" })
+ @Generated("jsonschema2pojo")
+ public static class Name {
+
+ @JsonProperty("familyName")
+ private String familyName;
+ @JsonProperty("givenName")
+ private String givenName;
+ @JsonProperty("middleName")
+ private String middleName;
+ @JsonIgnore
+ private Map additionalProperties = new HashMap();
+
+ @JsonProperty("familyName")
+ public String getFamilyName() {
+ return familyName;
+ }
+
+ @JsonProperty("familyName")
+ public void setFamilyName(String familyName) {
+ this.familyName = familyName;
+ }
+
+ @JsonProperty("givenName")
+ public String getGivenName() {
+ return givenName;
+ }
+
+ @JsonProperty("givenName")
+ public void setGivenName(String givenName) {
+ this.givenName = givenName;
+ }
+
+ @JsonProperty("middleName")
+ public String getMiddleName() {
+ return middleName;
+ }
+
+ @JsonProperty("middleName")
+ public void setMiddleName(String middleName) {
+ this.middleName = middleName;
+ }
+
+ @JsonAnyGetter
+ public Map getAdditionalProperties() {
+ return this.additionalProperties;
+ }
+
+ @JsonAnySetter
+ public void setAdditionalProperty(String name, Object value) {
+ this.additionalProperties.put(name, value);
+ }
+
+ }
+
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ @JsonPropertyOrder({ "location", "resourceType" })
+ @Generated("jsonschema2pojo")
+ public static class Meta {
+
+ @JsonProperty("location")
+ private String location;
+ @JsonProperty("resourceType")
+ private String resourceType;
+ @JsonIgnore
+ private Map additionalProperties = new HashMap();
+
+ @JsonProperty("location")
+ public String getLocation() {
+ return location;
+ }
+
+ @JsonProperty("location")
+ public void setLocation(String location) {
+ this.location = location;
+ }
+
+ @JsonProperty("resourceType")
+ public String getResourceType() {
+ return resourceType;
+ }
+
+ @JsonProperty("resourceType")
+ public void setResourceType(String resourceType) {
+ this.resourceType = resourceType;
+ }
+
+ @JsonAnyGetter
+ public Map getAdditionalProperties() {
+ return this.additionalProperties;
+ }
+
+ @JsonAnySetter
+ public void setAdditionalProperty(String name, Object value) {
+ this.additionalProperties.put(name, value);
+ }
+ }
+
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ @JsonPropertyOrder({ "primary", "type", "value" })
+ @Generated("jsonschema2pojo")
+ public static class Email {
+
+ @JsonProperty("primary")
+ private Boolean primary;
+ @JsonProperty("type")
+ private String type;
+ @JsonProperty("value")
+ private String value;
+ @JsonIgnore
+ private Map additionalProperties = new HashMap();
+
+ @JsonProperty("primary")
+ public Boolean getPrimary() {
+ return primary;
+ }
+
+ @JsonProperty("primary")
+ public void setPrimary(Boolean primary) {
+ this.primary = primary;
+ }
+
+ @JsonProperty("type")
+ public String getType() {
+ return type;
+ }
+
+ @JsonProperty("type")
+ public void setType(String type) {
+ this.type = type;
+ }
+
+ @JsonProperty("value")
+ public String getValue() {
+ return value;
+ }
+
+ @JsonProperty("value")
+ public void setValue(String value) {
+ this.value = value;
+ }
+
+ @JsonAnyGetter
+ public Map getAdditionalProperties() {
+ return this.additionalProperties;
+ }
+
+ @JsonAnySetter
+ public void setAdditionalProperty(String name, Object value) {
+ this.additionalProperties.put(name, value);
+ }
+ }
+
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ @JsonPropertyOrder({ "$ref", "display", "value" })
+ @Generated("jsonschema2pojo")
+ public static class Group {
+
+ @JsonProperty("$ref")
+ private String $ref;
+ @JsonProperty("display")
+ private String display;
+ @JsonProperty("value")
+ private String value;
+ @JsonIgnore
+ private Map additionalProperties = new HashMap();
+
+ @JsonProperty("$ref")
+ public String get$ref() {
+ return $ref;
+ }
+
+ @JsonProperty("$ref")
+ public void set$ref(String $ref) {
+ this.$ref = $ref;
+ }
+
+ @JsonProperty("display")
+ public String getDisplay() {
+ return display;
+ }
+
+ @JsonProperty("display")
+ public void setDisplay(String display) {
+ this.display = display;
+ }
+
+ @JsonProperty("value")
+ public String getValue() {
+ return value;
+ }
+
+ @JsonProperty("value")
+ public void setValue(String value) {
+ this.value = value;
+ }
+
+ @JsonAnyGetter
+ public Map getAdditionalProperties() {
+ return this.additionalProperties;
+ }
+
+ @JsonAnySetter
+ public void setAdditionalProperty(String name, Object value) {
+ this.additionalProperties.put(name, value);
+ }
+
+ }
+ }
+}
diff --git a/federation/ipatuura/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory b/federation/ipatuura/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory
new file mode 100644
index 00000000000..0907504e0d2
--- /dev/null
+++ b/federation/ipatuura/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory
@@ -0,0 +1,18 @@
+#
+# Copyright 2024 Red Hat, Inc. and/or its affiliates
+# and other contributors as indicated by the @author tags.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+org.keycloak.ipatuura_user_spi.IpatuuraUserStorageProviderFactory
diff --git a/federation/pom.xml b/federation/pom.xml
index 314e274a73b..662c1f5a77e 100755
--- a/federation/pom.xml
+++ b/federation/pom.xml
@@ -36,6 +36,7 @@
kerberos
ldap
sssd
+ ipatuura
diff --git a/pom.xml b/pom.xml
index 9c96c60d06a..291bf1c6aba 100644
--- a/pom.xml
+++ b/pom.xml
@@ -917,6 +917,11 @@
keycloak-ldap-federation
${project.version}
+
+ org.keycloak
+ keycloak-ipatuura-federation
+ ${project.version}
+
org.keycloak
keycloak-dependencies-server-min
diff --git a/quarkus/runtime/pom.xml b/quarkus/runtime/pom.xml
index f9ab9049c28..f75047d8de6 100644
--- a/quarkus/runtime/pom.xml
+++ b/quarkus/runtime/pom.xml
@@ -334,6 +334,16 @@
+
+ org.keycloak
+ keycloak-ipatuura-federation
+
+
+ *
+ *
+
+
+
org.keycloak
keycloak-config-api