diff --git a/.gitignore b/.gitignore
index 514f82116de..bb9686ae629 100644
--- a/.gitignore
+++ b/.gitignore
@@ -61,5 +61,6 @@ src/main/webapp/resources/images/cc0.png.thumb140
src/main/webapp/resources/images/dataverseproject.png.thumb140
# Docker development volumes
+/conf/keycloak/docker-dev-volumes
/docker-dev-volumes
/.vs
diff --git a/conf/keycloak/.env b/conf/keycloak/.env
new file mode 100644
index 00000000000..6d99d85b3a7
--- /dev/null
+++ b/conf/keycloak/.env
@@ -0,0 +1,5 @@
+APP_IMAGE=gdcc/dataverse:unstable
+POSTGRES_VERSION=17
+DATAVERSE_DB_USER=dataverse
+SOLR_VERSION=9.8.0
+SKIP_DEPLOY=0
\ No newline at end of file
diff --git a/conf/keycloak/Dockerfile b/conf/keycloak/Dockerfile
new file mode 100644
index 00000000000..76088f402c7
--- /dev/null
+++ b/conf/keycloak/Dockerfile
@@ -0,0 +1,39 @@
+# ------------------------------------------
+# Stage 1: Build SPI with Maven
+# ------------------------------------------
+FROM maven:3.9.5-eclipse-temurin-17 AS builder
+
+WORKDIR /app
+
+# Copy SPI source code
+COPY ./builtin-users-spi /app
+
+# Build the SPI JAR
+RUN mvn clean package
+
+# ------------------------------------------
+# Stage 2: Build Keycloak Image
+# ------------------------------------------
+FROM quay.io/keycloak/keycloak:26.1.4
+
+# Add the Oracle JDBC jars
+ARG ORACLE_JDBC_VERSION=23.7.0.25.01
+ADD --chown=keycloak:keycloak https://repo1.maven.org/maven2/com/oracle/database/jdbc/ojdbc11/${ORACLE_JDBC_VERSION}/ojdbc11-${ORACLE_JDBC_VERSION}.jar /opt/keycloak/providers/ojdbc11.jar
+ADD --chown=keycloak:keycloak https://repo1.maven.org/maven2/com/oracle/database/nls/orai18n/${ORACLE_JDBC_VERSION}/orai18n-${ORACLE_JDBC_VERSION}.jar /opt/keycloak/providers/orai18n.jar
+
+# Health build parameter
+ENV KC_HEALTH_ENABLED=true
+
+# Copy SPI JAR from builder stage
+COPY --from=builder /app/target/keycloak-dv-builtin-users-authenticator-1.0-SNAPSHOT.jar /opt/keycloak/providers/
+
+# Copy additional configurations
+COPY ./builtin-users-spi/conf/quarkus.properties /opt/keycloak/conf/
+COPY ./test-realm.json /opt/keycloak/data/import/
+
+# Set the Keycloak command
+ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]
+CMD ["start-dev", "--import-realm", "--http-port=8090"]
+
+# Expose port 8090
+EXPOSE 8090
diff --git a/conf/keycloak/builtin-users-spi/conf/quarkus.properties b/conf/keycloak/builtin-users-spi/conf/quarkus.properties
new file mode 100644
index 00000000000..64ce6d898c5
--- /dev/null
+++ b/conf/keycloak/builtin-users-spi/conf/quarkus.properties
@@ -0,0 +1,15 @@
+quarkus.datasource.user-store.db-kind=postgresql
+quarkus.datasource.user-store.jdbc.url=jdbc:postgresql://${DATAVERSE_DB_HOST}:${DATAVERSE_DB_PORT}/dataverse
+quarkus.datasource.user-store.username=${DATAVERSE_DB_USER}
+quarkus.datasource.user-store.password=${DATAVERSE_DB_PASSWORD}
+
+quarkus.datasource.user-store.jdbc.driver=org.postgresql.Driver
+quarkus.datasource.user-store.jdbc.transactions=disabled
+quarkus.transaction-manager.unsafe-multiple-last-resources=allow
+
+quarkus.datasource.user-store.jdbc.recovery.username=${DATAVERSE_DB_USER}
+quarkus.datasource.user-store.jdbc.recovery.password=${DATAVERSE_DB_PASSWORD}
+
+quarkus.datasource.user-store.jdbc.xa-properties.serverName=${DATAVERSE_DB_HOST}
+quarkus.datasource.user-store.jdbc.xa-properties.portNumber=${DATAVERSE_DB_PORT}
+quarkus.datasource.user-store.jdbc.xa-properties.databaseName=dataverse
diff --git a/conf/keycloak/builtin-users-spi/pom.xml b/conf/keycloak/builtin-users-spi/pom.xml
new file mode 100644
index 00000000000..afb3495c2be
--- /dev/null
+++ b/conf/keycloak/builtin-users-spi/pom.xml
@@ -0,0 +1,110 @@
+
+ 4.0.0
+ edu.harvard.iq.keycloak
+ keycloak-dv-builtin-users-authenticator
+ 1.0-SNAPSHOT
+ jar
+
+
+
+
+ org.keycloak
+ keycloak-server-spi
+ ${keycloak.version}
+ provided
+
+
+
+
+ org.keycloak
+ keycloak-server-spi-private
+ ${keycloak.version}
+ provided
+
+
+
+
+ org.keycloak
+ keycloak-services
+ ${keycloak.version}
+ provided
+
+
+
+
+ org.keycloak
+ keycloak-model-jpa
+ ${keycloak.version}
+ provided
+
+
+
+
+ jakarta.persistence
+ jakarta.persistence-api
+ ${jakarta.persistence.version}
+
+
+
+
+ org.mindrot
+ jbcrypt
+ ${mindrot.jbcrypt.version}
+ compile
+
+
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ ${junit.jupiter.version}
+ test
+
+
+
+
+ org.mockito
+ mockito-core
+ ${mockito.version}
+ test
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-shade-plugin
+ 3.2.4
+
+
+ package
+
+ shade
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+ 17
+ 17
+
+
+
+
+
+
+ 26.1.4
+ 17
+ 3.2.0
+ 0.4
+ 5.15.2
+ 5.11.4
+
+
diff --git a/conf/keycloak/builtin-users-spi/src/main/java/edu/harvard/iq/keycloak/auth/spi/adapters/DataverseUserAdapter.java b/conf/keycloak/builtin-users-spi/src/main/java/edu/harvard/iq/keycloak/auth/spi/adapters/DataverseUserAdapter.java
new file mode 100644
index 00000000000..d9609fe1c1f
--- /dev/null
+++ b/conf/keycloak/builtin-users-spi/src/main/java/edu/harvard/iq/keycloak/auth/spi/adapters/DataverseUserAdapter.java
@@ -0,0 +1,67 @@
+package edu.harvard.iq.keycloak.auth.spi.adapters;
+
+import edu.harvard.iq.keycloak.auth.spi.models.DataverseUser;
+import org.keycloak.component.ComponentModel;
+import org.keycloak.models.GroupModel;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.RealmModel;
+import org.keycloak.storage.StorageId;
+import org.keycloak.storage.adapter.AbstractUserAdapterFederatedStorage;
+
+import java.util.stream.Stream;
+
+public class DataverseUserAdapter extends AbstractUserAdapterFederatedStorage {
+
+ protected DataverseUser dataverseUser;
+ protected String keycloakId;
+
+ public DataverseUserAdapter(KeycloakSession session, RealmModel realm, ComponentModel model, DataverseUser dataverseUser) {
+ super(session, realm, model);
+ this.dataverseUser = dataverseUser;
+ keycloakId = StorageId.keycloakId(model, dataverseUser.getBuiltinUser().getId().toString());
+ }
+
+ @Override
+ public void setUsername(String s) {
+ }
+
+ @Override
+ public String getUsername() {
+ return dataverseUser.getBuiltinUser().getUsername();
+ }
+
+ @Override
+ public String getEmail() {
+ return dataverseUser.getAuthenticatedUser().getEmail();
+ }
+
+ @Override
+ public String getFirstName() {
+ return dataverseUser.getAuthenticatedUser().getFirstName();
+ }
+
+ @Override
+ public String getLastName() {
+ return dataverseUser.getAuthenticatedUser().getLastName();
+ }
+
+ @Override
+ public Stream getGroupsStream(String search, Integer first, Integer max) {
+ return super.getGroupsStream(search, first, max);
+ }
+
+ @Override
+ public long getGroupsCount() {
+ return super.getGroupsCount();
+ }
+
+ @Override
+ public long getGroupsCountByNameContaining(String search) {
+ return super.getGroupsCountByNameContaining(search);
+ }
+
+ @Override
+ public String getId() {
+ return keycloakId;
+ }
+}
diff --git a/conf/keycloak/builtin-users-spi/src/main/java/edu/harvard/iq/keycloak/auth/spi/models/DataverseAuthenticatedUser.java b/conf/keycloak/builtin-users-spi/src/main/java/edu/harvard/iq/keycloak/auth/spi/models/DataverseAuthenticatedUser.java
new file mode 100644
index 00000000000..d2d1e292ade
--- /dev/null
+++ b/conf/keycloak/builtin-users-spi/src/main/java/edu/harvard/iq/keycloak/auth/spi/models/DataverseAuthenticatedUser.java
@@ -0,0 +1,48 @@
+package edu.harvard.iq.keycloak.auth.spi.models;
+
+import jakarta.persistence.*;
+
+@NamedQueries({
+ @NamedQuery(name = "DataverseAuthenticatedUser.findByEmail",
+ query = "select au from DataverseAuthenticatedUser au WHERE LOWER(au.email)=LOWER(:email)"),
+ @NamedQuery(name = "DataverseAuthenticatedUser.findByIdentifier",
+ query = "select au from DataverseAuthenticatedUser au WHERE LOWER(au.userIdentifier)=LOWER(:identifier)"),
+})
+@Entity
+@Table(name = "authenticateduser")
+public class DataverseAuthenticatedUser {
+ @Id
+ private Integer id;
+ private String email;
+ private String lastName;
+ private String firstName;
+ private String userIdentifier;
+
+ public void setId(Integer id) {
+ this.id = id;
+ }
+
+ public void setEmail(String email) {
+ this.email = email;
+ }
+
+ public void setUserIdentifier(String userIdentifier) {
+ this.userIdentifier = userIdentifier;
+ }
+
+ public String getEmail() {
+ return email;
+ }
+
+ public String getLastName() {
+ return lastName;
+ }
+
+ public String getFirstName() {
+ return firstName;
+ }
+
+ public String getUserIdentifier() {
+ return userIdentifier;
+ }
+}
diff --git a/conf/keycloak/builtin-users-spi/src/main/java/edu/harvard/iq/keycloak/auth/spi/models/DataverseBuiltinUser.java b/conf/keycloak/builtin-users-spi/src/main/java/edu/harvard/iq/keycloak/auth/spi/models/DataverseBuiltinUser.java
new file mode 100644
index 00000000000..b4dd59339d2
--- /dev/null
+++ b/conf/keycloak/builtin-users-spi/src/main/java/edu/harvard/iq/keycloak/auth/spi/models/DataverseBuiltinUser.java
@@ -0,0 +1,48 @@
+package edu.harvard.iq.keycloak.auth.spi.models;
+
+import jakarta.persistence.*;
+
+@NamedQueries({
+ @NamedQuery(name = "DataverseBuiltinUser.findByUsername",
+ query = "SELECT u FROM DataverseBuiltinUser u WHERE LOWER(u.username)=LOWER(:username)")
+})
+@Entity
+@Table(name = "builtinuser")
+public class DataverseBuiltinUser {
+ @Id
+ private Integer id;
+
+ private String username;
+
+ private String encryptedPassword;
+
+ private Integer passwordEncryptionVersion;
+
+ public void setId(Integer id) {
+ this.id = id;
+ }
+
+ public void setUsername(String username) {
+ this.username = username;
+ }
+
+ public Integer getId() {
+ return id;
+ }
+
+ public String getUsername() {
+ return username;
+ }
+
+ public Integer getPasswordEncryptionVersion() {
+ return passwordEncryptionVersion;
+ }
+
+ public void setEncryptedPassword(String encryptedPassword) {
+ this.encryptedPassword = encryptedPassword;
+ }
+
+ public String getEncryptedPassword() {
+ return encryptedPassword;
+ }
+}
diff --git a/conf/keycloak/builtin-users-spi/src/main/java/edu/harvard/iq/keycloak/auth/spi/models/DataverseUser.java b/conf/keycloak/builtin-users-spi/src/main/java/edu/harvard/iq/keycloak/auth/spi/models/DataverseUser.java
new file mode 100644
index 00000000000..d697fe52fc8
--- /dev/null
+++ b/conf/keycloak/builtin-users-spi/src/main/java/edu/harvard/iq/keycloak/auth/spi/models/DataverseUser.java
@@ -0,0 +1,20 @@
+package edu.harvard.iq.keycloak.auth.spi.models;
+
+public class DataverseUser {
+
+ private final DataverseAuthenticatedUser authenticatedUser;
+ private final DataverseBuiltinUser builtinUser;
+
+ public DataverseUser(DataverseAuthenticatedUser authenticatedUser, DataverseBuiltinUser builtinUser) {
+ this.authenticatedUser = authenticatedUser;
+ this.builtinUser = builtinUser;
+ }
+
+ public DataverseAuthenticatedUser getAuthenticatedUser() {
+ return authenticatedUser;
+ }
+
+ public DataverseBuiltinUser getBuiltinUser() {
+ return builtinUser;
+ }
+}
diff --git a/conf/keycloak/builtin-users-spi/src/main/java/edu/harvard/iq/keycloak/auth/spi/providers/DataverseUserStorageProvider.java b/conf/keycloak/builtin-users-spi/src/main/java/edu/harvard/iq/keycloak/auth/spi/providers/DataverseUserStorageProvider.java
new file mode 100644
index 00000000000..20e6eeaefa1
--- /dev/null
+++ b/conf/keycloak/builtin-users-spi/src/main/java/edu/harvard/iq/keycloak/auth/spi/providers/DataverseUserStorageProvider.java
@@ -0,0 +1,83 @@
+package edu.harvard.iq.keycloak.auth.spi.providers;
+
+import edu.harvard.iq.keycloak.auth.spi.adapters.DataverseUserAdapter;
+import edu.harvard.iq.keycloak.auth.spi.models.DataverseUser;
+import edu.harvard.iq.keycloak.auth.spi.services.DataverseAuthenticationService;
+import edu.harvard.iq.keycloak.auth.spi.services.DataverseUserService;
+import org.jboss.logging.Logger;
+import org.keycloak.component.ComponentModel;
+import org.keycloak.credential.CredentialInput;
+import org.keycloak.credential.CredentialInputValidator;
+import org.keycloak.models.*;
+import org.keycloak.models.credential.PasswordCredentialModel;
+import org.keycloak.storage.UserStorageProvider;
+import org.keycloak.storage.user.UserLookupProvider;
+
+/**
+ * DataverseUserStorageProvider integrates Keycloak with Dataverse user storage.
+ * It enables authentication and retrieval of users from a Dataverse-based user store.
+ */
+public class DataverseUserStorageProvider implements
+ UserStorageProvider,
+ UserLookupProvider,
+ CredentialInputValidator {
+
+ private static final Logger logger = Logger.getLogger(DataverseUserStorageProvider.class);
+
+ private final ComponentModel model;
+ private final KeycloakSession session;
+ private final DataverseUserService dataverseUserService;
+
+ public DataverseUserStorageProvider(KeycloakSession session, ComponentModel model) {
+ this.session = session;
+ this.model = model;
+ this.dataverseUserService = new DataverseUserService(session);
+ }
+
+ @Override
+ public UserModel getUserById(RealmModel realm, String id) {
+ DataverseUser dataverseUser = dataverseUserService.getUserById(id);
+ return (dataverseUser != null) ? new DataverseUserAdapter(session, realm, model, dataverseUser) : null;
+ }
+
+ @Override
+ public UserModel getUserByUsername(RealmModel realm, String username) {
+ DataverseUser dataverseUser = dataverseUserService.getUserByUsername(username);
+ return (dataverseUser != null) ? new DataverseUserAdapter(session, realm, model, dataverseUser) : null;
+ }
+
+ @Override
+ public UserModel getUserByEmail(RealmModel realm, String email) {
+ DataverseUser dataverseUser = dataverseUserService.getUserByEmail(email);
+ return (dataverseUser != null) ? new DataverseUserAdapter(session, realm, model, dataverseUser) : null;
+ }
+
+ @Override
+ public boolean supportsCredentialType(String credentialType) {
+ return PasswordCredentialModel.TYPE.equals(credentialType);
+ }
+
+ @Override
+ public boolean isConfiguredFor(RealmModel realm, UserModel user, String credentialType) {
+ logger.debugf("Checking credential configuration for user: %s, credentialType: %s", user.getUsername(), credentialType);
+ return false;
+ }
+
+ @Override
+ public boolean isValid(RealmModel realm, UserModel user, CredentialInput input) {
+ logger.debugf("Validating credentials for user: %s", user.getUsername());
+
+ if (!supportsCredentialType(input.getType()) || !(input instanceof UserCredentialModel userCredential)) {
+ return false;
+ }
+
+ DataverseAuthenticationService dataverseAuthenticationService = new DataverseAuthenticationService(dataverseUserService);
+ return dataverseAuthenticationService.canLogInAsBuiltinUser(user.getUsername(), userCredential.getValue());
+ }
+
+ @Override
+ public void close() {
+ logger.debug("Closing DataverseUserStorageProvider");
+ this.dataverseUserService.close();
+ }
+}
diff --git a/conf/keycloak/builtin-users-spi/src/main/java/edu/harvard/iq/keycloak/auth/spi/providers/DataverseUserStorageProviderFactory.java b/conf/keycloak/builtin-users-spi/src/main/java/edu/harvard/iq/keycloak/auth/spi/providers/DataverseUserStorageProviderFactory.java
new file mode 100644
index 00000000000..688d530dd02
--- /dev/null
+++ b/conf/keycloak/builtin-users-spi/src/main/java/edu/harvard/iq/keycloak/auth/spi/providers/DataverseUserStorageProviderFactory.java
@@ -0,0 +1,33 @@
+package edu.harvard.iq.keycloak.auth.spi.providers;
+
+import org.jboss.logging.Logger;
+import org.keycloak.component.ComponentModel;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.storage.UserStorageProviderFactory;
+
+public class DataverseUserStorageProviderFactory implements UserStorageProviderFactory {
+
+ public static final String PROVIDER_ID = "dv-builtin-users-authenticator";
+
+ private static final Logger logger = Logger.getLogger(DataverseUserStorageProviderFactory.class);
+
+ @Override
+ public DataverseUserStorageProvider create(KeycloakSession session, ComponentModel model) {
+ return new DataverseUserStorageProvider(session, model);
+ }
+
+ @Override
+ public String getId() {
+ return PROVIDER_ID;
+ }
+
+ @Override
+ public String getHelpText() {
+ return "A Keycloak Storage Provider to authenticate Dataverse Builtin Users";
+ }
+
+ @Override
+ public void close() {
+ logger.debug("<<<<<< Closing factory");
+ }
+}
diff --git a/conf/keycloak/builtin-users-spi/src/main/java/edu/harvard/iq/keycloak/auth/spi/services/DataverseAuthenticationService.java b/conf/keycloak/builtin-users-spi/src/main/java/edu/harvard/iq/keycloak/auth/spi/services/DataverseAuthenticationService.java
new file mode 100644
index 00000000000..995662e1cb6
--- /dev/null
+++ b/conf/keycloak/builtin-users-spi/src/main/java/edu/harvard/iq/keycloak/auth/spi/services/DataverseAuthenticationService.java
@@ -0,0 +1,48 @@
+package edu.harvard.iq.keycloak.auth.spi.services;
+
+import edu.harvard.iq.keycloak.auth.spi.models.DataverseBuiltinUser;
+import edu.harvard.iq.keycloak.auth.spi.models.DataverseUser;
+
+public class DataverseAuthenticationService {
+
+ private final DataverseUserService dataverseUserService;
+
+ private PasswordEncryption.Algorithm passwordEncryptionAlgorithm;
+
+ public DataverseAuthenticationService(DataverseUserService dataverseUserService) {
+ this(dataverseUserService, null);
+ }
+
+ // Just for testing purposes, do not use
+ public DataverseAuthenticationService(DataverseUserService dataverseUserService, PasswordEncryption.Algorithm passwordEncryptionAlgorithm) {
+ this.dataverseUserService = dataverseUserService;
+ this.passwordEncryptionAlgorithm = passwordEncryptionAlgorithm;
+ }
+
+ /**
+ * Validates if a Dataverse built-in user can log in with the given credentials.
+ *
+ * @param usernameOrEmail The username or email of the Dataverse built-in user.
+ * @param password The password to be validated.
+ * @return {@code true} if the user can log in, {@code false} otherwise.
+ */
+ public boolean canLogInAsBuiltinUser(String usernameOrEmail, String password) {
+ DataverseUser dataverseUser = this.dataverseUserService.getUserByUsername(usernameOrEmail);
+
+ if (dataverseUser == null) {
+ dataverseUser = this.dataverseUserService.getUserByEmail(usernameOrEmail);
+ }
+
+ if (dataverseUser == null) {
+ return false;
+ }
+
+ DataverseBuiltinUser builtinUser = dataverseUser.getBuiltinUser();
+
+ if (passwordEncryptionAlgorithm == null) {
+ passwordEncryptionAlgorithm = PasswordEncryption.getVersion(builtinUser.getPasswordEncryptionVersion());
+ }
+
+ return passwordEncryptionAlgorithm.check(password, builtinUser.getEncryptedPassword());
+ }
+}
diff --git a/conf/keycloak/builtin-users-spi/src/main/java/edu/harvard/iq/keycloak/auth/spi/services/DataverseUserService.java b/conf/keycloak/builtin-users-spi/src/main/java/edu/harvard/iq/keycloak/auth/spi/services/DataverseUserService.java
new file mode 100644
index 00000000000..d7cae489c20
--- /dev/null
+++ b/conf/keycloak/builtin-users-spi/src/main/java/edu/harvard/iq/keycloak/auth/spi/services/DataverseUserService.java
@@ -0,0 +1,96 @@
+package edu.harvard.iq.keycloak.auth.spi.services;
+
+import edu.harvard.iq.keycloak.auth.spi.models.DataverseAuthenticatedUser;
+import edu.harvard.iq.keycloak.auth.spi.models.DataverseBuiltinUser;
+import edu.harvard.iq.keycloak.auth.spi.models.DataverseUser;
+import jakarta.persistence.EntityManager;
+import org.jboss.logging.Logger;
+import org.keycloak.connections.jpa.JpaConnectionProvider;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.storage.StorageId;
+
+import java.util.List;
+
+public class DataverseUserService {
+
+ private static final Logger logger = Logger.getLogger(DataverseUserService.class);
+
+ private final EntityManager em;
+
+ public DataverseUserService(KeycloakSession session) {
+ this.em = session.getProvider(JpaConnectionProvider.class, "user-store").getEntityManager();
+ }
+
+ public DataverseUser getUserById(String id) {
+ logger.debugf("Fetching user by ID: %s", id);
+ String persistenceId = StorageId.externalId(id);
+
+ DataverseBuiltinUser builtinUser = em.find(DataverseBuiltinUser.class, persistenceId);
+ if (builtinUser == null) {
+ logger.debugf("User not found for external ID: %s", persistenceId);
+ return null;
+ }
+
+ DataverseAuthenticatedUser authenticatedUser = getAuthenticatedUserByUsername(builtinUser.getUsername());
+
+ return new DataverseUser(authenticatedUser, builtinUser);
+ }
+
+ public DataverseUser getUserByUsername(String username) {
+ logger.debugf("Fetching user by username: %s", username);
+ List users = em.createNamedQuery("DataverseBuiltinUser.findByUsername", DataverseBuiltinUser.class)
+ .setParameter("username", username)
+ .getResultList();
+
+ if (users.isEmpty()) {
+ logger.debugf("User not found by username: %s", username);
+ return null;
+ }
+
+ DataverseAuthenticatedUser authenticatedUser = getAuthenticatedUserByUsername(username);
+
+ return new DataverseUser(authenticatedUser, users.get(0));
+ }
+
+ public DataverseUser getUserByEmail(String email) {
+ logger.debugf("Fetching user by email: %s", email);
+ List authUsers = em.createNamedQuery("DataverseAuthenticatedUser.findByEmail", DataverseAuthenticatedUser.class)
+ .setParameter("email", email)
+ .getResultList();
+
+ if (authUsers.isEmpty()) {
+ logger.debugf("User not found by email: %s", email);
+ return null;
+ }
+
+ String username = authUsers.get(0).getUserIdentifier();
+ List builtinUsers = em.createNamedQuery("DataverseBuiltinUser.findByUsername", DataverseBuiltinUser.class)
+ .setParameter("username", username)
+ .getResultList();
+
+ return new DataverseUser(authUsers.get(0), builtinUsers.get(0));
+ }
+
+ public void close() {
+ if (em != null) {
+ em.close();
+ }
+ }
+
+ /**
+ * Retrieves an authenticated user from Dataverse by username.
+ *
+ * @param username The username to look up.
+ * @return The authenticated user or null if not found.
+ */
+ private DataverseAuthenticatedUser getAuthenticatedUserByUsername(String username) {
+ try {
+ return em.createNamedQuery("DataverseAuthenticatedUser.findByIdentifier", DataverseAuthenticatedUser.class)
+ .setParameter("identifier", username)
+ .getSingleResult();
+ } catch (Exception e) {
+ logger.debugf("Could not find authenticated user by username: %s", username);
+ return null;
+ }
+ }
+}
diff --git a/conf/keycloak/builtin-users-spi/src/main/java/edu/harvard/iq/keycloak/auth/spi/services/PasswordEncryption.java b/conf/keycloak/builtin-users-spi/src/main/java/edu/harvard/iq/keycloak/auth/spi/services/PasswordEncryption.java
new file mode 100644
index 00000000000..f8ecd4232b3
--- /dev/null
+++ b/conf/keycloak/builtin-users-spi/src/main/java/edu/harvard/iq/keycloak/auth/spi/services/PasswordEncryption.java
@@ -0,0 +1,86 @@
+package edu.harvard.iq.keycloak.auth.spi.services;
+
+import org.mindrot.jbcrypt.BCrypt;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Base64;
+
+/**
+ * Password encryption, supporting multiple encryption algorithms to
+ * allow migrations between them.
+ *
+ * When adding a new password hashing algorithm, implement the {@link Algorithm}
+ * interface, and add an instance of the implementation as the last element
+ * of the {@link #algorithms} array. The rest should pretty much happen automatically
+ * (e.g system will detect outdated passwords for users and initiate the password reset breakout).
+ *
+ * NOTE: This class is a copy of the one in
+ * {@code edu.harvard.iq.dataverse.authorization.providers.builtin}
+ * within the Dataverse application and must stay in sync with it.
+ *
+ * @author Ellen Kraffmiller
+ * @author Michael Bar-Sinai
+ */
+public final class PasswordEncryption implements java.io.Serializable {
+
+ public interface Algorithm {
+ boolean check(String plainText, String hashed);
+ }
+
+ /**
+ * The SHA algorithm, now considered not secure enough.
+ */
+ private static final Algorithm SHA = new Algorithm() {
+
+ private String encrypt(String plainText) {
+ try {
+ MessageDigest md = MessageDigest.getInstance("SHA");
+ md.update(plainText.getBytes(StandardCharsets.UTF_8));
+ byte[] raw = md.digest();
+ return Base64.getEncoder().encodeToString(raw);
+
+ } catch (NoSuchAlgorithmException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Override
+ public boolean check(String plainText, String hashed) {
+ return hashed.equals(encrypt(plainText));
+ }
+ };
+
+ /**
+ * BCrypt, using a complexity factor of 10 (considered safe by 2015 standards).
+ */
+ private static final Algorithm BCRYPT_10 = new Algorithm() {
+
+ @Override
+ public boolean check(String plainText, String hashed) {
+ try {
+ return BCrypt.checkpw(plainText, hashed);
+ } catch (IllegalArgumentException iae) {
+ // the password was probably not hashed using bcrypt.
+ return false;
+ }
+ }
+ };
+
+ private static final Algorithm[] algorithms;
+
+ static {
+ algorithms = new Algorithm[]{SHA, BCRYPT_10};
+ }
+
+ /**
+ * Prevent people instantiating this class.
+ */
+ private PasswordEncryption() {
+ }
+
+ public static Algorithm getVersion(int i) {
+ return algorithms[i];
+ }
+}
diff --git a/conf/keycloak/builtin-users-spi/src/main/resources/META-INF/beans.xml b/conf/keycloak/builtin-users-spi/src/main/resources/META-INF/beans.xml
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/conf/keycloak/builtin-users-spi/src/main/resources/META-INF/persistence.xml b/conf/keycloak/builtin-users-spi/src/main/resources/META-INF/persistence.xml
new file mode 100644
index 00000000000..5c1e867dc6d
--- /dev/null
+++ b/conf/keycloak/builtin-users-spi/src/main/resources/META-INF/persistence.xml
@@ -0,0 +1,32 @@
+
+
+
+ edu.harvard.iq.keycloak.auth.spi.models.DataverseBuiltinUser
+ edu.harvard.iq.keycloak.auth.spi.models.DataverseAuthenticatedUser
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/conf/keycloak/builtin-users-spi/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory b/conf/keycloak/builtin-users-spi/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory
new file mode 100644
index 00000000000..4ec99f734db
--- /dev/null
+++ b/conf/keycloak/builtin-users-spi/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory
@@ -0,0 +1 @@
+edu.harvard.iq.keycloak.auth.spi.providers.DataverseUserStorageProviderFactory
\ No newline at end of file
diff --git a/conf/keycloak/builtin-users-spi/src/test/java/edu/harvard/iq/keycloak/auth/spi/services/DataverseAuthenticationServiceTest.java b/conf/keycloak/builtin-users-spi/src/test/java/edu/harvard/iq/keycloak/auth/spi/services/DataverseAuthenticationServiceTest.java
new file mode 100644
index 00000000000..35f37973b71
--- /dev/null
+++ b/conf/keycloak/builtin-users-spi/src/test/java/edu/harvard/iq/keycloak/auth/spi/services/DataverseAuthenticationServiceTest.java
@@ -0,0 +1,64 @@
+package edu.harvard.iq.keycloak.auth.spi.services;
+
+import edu.harvard.iq.keycloak.auth.spi.models.DataverseBuiltinUser;
+import edu.harvard.iq.keycloak.auth.spi.models.DataverseUser;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class DataverseAuthenticationServiceTest {
+
+ private DataverseUserService dataverseUserServiceMock;
+ private PasswordEncryption.Algorithm passwordEncryptionAlgorithmMock;
+ private DataverseAuthenticationService sut;
+
+ @BeforeEach
+ void setUp() {
+ dataverseUserServiceMock = mock(DataverseUserService.class);
+ passwordEncryptionAlgorithmMock = mock(PasswordEncryption.Algorithm.class);
+ sut = new DataverseAuthenticationService(dataverseUserServiceMock, passwordEncryptionAlgorithmMock);
+ }
+
+ @Test
+ void canLogInAsBuiltinUser_userFoundByUsername_validCredentials() {
+ setupUserMock("username", true, true);
+ assertTrue(sut.canLogInAsBuiltinUser("username", "password"));
+ }
+
+ @Test
+ void canLogInAsBuiltinUser_userFoundByUsername_invalidCredentials() {
+ setupUserMock("username", true, false);
+ assertFalse(sut.canLogInAsBuiltinUser("username", "password"));
+ }
+
+ @Test
+ void canLogInAsBuiltinUser_userFoundByEmail_validCredentials() {
+ setupUserMock("user@dataverse.org", false, true);
+ assertTrue(sut.canLogInAsBuiltinUser("user@dataverse.org", "password"));
+ }
+
+ @Test
+ void canLogInAsBuiltinUser_userFoundByEmail_invalidCredentials() {
+ setupUserMock("user@dataverse.org", false, false);
+ assertFalse(sut.canLogInAsBuiltinUser("user@dataverse.org", "password"));
+ }
+
+ private void setupUserMock(String identifier, boolean foundByUsername, boolean validPassword) {
+ String encryptedPassword = "encryptedPassword";
+ DataverseUser dataverseUserMock = mock(DataverseUser.class);
+ DataverseBuiltinUser dataverseBuiltinUser = new DataverseBuiltinUser();
+ dataverseBuiltinUser.setEncryptedPassword(encryptedPassword);
+
+ when(dataverseUserMock.getBuiltinUser()).thenReturn(dataverseBuiltinUser);
+ when(passwordEncryptionAlgorithmMock.check(anyString(), eq(encryptedPassword))).thenReturn(validPassword);
+
+ if (foundByUsername) {
+ when(dataverseUserServiceMock.getUserByUsername(identifier)).thenReturn(dataverseUserMock);
+ } else {
+ when(dataverseUserServiceMock.getUserByUsername(identifier)).thenReturn(null);
+ when(dataverseUserServiceMock.getUserByEmail(identifier)).thenReturn(dataverseUserMock);
+ }
+ }
+}
diff --git a/conf/keycloak/builtin-users-spi/src/test/java/edu/harvard/iq/keycloak/auth/spi/services/DataverseUserServiceTest.java b/conf/keycloak/builtin-users-spi/src/test/java/edu/harvard/iq/keycloak/auth/spi/services/DataverseUserServiceTest.java
new file mode 100644
index 00000000000..bae96bfc9fc
--- /dev/null
+++ b/conf/keycloak/builtin-users-spi/src/test/java/edu/harvard/iq/keycloak/auth/spi/services/DataverseUserServiceTest.java
@@ -0,0 +1,148 @@
+package edu.harvard.iq.keycloak.auth.spi.services;
+
+import edu.harvard.iq.keycloak.auth.spi.models.DataverseAuthenticatedUser;
+import edu.harvard.iq.keycloak.auth.spi.models.DataverseBuiltinUser;
+import edu.harvard.iq.keycloak.auth.spi.models.DataverseUser;
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.TypedQuery;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.keycloak.connections.jpa.JpaConnectionProvider;
+import org.keycloak.models.KeycloakSession;
+
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class DataverseUserServiceTest {
+
+ private EntityManager entityManagerMock;
+ private DataverseUserService sut;
+
+ @BeforeEach
+ void setUp() {
+ entityManagerMock = mock(EntityManager.class);
+ KeycloakSession sessionMock = mock(KeycloakSession.class);
+
+ JpaConnectionProvider jpaConnectionProviderMock = mock(JpaConnectionProvider.class);
+ when(sessionMock.getProvider(JpaConnectionProvider.class, "user-store")).thenReturn(jpaConnectionProviderMock);
+ when(jpaConnectionProviderMock.getEntityManager()).thenReturn(entityManagerMock);
+
+ sut = new DataverseUserService(sessionMock);
+ }
+
+ @Test
+ void getUserById_userExists() {
+ String testUserId = "123";
+ String testUsername = "testuser";
+
+ DataverseBuiltinUser builtinUser = new DataverseBuiltinUser();
+ builtinUser.setId(1);
+ builtinUser.setUsername(testUsername);
+
+ when(entityManagerMock.find(DataverseBuiltinUser.class, "123")).thenReturn(builtinUser);
+ TypedQuery authUserQuery = mock(TypedQuery.class);
+ when(entityManagerMock.createNamedQuery("DataverseAuthenticatedUser.findByIdentifier", DataverseAuthenticatedUser.class))
+ .thenReturn(authUserQuery);
+ when(authUserQuery.setParameter("identifier", testUsername)).thenReturn(authUserQuery);
+
+ DataverseAuthenticatedUser authUser = new DataverseAuthenticatedUser();
+ authUser.setUserIdentifier(testUsername);
+ when(authUserQuery.getSingleResult()).thenReturn(authUser);
+
+ DataverseUser user = sut.getUserById(testUserId);
+ assertNotNull(user);
+ assertEquals(testUsername, user.getBuiltinUser().getUsername());
+ }
+
+ @Test
+ void getUserById_userNotFound() {
+ when(entityManagerMock.find(DataverseBuiltinUser.class, "123")).thenReturn(null);
+ assertNull(sut.getUserById("123"));
+ }
+
+ @Test
+ void getUserByUsername_userExists() {
+ String testUsername = "testuser";
+
+ DataverseBuiltinUser builtinUser = new DataverseBuiltinUser();
+ builtinUser.setUsername(testUsername);
+ builtinUser.setId(1);
+
+ DataverseAuthenticatedUser authUser = new DataverseAuthenticatedUser();
+ authUser.setUserIdentifier(testUsername);
+
+ TypedQuery builtinUserQuery = mock(TypedQuery.class);
+ TypedQuery authUserQuery = mock(TypedQuery.class);
+
+ when(entityManagerMock.createNamedQuery("DataverseBuiltinUser.findByUsername", DataverseBuiltinUser.class))
+ .thenReturn(builtinUserQuery);
+ when(builtinUserQuery.setParameter("username", testUsername)).thenReturn(builtinUserQuery);
+ when(builtinUserQuery.getResultList()).thenReturn(Collections.singletonList(builtinUser));
+
+ when(entityManagerMock.createNamedQuery("DataverseAuthenticatedUser.findByIdentifier", DataverseAuthenticatedUser.class))
+ .thenReturn(authUserQuery);
+ when(authUserQuery.setParameter("identifier", testUsername)).thenReturn(authUserQuery);
+ when(authUserQuery.getSingleResult()).thenReturn(authUser);
+
+ DataverseUser user = sut.getUserByUsername(testUsername);
+ assertNotNull(user);
+ assertEquals(testUsername, user.getBuiltinUser().getUsername());
+ }
+
+ @Test
+ void getUserByUsername_userNotFound() {
+ TypedQuery query = mock(TypedQuery.class);
+ when(entityManagerMock.createNamedQuery("DataverseBuiltinUser.findByUsername", DataverseBuiltinUser.class))
+ .thenReturn(query);
+ when(query.setParameter("username", "unknown")).thenReturn(query);
+ when(query.getResultList()).thenReturn(Collections.emptyList());
+
+ assertNull(sut.getUserByUsername("unknown"));
+ }
+
+ @Test
+ void getUserByEmail_userExists() {
+ String testEmail = "test@dataverse.org";
+ String testUsername = "testuser";
+
+ DataverseAuthenticatedUser authUser = new DataverseAuthenticatedUser();
+ authUser.setEmail(testEmail);
+ authUser.setId(1);
+ authUser.setUserIdentifier(testUsername);
+
+ DataverseBuiltinUser builtinUser = new DataverseBuiltinUser();
+ builtinUser.setUsername(testUsername);
+ builtinUser.setId(1);
+
+ TypedQuery authUserQuery = mock(TypedQuery.class);
+ TypedQuery builtinUserQuery = mock(TypedQuery.class);
+
+ when(entityManagerMock.createNamedQuery("DataverseAuthenticatedUser.findByEmail", DataverseAuthenticatedUser.class))
+ .thenReturn(authUserQuery);
+ when(authUserQuery.setParameter("email", testEmail)).thenReturn(authUserQuery);
+ when(authUserQuery.getResultList()).thenReturn(Collections.singletonList(authUser));
+
+ when(entityManagerMock.createNamedQuery("DataverseBuiltinUser.findByUsername", DataverseBuiltinUser.class))
+ .thenReturn(builtinUserQuery);
+ when(builtinUserQuery.setParameter("username", testUsername)).thenReturn(builtinUserQuery);
+ when(builtinUserQuery.getResultList()).thenReturn(Collections.singletonList(builtinUser));
+
+ DataverseUser user = sut.getUserByEmail(testEmail);
+ assertNotNull(user);
+ assertEquals(testUsername, user.getBuiltinUser().getUsername());
+ }
+
+ @Test
+ void getUserByEmail_userNotFound() {
+ TypedQuery query = mock(TypedQuery.class);
+ when(entityManagerMock.createNamedQuery("DataverseAuthenticatedUser.findByEmail", DataverseAuthenticatedUser.class))
+ .thenReturn(query);
+ when(query.setParameter("email", "unknown@dataverse.org")).thenReturn(query);
+ when(query.getResultList()).thenReturn(Collections.emptyList());
+
+ assertNull(sut.getUserByEmail("unknown@dataverse.org"));
+ }
+}
diff --git a/conf/keycloak/docker-compose-dev.yml b/conf/keycloak/docker-compose-dev.yml
new file mode 100644
index 00000000000..7356161ec47
--- /dev/null
+++ b/conf/keycloak/docker-compose-dev.yml
@@ -0,0 +1,318 @@
+# This file is designed for testing Keycloak authentication using the
+# Dataverse Builtin Users SPI.
+#
+# Keycloak is deployed using a custom-built image, defined by a Dockerfile
+# located in this directory. This allows for a controlled
+# and flexible development setup. Note that this image is currently
+# intended for development and testing purposes only and should be used
+# accordingly in non-production environments.
+
+version: "2.4"
+
+services:
+
+ dev_dataverse:
+ container_name: "dev_dataverse"
+ hostname: dataverse
+ image: ${APP_IMAGE}
+ restart: on-failure
+ user: payara
+ environment:
+ DATAVERSE_DB_HOST: postgres
+ DATAVERSE_DB_PASSWORD: secret
+ DATAVERSE_DB_USER: ${DATAVERSE_DB_USER}
+ ENABLE_JDWP: "1"
+ ENABLE_RELOAD: "1"
+ SKIP_DEPLOY: "${SKIP_DEPLOY}"
+ DATAVERSE_JSF_REFRESH_PERIOD: "1"
+ DATAVERSE_FEATURE_API_BEARER_AUTH: "1"
+ DATAVERSE_FEATURE_INDEX_HARVESTED_METADATA_SOURCE: "1"
+ DATAVERSE_FEATURE_API_BEARER_AUTH_PROVIDE_MISSING_CLAIMS: "1"
+ DATAVERSE_FEATURE_API_BEARER_AUTH_USE_BUILTIN_USER_ON_ID_MATCH: "1"
+ DATAVERSE_MAIL_SYSTEM_EMAIL: "dataverse@localhost"
+ DATAVERSE_MAIL_MTA_HOST: "smtp"
+ DATAVERSE_AUTH_OIDC_ENABLED: "1"
+ DATAVERSE_AUTH_OIDC_CLIENT_ID: test
+ DATAVERSE_AUTH_OIDC_CLIENT_SECRET: 94XHrfNRwXsjqTqApRrwWmhDLDHpIYV8
+ DATAVERSE_AUTH_OIDC_AUTH_SERVER_URL: http://keycloak.mydomain.com:8090/realms/test
+ DATAVERSE_SPI_EXPORTERS_DIRECTORY: "/dv/exporters"
+ # These two oai settings are here to get HarvestingServerIT to pass
+ dataverse_oai_server_maxidentifiers: "2"
+ dataverse_oai_server_maxrecords: "2"
+ JVM_ARGS: -Ddataverse.files.storage-driver-id=file1
+ -Ddataverse.files.file1.type=file
+ -Ddataverse.files.file1.label=Filesystem
+ -Ddataverse.files.file1.directory=${STORAGE_DIR}/store
+ -Ddataverse.files.localstack1.type=s3
+ -Ddataverse.files.localstack1.label=LocalStack
+ -Ddataverse.files.localstack1.custom-endpoint-url=http://localstack:4566
+ -Ddataverse.files.localstack1.custom-endpoint-region=us-east-2
+ -Ddataverse.files.localstack1.bucket-name=mybucket
+ -Ddataverse.files.localstack1.path-style-access=true
+ -Ddataverse.files.localstack1.upload-redirect=true
+ -Ddataverse.files.localstack1.download-redirect=true
+ -Ddataverse.files.localstack1.access-key=default
+ -Ddataverse.files.localstack1.secret-key=default
+ -Ddataverse.files.minio1.type=s3
+ -Ddataverse.files.minio1.label=MinIO
+ -Ddataverse.files.minio1.custom-endpoint-url=http://minio:9000
+ -Ddataverse.files.minio1.custom-endpoint-region=us-east-1
+ -Ddataverse.files.minio1.bucket-name=mybucket
+ -Ddataverse.files.minio1.path-style-access=true
+ -Ddataverse.files.minio1.upload-redirect=false
+ -Ddataverse.files.minio1.download-redirect=false
+ -Ddataverse.files.minio1.access-key=4cc355_k3y
+ -Ddataverse.files.minio1.secret-key=s3cr3t_4cc355_k3y
+ -Ddataverse.pid.providers=fake
+ -Ddataverse.pid.default-provider=fake
+ -Ddataverse.pid.fake.type=FAKE
+ -Ddataverse.pid.fake.label=FakeDOIProvider
+ -Ddataverse.pid.fake.authority=10.5072
+ -Ddataverse.pid.fake.shoulder=FK2/
+ #-Ddataverse.lang.directory=/dv/lang
+ ports:
+ - "8080:8080" # HTTP (Dataverse Application)
+ - "4949:4848" # HTTPS (Payara Admin Console)
+ - "9009:9009" # JDWP
+ - "8686:8686" # JMX
+ networks:
+ - dataverse
+ depends_on:
+ - dev_postgres
+ - dev_solr
+ - dev_dv_initializer
+ volumes:
+ - ./docker-dev-volumes/app/data:/dv
+ - ./docker-dev-volumes/app/secrets:/secrets
+ - ../../target/dataverse:/opt/payara/deployments/dataverse:ro
+ tmpfs:
+ - /dumps:mode=770,size=2052M,uid=1000,gid=1000
+ - /tmp:mode=770,size=2052M,uid=1000,gid=1000
+ mem_limit: 2147483648 # 2 GiB
+ mem_reservation: 1024m
+ privileged: false
+
+ dev_bootstrap:
+ container_name: "dev_bootstrap"
+ image: gdcc/configbaker:unstable
+ restart: "no"
+ command:
+ - bootstrap.sh
+ - dev
+ networks:
+ - dataverse
+ volumes:
+ - ./docker-dev-volumes/solr/data:/var/solr
+
+ dev_dv_initializer:
+ container_name: "dev_dv_initializer"
+ image: gdcc/configbaker:unstable
+ restart: "no"
+ command:
+ - sh
+ - -c
+ - "fix-fs-perms.sh dv"
+ volumes:
+ - ./docker-dev-volumes/app/data:/dv
+
+ dev_postgres:
+ container_name: "dev_postgres"
+ hostname: postgres
+ image: postgres:${POSTGRES_VERSION}
+ restart: on-failure
+ environment:
+ - POSTGRES_USER=${DATAVERSE_DB_USER}
+ - POSTGRES_PASSWORD=secret
+ ports:
+ - "5432:5432"
+ networks:
+ - dataverse
+ volumes:
+ - ./docker-dev-volumes/postgresql/data:/var/lib/postgresql/data
+
+ dev_solr_initializer:
+ container_name: "dev_solr_initializer"
+ image: gdcc/configbaker:unstable
+ restart: "no"
+ command:
+ - sh
+ - -c
+ - "fix-fs-perms.sh solr && cp -a /template/* /solr-template"
+ volumes:
+ - ./docker-dev-volumes/solr/data:/var/solr
+ - ./docker-dev-volumes/solr/conf:/solr-template
+
+ dev_solr:
+ container_name: "dev_solr"
+ hostname: "solr"
+ image: solr:${SOLR_VERSION}
+ depends_on:
+ - dev_solr_initializer
+ restart: on-failure
+ ports:
+ - "8983:8983"
+ networks:
+ - dataverse
+ command:
+ - "solr-precreate"
+ - "collection1"
+ - "/template"
+ volumes:
+ - ./docker-dev-volumes/solr/data:/var/solr
+ - ./docker-dev-volumes/solr/conf:/template
+
+ dev_smtp:
+ container_name: "dev_smtp"
+ hostname: "smtp"
+ image: maildev/maildev:2.0.5
+ restart: on-failure
+ ports:
+ - "25:25" # smtp server
+ - "1080:1080" # web ui
+ environment:
+ - MAILDEV_SMTP_PORT=25
+ - MAILDEV_MAIL_DIRECTORY=/mail
+ networks:
+ - dataverse
+ #volumes:
+ # - ./docker-dev-volumes/smtp/data:/mail
+ tmpfs:
+ - /mail:mode=770,size=128M,uid=1000,gid=1000
+
+ dev_keycloak:
+ container_name: "dev_keycloak"
+ build:
+ context: .
+ dockerfile: Dockerfile
+ image: gdcc/keycloak
+ hostname: keycloak
+ environment:
+ - KEYCLOAK_ADMIN=kcadmin
+ - KEYCLOAK_ADMIN_PASSWORD=kcpassword
+ - KEYCLOAK_LOGLEVEL=DEBUG
+ - KC_HOSTNAME_STRICT=false
+ - KC_DB=postgres
+ - KC_DB_URL=jdbc:postgresql://postgres:5432/dataverse
+ - KC_DB_USERNAME=${DATAVERSE_DB_USER}
+ - KC_DB_PASSWORD=secret
+ - DATAVERSE_DB_HOST=postgres
+ - DATAVERSE_DB_PORT=5432
+ - DATAVERSE_DB_USER=${DATAVERSE_DB_USER}
+ - DATAVERSE_DB_PASSWORD=secret
+ - DATAVERSE_BASE_URL=http://dataverse:8080
+ networks:
+ dataverse:
+ aliases:
+ - keycloak.mydomain.com #create a DNS alias within the network (add the same alias to your /etc/hosts to get a working OIDC flow)
+ command: start-dev --verbose --import-realm --http-port=8090 # change port to 8090, so within the network and external the same port is used
+ expose:
+ - "9000"
+ ports:
+ - "8090:8090"
+
+ dev_keycloak_initializer:
+ image: alpine:latest
+ container_name: "dev_keycloak_initializer"
+ depends_on:
+ - dev_keycloak
+ environment:
+ - KEYCLOAK_ADMIN=kcadmin
+ - KEYCLOAK_ADMIN_PASSWORD=kcpassword
+ volumes:
+ - ./setup-spi.sh:/usr/local/bin/setup-spi.sh
+ command: [ "/bin/sh", "-c", "apk add --no-cache curl jq && /usr/local/bin/setup-spi.sh" ]
+ networks:
+ - dataverse
+
+ # This proxy configuration is only intended to be used for development purposes!
+ # DO NOT USE IN PRODUCTION! HIGH SECURITY RISK!
+ dev_proxy:
+ image: caddy:2-alpine
+ # The command below is enough to enable using the admin gui, but it will not rewrite location headers to HTTP.
+ # To achieve rewriting from https:// to http://, we need a simple configuration file
+ #command: ["caddy", "reverse-proxy", "-f", ":4848", "-t", "https://dataverse:4848", "--insecure"]
+ command: ["caddy", "run", "-c", "/Caddyfile"]
+ ports:
+ - "4848:4848" # Will expose Payara Admin Console (HTTPS) as HTTP
+ restart: always
+ volumes:
+ - ../proxy/Caddyfile:/Caddyfile:ro
+ depends_on:
+ - dev_dataverse
+ networks:
+ - dataverse
+
+ dev_localstack:
+ container_name: "dev_localstack"
+ hostname: "localstack"
+ image: localstack/localstack:2.3.2
+ restart: on-failure
+ ports:
+ - "127.0.0.1:4566:4566"
+ environment:
+ - DEBUG=${DEBUG-}
+ - DOCKER_HOST=unix:///var/run/docker.sock
+ - HOSTNAME_EXTERNAL=localstack
+ networks:
+ - dataverse
+ volumes:
+ - ../localstack:/etc/localstack/init/ready.d
+ tmpfs:
+ - /localstack:mode=770,size=128M,uid=1000,gid=1000
+
+ dev_minio:
+ container_name: "dev_minio"
+ hostname: "minio"
+ image: minio/minio
+ restart: on-failure
+ ports:
+ - "9000:9000"
+ - "9001:9001"
+ networks:
+ - dataverse
+ volumes:
+ - ./docker-dev-volumes/minio_storage:/data
+ environment:
+ MINIO_ROOT_USER: 4cc355_k3y
+ MINIO_ROOT_PASSWORD: s3cr3t_4cc355_k3y
+ command: server /data
+
+ previewers-provider:
+ container_name: previewers-provider
+ hostname: previewers-provider
+ image: trivadis/dataverse-previewers-provider:latest
+ ports:
+ - "9080:9080"
+ networks:
+ - dataverse
+ environment:
+ # have nginx match the port we run previewers on
+ - NGINX_HTTP_PORT=9080
+ - PREVIEWERS_PROVIDER_URL=http://localhost:9080
+ - VERSIONS="v1.4,betatest"
+ # https://docs.docker.com/reference/compose-file/services/#platform
+ # https://github.com/fabric8io/docker-maven-plugin/issues/1750
+ platform: linux/amd64
+
+ register-previewers:
+ container_name: register-previewers
+ hostname: register-previewers
+ image: trivadis/dataverse-deploy-previewers:latest
+ networks:
+ - dataverse
+ environment:
+ - DATAVERSE_URL=http://dataverse:8080
+ - TIMEOUT=10m
+ - PREVIEWERS_PROVIDER_URL=http://localhost:9080
+ # Uncomment to specify which previewers you want. Otherwise you get all of them.
+ #- INCLUDE_PREVIEWERS=text,html,pdf,csv,comma-separated-values,tsv,tab-separated-values,jpeg,png,gif,markdown,x-markdown
+ - EXCLUDE_PREVIEWERS=
+ - REMOVE_EXISTING=true
+ command:
+ - deploy
+ restart: "no"
+ platform: linux/amd64
+
+networks:
+ dataverse:
+ driver: bridge
diff --git a/conf/keycloak/docker-compose.yml b/conf/keycloak/docker-compose.yml
index 12b2382bd3d..272d8ace363 100644
--- a/conf/keycloak/docker-compose.yml
+++ b/conf/keycloak/docker-compose.yml
@@ -3,7 +3,7 @@ version: "3.9"
services:
keycloak:
- image: 'quay.io/keycloak/keycloak:21.0'
+ image: 'quay.io/keycloak/keycloak:26.1.4'
command:
- "start-dev"
- "--import-realm"
diff --git a/conf/keycloak/run-keycloak.sh b/conf/keycloak/run-keycloak.sh
index ddc5108bee4..9f851a558c7 100755
--- a/conf/keycloak/run-keycloak.sh
+++ b/conf/keycloak/run-keycloak.sh
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
-DOCKER_IMAGE="quay.io/keycloak/keycloak:21.0"
+DOCKER_IMAGE="quay.io/keycloak/keycloak:26.1.4"
KEYCLOAK_USER="kcadmin"
KEYCLOAK_PASSWORD="kcpassword"
KEYCLOAK_PORT=8090
diff --git a/conf/keycloak/setup-spi.sh b/conf/keycloak/setup-spi.sh
new file mode 100755
index 00000000000..f287d71a039
--- /dev/null
+++ b/conf/keycloak/setup-spi.sh
@@ -0,0 +1,41 @@
+#!/bin/sh
+
+echo "Waiting for Keycloak to be fully up..."
+
+# Loop until the health check returns 200
+while true; do
+ RESPONSE=$(curl -s -w "\n%{http_code}" "http://keycloak:9000/health")
+ HTTP_BODY=$(echo "$RESPONSE" | head -n -1) # Extract response body
+ HTTP_CODE=$(echo "$RESPONSE" | tail -n 1) # Extract HTTP status code
+
+ if [ "$HTTP_CODE" -eq 200 ]; then
+ echo "Keycloak is up! (HTTP $HTTP_CODE)"
+ break
+ else
+ echo "Health check failed (HTTP $HTTP_CODE). Response: $HTTP_BODY"
+ sleep 5
+ fi
+done
+
+echo "Keycloak is up and running! Executing SPI setup script..."
+
+# Obtain admin token
+ADMIN_TOKEN=$(curl -s -X POST "http://keycloak:8090/realms/master/protocol/openid-connect/token" \
+ -H "Content-Type: application/x-www-form-urlencoded" \
+ -d "username=$KEYCLOAK_ADMIN" \
+ -d "password=$KEYCLOAK_ADMIN_PASSWORD" \
+ -d "grant_type=password" \
+ -d "client_id=admin-cli" | jq -r .access_token)
+
+# Create user storage provider using the components endpoint
+curl -X POST "http://keycloak:8090/admin/realms/test/components" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "name": "Dataverse built-in users authentication",
+ "providerId": "dv-builtin-users-authenticator",
+ "providerType": "org.keycloak.storage.UserStorageProvider",
+ "parentId": null
+ }'
+
+echo "Keycloak SPI configured in realm."
diff --git a/doc/release-notes/11197-builtin-users-oidc-auth.md b/doc/release-notes/11197-builtin-users-oidc-auth.md
new file mode 100644
index 00000000000..4a9d3292eaf
--- /dev/null
+++ b/doc/release-notes/11197-builtin-users-oidc-auth.md
@@ -0,0 +1,21 @@
+### API_BEARER_AUTH_USE_BUILTIN_USER_ON_ID_MATCH feature flag
+
+A new feature flag called `API_BEARER_AUTH_USE_BUILTIN_USER_ON_ID_MATCH` has been introduced, which allows the use of a built-in user
+account when an identity match is found during OIDC API bearer token authentication.
+
+This feature enables automatic association of an incoming IdP identity with an existing built-in user account, bypassing
+the need for additional user registration steps.
+
+See [the guides](https://dataverse-guide--11193.org.readthedocs.build/en/11193/installation/config.html#feature-flags), #11193, #11197, and #11314.
+
+### Keycloak SPI for Built-In users
+
+A Keycloak SPI, `builtin-users-spi`, has been implemented that allows the use of Keycloak on instances with built-in
+accounts for OIDC
+authentication, enabling the use of the SPA on those instances.
+
+Looking ahead, this authenticator SPI could also support mapping Shibboleth users coming in through Keycloak to existing
+Shib users without changing the provider in the Dataverse database. However, this would require changes to the storage
+provider to support more than just built-in users.
+
+The SPI code is available in the Dataverse code repository (`conf/keycloak/builtin-users-spi`).
diff --git a/doc/sphinx-guides/source/installation/config.rst b/doc/sphinx-guides/source/installation/config.rst
index 6aa5f5c8ff6..f5a3025a382 100644
--- a/doc/sphinx-guides/source/installation/config.rst
+++ b/doc/sphinx-guides/source/installation/config.rst
@@ -3497,6 +3497,9 @@ please find all known feature flags below. Any of these flags can be activated u
* - api-bearer-auth-handle-tos-acceptance-in-idp
- Specifies that Terms of Service acceptance is handled by the IdP, eliminating the need to include ToS acceptance boolean parameter (termsAccepted) in the OIDC user registration request body. This feature only works when the feature flag ``api-bearer-auth`` is also enabled.
- ``Off``
+ * - api-bearer-auth-use-builtin-user-on-id-match
+ - Allows the use of a built-in user account when an identity match is found during API bearer authentication. This feature enables automatic association of an incoming IdP identity with an existing built-in user account, bypassing the need for additional user registration steps. This feature only works when the feature flag ``api-bearer-auth`` is also enabled. **Caution: Enabling this feature flag exposes the installation to potential user impersonation issues depending on the specifics of the IdP configured (For example, if it is configured such that an attacker can create a new account in the IdP, or configured social login account, matching a Dataverse built-in account).**
+ - ``Off``
* - avoid-expensive-solr-join
- Changes the way Solr queries are constructed for public content (published Collections, Datasets and Files). It removes a very expensive Solr join on all such documents, improving overall performance, especially for large instances under heavy load. Before this feature flag is enabled, the corresponding indexing feature (see next feature flag) must be turned on and a full reindex performed (otherwise public objects are not going to be shown in search results). See :doc:`/admin/solr-search-index`.
- ``Off``
diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml
index 8db5b52777d..986c0a49f12 100644
--- a/docker-compose-dev.yml
+++ b/docker-compose-dev.yml
@@ -171,7 +171,7 @@ services:
dev_keycloak:
container_name: "dev_keycloak"
- image: 'quay.io/keycloak/keycloak:21.0'
+ image: 'quay.io/keycloak/keycloak:26.1.4'
hostname: keycloak
environment:
- KEYCLOAK_ADMIN=kcadmin
diff --git a/pom.xml b/pom.xml
index 7b15a97ee73..0c783d5204d 100644
--- a/pom.xml
+++ b/pom.xml
@@ -733,7 +733,7 @@
com.github.dasnikotestcontainers-keycloak
- 3.0.0
+ 3.6.0test
@@ -987,6 +987,9 @@
${testsToExclude}${skipUnitTests}${surefire.jacoco.args} ${argLine}
+
+ **/builtin-users-spi/**
+
diff --git a/src/main/java/edu/harvard/iq/dataverse/api/BuiltinUsers.java b/src/main/java/edu/harvard/iq/dataverse/api/BuiltinUsers.java
index ba99cf33c5b..e6991072d76 100644
--- a/src/main/java/edu/harvard/iq/dataverse/api/BuiltinUsers.java
+++ b/src/main/java/edu/harvard/iq/dataverse/api/BuiltinUsers.java
@@ -4,6 +4,7 @@
import edu.harvard.iq.dataverse.UserNotification;
import edu.harvard.iq.dataverse.actionlogging.ActionLogRecord;
import edu.harvard.iq.dataverse.api.auth.ApiKeyAuthMechanism;
+import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean;
import edu.harvard.iq.dataverse.authorization.UserRecordIdentifier;
import edu.harvard.iq.dataverse.authorization.providers.builtin.BuiltinAuthenticationProvider;
import edu.harvard.iq.dataverse.authorization.providers.builtin.BuiltinUser;
@@ -15,8 +16,10 @@
import java.sql.Timestamp;
import java.util.logging.Level;
import java.util.logging.Logger;
+
import jakarta.ejb.EJB;
import jakarta.ejb.EJBException;
+import jakarta.inject.Inject;
import jakarta.json.Json;
import jakarta.json.JsonObjectBuilder;
import jakarta.ws.rs.GET;
@@ -28,7 +31,6 @@
import jakarta.ws.rs.core.Response.Status;
import java.util.Date;
import static edu.harvard.iq.dataverse.util.json.JsonPrinter.json;
-import static edu.harvard.iq.dataverse.util.json.JsonPrinter.json;
/**
* REST API bean for managing {@link BuiltinUser}s.
@@ -45,6 +47,9 @@ public class BuiltinUsers extends AbstractApiBean {
@EJB
protected BuiltinUserServiceBean builtinUserSvc;
+ @Inject
+ private AuthenticationServiceBean authenticationService;
+
@GET
@Path("{username}/api-token")
public Response getApiToken( @PathParam("username") String username, @QueryParam("password") String password ) {
diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java
index 4b6fd5a1e69..b49fa70cea1 100644
--- a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java
+++ b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java
@@ -38,6 +38,7 @@
import edu.harvard.iq.dataverse.privateurl.PrivateUrl;
import edu.harvard.iq.dataverse.privateurl.PrivateUrlServiceBean;
import edu.harvard.iq.dataverse.search.savedsearch.SavedSearchServiceBean;
+import edu.harvard.iq.dataverse.settings.FeatureFlags;
import edu.harvard.iq.dataverse.util.BundleUtil;
import edu.harvard.iq.dataverse.validation.PasswordValidatorServiceBean;
import edu.harvard.iq.dataverse.workflow.PendingWorkflowInvocation;
@@ -245,11 +246,15 @@ public AuthenticatedUser getAuthenticatedUserWithProvider( String identifier ) {
AuthenticatedUser authenticatedUser = em.createNamedQuery("AuthenticatedUser.findByIdentifier", AuthenticatedUser.class)
.setParameter("identifier", identifier)
.getSingleResult();
- AuthenticatedUserLookup aul = em.createNamedQuery("AuthenticatedUserLookup.findByAuthUser", AuthenticatedUserLookup.class)
- .setParameter("authUser", authenticatedUser)
- .getSingleResult();
- authenticatedUser.setAuthProviderId(aul.getAuthenticationProviderId());
-
+
+ if (authenticatedUser != null) {
+ AuthenticatedUserLookup aul = em.createNamedQuery("AuthenticatedUserLookup.findByAuthUser", AuthenticatedUserLookup.class)
+ .setParameter("authUser", authenticatedUser)
+ .getSingleResult();
+
+ authenticatedUser.setAuthProviderId(aul.getAuthenticationProviderId());
+ }
+
return authenticatedUser;
} catch ( NoResultException nre ) {
return null;
@@ -990,6 +995,10 @@ public AuthenticatedUser lookupUserByOIDCBearerToken(String bearerToken) throws
// TODO: Get the identifier from an invalidating cache to avoid lookup bursts of the same token.
// Tokens in the cache should be removed after some (configurable) time.
OAuth2UserRecord oAuth2UserRecord = verifyOIDCBearerTokenAndGetOAuth2UserRecord(bearerToken);
+ if (FeatureFlags.API_BEARER_AUTH_USE_BUILTIN_USER_ON_ID_MATCH.enabled()) {
+ AuthenticatedUser builtinAuthenticatedUser = lookupUser(BuiltinAuthenticationProvider.PROVIDER_ID, oAuth2UserRecord.getUsername());
+ return (builtinAuthenticatedUser != null) ? builtinAuthenticatedUser : lookupUser(oAuth2UserRecord.getUserRecordIdentifier());
+ }
return lookupUser(oAuth2UserRecord.getUserRecordIdentifier());
}
diff --git a/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java b/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java
index 4326dea6e1c..f3b9a1bf180 100644
--- a/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java
+++ b/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java
@@ -45,7 +45,7 @@ public enum FeatureFlags {
* {@link #API_BEARER_AUTH} is enabled.
*
* @apiNote Raise flag by setting "dataverse.feature.api-bearer-auth-provide-missing-claims"
- * @since Dataverse @TODO:
+ * @since Dataverse 6.6:
*/
API_BEARER_AUTH_PROVIDE_MISSING_CLAIMS("api-bearer-auth-provide-missing-claims"),
/**
@@ -56,9 +56,21 @@ public enum FeatureFlags {
* {@link #API_BEARER_AUTH} is enabled.
*
* @apiNote Raise flag by setting "dataverse.feature.api-bearer-auth-handle-tos-acceptance-in-idp"
- * @since Dataverse @TODO:
+ * @since Dataverse 6.6:
*/
API_BEARER_AUTH_HANDLE_TOS_ACCEPTANCE_IN_IDP("api-bearer-auth-handle-tos-acceptance-in-idp"),
+ /**
+ * Allows the use of a built-in user account when an identity match is found during API bearer authentication.
+ * This feature enables automatic association of an incoming IdP identity with an existing built-in user account,
+ * bypassing the need for additional user registration steps.
+ *
+ *
The value of this feature flag is only considered when the feature flag
+ * {@link #API_BEARER_AUTH} is enabled.
+ *
+ * @apiNote Raise flag by setting "dataverse.feature.api-bearer-auth-use-builtin-user-on-id-match"
+ * @since Dataverse @6.7:
+ */
+ API_BEARER_AUTH_USE_BUILTIN_USER_ON_ID_MATCH("api-bearer-auth-use-builtin-user-on-id-match"),
/**
* For published (public) objects, don't use a join when searching Solr.
* Experimental! Requires a reindex with the following feature flag enabled,
diff --git a/src/test/java/edu/harvard/iq/dataverse/api/BuiltinUsersIT.java b/src/test/java/edu/harvard/iq/dataverse/api/BuiltinUsersIT.java
index af938cbebe1..3fa15657483 100644
--- a/src/test/java/edu/harvard/iq/dataverse/api/BuiltinUsersIT.java
+++ b/src/test/java/edu/harvard/iq/dataverse/api/BuiltinUsersIT.java
@@ -17,9 +17,8 @@
import java.util.stream.Stream;
import jakarta.json.Json;
import jakarta.json.JsonObjectBuilder;
-import static jakarta.ws.rs.core.Response.Status.OK;
-import static jakarta.ws.rs.core.Response.Status.FORBIDDEN;
-import static jakarta.ws.rs.core.Response.Status.UNAUTHORIZED;
+
+import static jakarta.ws.rs.core.Response.Status.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.Matchers.startsWith;
@@ -389,13 +388,6 @@ private Response createUser(String username, String firstName, String lastName,
return response;
}
- private Response getApiTokenUsingEmail(String email, String password) {
- Response response = given()
- .contentType(ContentType.JSON)
- .get("/api/builtin-users/" + email + "/api-token?username=" + email + "&password=" + password);
- return response;
- }
-
private Response getUserFromDatabase(String username) {
Response getUserResponse = given()
.get("/api/admin/authenticatedUsers/" + username + "/");
diff --git a/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java b/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java
index 56ac4eefb3d..2a5642d5659 100644
--- a/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java
+++ b/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java
@@ -4,17 +4,22 @@
import com.nimbusds.oauth2.sdk.token.BearerAccessToken;
import com.nimbusds.openid.connect.sdk.claims.UserInfo;
import edu.harvard.iq.dataverse.authorization.exceptions.AuthorizationException;
+import edu.harvard.iq.dataverse.authorization.providers.builtin.BuiltinAuthenticationProvider;
import edu.harvard.iq.dataverse.authorization.providers.oauth2.OAuth2Exception;
import edu.harvard.iq.dataverse.authorization.providers.oauth2.OAuth2UserRecord;
import edu.harvard.iq.dataverse.authorization.providers.oauth2.oidc.OIDCAuthProvider;
import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser;
import edu.harvard.iq.dataverse.authorization.users.User;
+import edu.harvard.iq.dataverse.settings.JvmSettings;
import edu.harvard.iq.dataverse.util.BundleUtil;
+import edu.harvard.iq.dataverse.util.testing.JvmSetting;
+import edu.harvard.iq.dataverse.util.testing.LocalJvmSettings;
import jakarta.persistence.EntityManager;
import jakarta.persistence.NoResultException;
import jakarta.persistence.TypedQuery;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
import java.io.IOException;
@@ -23,6 +28,7 @@
import static org.junit.jupiter.api.Assertions.*;
+@LocalJvmSettings
public class AuthenticationServiceBeanTest {
private AuthenticationServiceBean sut;
@@ -84,7 +90,7 @@ void testLookupUserByOIDCBearerToken_oneProvider_validToken() throws ParseExcept
setUpOIDCProviderWhichValidatesToken();
// Setting up an authenticated user is found
- AuthenticatedUser authenticatedUser = setupAuthenticatedUserQueryWithResult(new AuthenticatedUser());
+ AuthenticatedUser authenticatedUser = setupAuthenticatedUserByAuthPrvIDQueryWithResult(new AuthenticatedUser());
// When invoking lookupUserByOIDCBearerToken
User actualUser = sut.lookupUserByOIDCBearerToken(TEST_BEARER_TOKEN);
@@ -108,13 +114,66 @@ void testLookupUserByOIDCBearerToken_oneProvider_validToken_noAccount() throws P
assertNull(actualUser);
}
- private AuthenticatedUser setupAuthenticatedUserQueryWithResult(AuthenticatedUser authenticatedUser) {
- TypedQuery queryStub = Mockito.mock(TypedQuery.class);
- AuthenticatedUserLookup lookupStub = Mockito.mock(AuthenticatedUserLookup.class);
- Mockito.when(lookupStub.getAuthenticatedUser()).thenReturn(authenticatedUser);
- Mockito.when(queryStub.getSingleResult()).thenReturn(lookupStub);
- Mockito.when(sut.em.createNamedQuery("AuthenticatedUserLookup.findByAuthPrvID_PersUserId", AuthenticatedUserLookup.class)).thenReturn(queryStub);
- return authenticatedUser;
+ @Test
+ @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-bearer-auth-use-builtin-user-on-id-match")
+ void testLookupUserByOIDCBearerToken_oneProvider_validToken_userNotPresentAsBuiltin_useBuiltinUserOnIdMatchFeatureFlagEnabled()
+ throws ParseException, IOException, AuthorizationException, OAuth2Exception {
+
+ // Given a single OIDC provider that returns a valid user identifier
+ setUpOIDCProviderWhichValidatesToken();
+
+ // Spy on the SUT to verify method calls
+ AuthenticationServiceBean spySut = Mockito.spy(sut);
+
+ // Setting up an authenticated user is found (but only after the second call to lookupUser, that is, not coming from the builtin user provider)
+ AuthenticatedUser authenticatedUser = setupAuthenticatedUserByAuthPrvIDQueryWithResult(new AuthenticatedUser(), true);
+
+ // When invoking lookupUserByOIDCBearerToken
+ User actualUser = spySut.lookupUserByOIDCBearerToken(TEST_BEARER_TOKEN);
+
+ // Then the actual user should match the expected authenticated user
+ assertEquals(authenticatedUser, actualUser);
+
+ // Capture calls to lookupUser
+ ArgumentCaptor providerIdCaptor = ArgumentCaptor.forClass(String.class);
+ ArgumentCaptor userIdCaptor = ArgumentCaptor.forClass(String.class);
+
+ // Ensure lookupUser is called twice
+ Mockito.verify(spySut, Mockito.times(2)).lookupUser(providerIdCaptor.capture(), userIdCaptor.capture());
+
+ // Assert that the first call was with expected parameters
+ assertEquals(BuiltinAuthenticationProvider.PROVIDER_ID, providerIdCaptor.getAllValues().get(0));
+ assertEquals("testUsername", userIdCaptor.getAllValues().get(0));
+ }
+
+ @Test
+ @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-bearer-auth-use-builtin-user-on-id-match")
+ void testLookupUserByOIDCBearerToken_oneProvider_validToken_userIsPresentAsBuiltin_useBuiltinUserOnIdMatchFeatureFlagEnabled() throws ParseException, IOException, AuthorizationException, OAuth2Exception {
+ // Given a single OIDC provider that returns a valid user identifier
+ setUpOIDCProviderWhichValidatesToken();
+
+ // Spy on the SUT to verify method calls
+ AuthenticationServiceBean spySut = Mockito.spy(sut);
+
+ // Setting up an authenticated user is found
+ AuthenticatedUser authenticatedUser = setupAuthenticatedUserByAuthPrvIDQueryWithResult(new AuthenticatedUser());
+
+ // When invoking lookupUserByOIDCBearerToken
+ User actualUser = spySut.lookupUserByOIDCBearerToken(TEST_BEARER_TOKEN);
+
+ // Then the actual user should match the expected authenticated user
+ assertEquals(authenticatedUser, actualUser);
+
+ // Capture calls to lookupUser
+ ArgumentCaptor providerIdCaptor = ArgumentCaptor.forClass(String.class);
+ ArgumentCaptor userIdCaptor = ArgumentCaptor.forClass(String.class);
+
+ // Ensure lookupUser is called once
+ Mockito.verify(spySut, Mockito.times(1)).lookupUser(providerIdCaptor.capture(), userIdCaptor.capture());
+
+ // Assert that lookupUser is called with expected parameters
+ assertEquals(BuiltinAuthenticationProvider.PROVIDER_ID, providerIdCaptor.getAllValues().get(0));
+ assertEquals("testUsername", userIdCaptor.getAllValues().get(0));
}
private void setupAuthenticatedUserQueryWithNoResult() {
@@ -138,6 +197,7 @@ private void setUpOIDCProviderWhichValidatesToken() throws ParseException, IOExc
Mockito.when(userRecordIdentifierStub.getUserIdInRepo()).thenReturn("testUserId");
Mockito.when(userRecordIdentifierStub.getUserRepoId()).thenReturn("testRepoId");
Mockito.when(oAuth2UserRecordStub.getUserRecordIdentifier()).thenReturn(userRecordIdentifierStub);
+ Mockito.when(oAuth2UserRecordStub.getUsername()).thenReturn("testUsername");
// Stub the OIDCAuthProvider to return OAuth2UserRecord
Mockito.when(oidcAuthProviderStub.getUserRecord(userInfoStub)).thenReturn(oAuth2UserRecordStub);
@@ -149,4 +209,23 @@ private OIDCAuthProvider stubOIDCAuthProvider(String providerID) {
Mockito.when(sut.authProvidersRegistrationService.getAuthenticationProvidersMap()).thenReturn(Map.of(providerID, oidcAuthProviderStub));
return oidcAuthProviderStub;
}
+
+ private AuthenticatedUser setupAuthenticatedUserByAuthPrvIDQueryWithResult(AuthenticatedUser authenticatedUser) {
+ return setupAuthenticatedUserByAuthPrvIDQueryWithResult(authenticatedUser, false);
+ }
+
+ private AuthenticatedUser setupAuthenticatedUserByAuthPrvIDQueryWithResult(AuthenticatedUser authenticatedUser, boolean returnNullOnFirstCall) {
+ TypedQuery queryStub = Mockito.mock(TypedQuery.class);
+ AuthenticatedUserLookup lookupStub = Mockito.mock(AuthenticatedUserLookup.class);
+ Mockito.when(lookupStub.getAuthenticatedUser()).thenReturn(authenticatedUser);
+ if (returnNullOnFirstCall) {
+ Mockito.when(queryStub.getSingleResult())
+ .thenThrow(new NoResultException())
+ .thenReturn(lookupStub);
+ } else {
+ Mockito.when(queryStub.getSingleResult()).thenReturn(lookupStub);
+ }
+ Mockito.when(sut.em.createNamedQuery("AuthenticatedUserLookup.findByAuthPrvID_PersUserId", AuthenticatedUserLookup.class)).thenReturn(queryStub);
+ return authenticatedUser;
+ }
}
diff --git a/src/test/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthenticationProviderFactoryIT.java b/src/test/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthenticationProviderFactoryIT.java
index 58b792691b9..3f2afb1927e 100644
--- a/src/test/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthenticationProviderFactoryIT.java
+++ b/src/test/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthenticationProviderFactoryIT.java
@@ -19,10 +19,7 @@
import org.htmlunit.FailingHttpStatusCodeException;
import org.htmlunit.WebClient;
import org.htmlunit.WebResponse;
-import org.htmlunit.html.HtmlForm;
-import org.htmlunit.html.HtmlInput;
-import org.htmlunit.html.HtmlPage;
-import org.htmlunit.html.HtmlSubmitInput;
+import org.htmlunit.html.*;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -69,7 +66,7 @@ class OIDCAuthenticationProviderFactoryIT {
// The realm JSON resides in conf/keycloak/test-realm.json and gets avail here using in pom.xml
@Container
- static KeycloakContainer keycloakContainer = new KeycloakContainer("quay.io/keycloak/keycloak:22.0")
+ static KeycloakContainer keycloakContainer = new KeycloakContainer("quay.io/keycloak/keycloak:26.1.4")
.withRealmImportFile("keycloak/test-realm.json")
.withAdminUsername(adminUser)
.withAdminPassword(adminPassword);
@@ -186,8 +183,7 @@ void testAuthorizationCodeFlowWithPKCE() throws Exception {
OIDCAuthProvider oidcAuthProvider = getProvider();
String authzUrl = oidcAuthProvider.buildAuthzUrl(state, callbackUrl);
- //System.out.println(authzUrl);
-
+
try (WebClient webClient = new WebClient()) {
webClient.getOptions().setCssEnabled(false);
webClient.getOptions().setJavaScriptEnabled(false);
@@ -200,12 +196,12 @@ void testAuthorizationCodeFlowWithPKCE() throws Exception {
HtmlForm form = loginPage.getForms().get(0);
HtmlInput username = form.getInputByName("username");
HtmlInput password = form.getInputByName("password");
- HtmlSubmitInput submit = form.getInputByName("login");
-
+ HtmlButton submitButton = (HtmlButton) loginPage.getElementById("kc-login");
+
username.type(realmAdminUser);
password.type(realmAdminPassword);
-
- FailingHttpStatusCodeException exception = assertThrows(FailingHttpStatusCodeException.class, submit::click);
+
+ FailingHttpStatusCodeException exception = assertThrows(FailingHttpStatusCodeException.class, submitButton::click);
assertEquals(302, exception.getStatusCode());
WebResponse response = exception.getResponse();
@@ -213,14 +209,13 @@ void testAuthorizationCodeFlowWithPKCE() throws Exception {
String callbackLocation = response.getResponseHeaderValue("Location");
assertTrue(callbackLocation.startsWith(callbackUrl));
- //System.out.println(callbackLocation);
-
+
String queryPart = callbackLocation.trim().split("\\?")[1];
Map parameters = Pattern.compile("\\s*&\\s*")
.splitAsStream(queryPart)
.map(s -> s.split("=", 2))
.collect(Collectors.toMap(a -> a[0], a -> a.length > 1 ? a[1]: ""));
- //System.out.println(map);
+
assertTrue(parameters.containsKey("code"));
assertTrue(parameters.containsKey("state"));
@@ -237,4 +232,4 @@ void testAuthorizationCodeFlowWithPKCE() throws Exception {
throw e;
}
}
-}
\ No newline at end of file
+}