Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/release-notes/10266-oidc-property-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
OIDC user properties can now be synchronized with the Dataverse user account during authentication when the dataverse.feature.oidc-user-property-sync feature flag is enabled. This includes first name, last name, email address, and email verification status (mapped from the email_verified claim).
25 changes: 16 additions & 9 deletions doc/sphinx-guides/source/installation/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,9 @@ Using X-Forwarded-Proto for Signed URLs
+++++++++++++++++++++++++++++++++++++++

If you use a proxy such as Apache or Nginx, or have a firewall such as Anubis, and they are configured to forward traffic to Dataverse over HTTP
(i.e. your proxy receives user calls over HTTPS but forwards locally to Dataverse over HTTP), signed URLs, used by external tools and
(i.e. your proxy receives user calls over HTTPS but forwards locally to Dataverse over HTTP), signed URLs, used by external tools and
upload apps (such as DVWebloader), are likely to fail unless you configure your proxy to send an X-Forwarded-Proto HTTP Header.
This allows Dataverse to recognize that the communication from the user was over HTTPS and that validation of signed URLs should assume
This allows Dataverse to recognize that the communication from the user was over HTTPS and that validation of signed URLs should assume
they started with https:// (rather than http:// as received from the proxy).

.. _PrivacyConsiderations:
Expand Down Expand Up @@ -2259,19 +2259,19 @@ These archival Bags include all of the files and metadata in a given dataset ver

The Dataverse Software offers an internal archive workflow which may be configured as a PostPublication workflow via an admin API call to manually submit previously published Datasets and prior versions to a configured archive such as Chronopolis. The workflow creates a `JSON-LD <http://www.openarchives.org/ore/0.9/jsonld>`_ serialized `OAI-ORE <https://www.openarchives.org/ore/>`_ map file, which is also available as a metadata export format in the Dataverse Software web interface.

The size of the zipped archival Bag can be limited, and files that don't fit within that limit can either be transferred separately (placed so that they are correctly positioned according to the BagIt specification when the zipped bag in unzipped in place) or just referenced for later download (using the BagIt concept of a 'holey' bag with a list of files in a ``fetch.txt`` file) can now be configured for all archivers. These settings allow for managing large datasets by excluding files over a certain size or total data size, which can be useful for archivers with size limitations or to reduce transfer times. See the :ref:`dataverse.bagit.zip.max-file-size`, :ref:`dataverse.bagit.zip.max-data-size`, and :ref:`dataverse.bagit.zip.holey` JVM options for more details.
The size of the zipped archival Bag can be limited, and files that don't fit within that limit can either be transferred separately (placed so that they are correctly positioned according to the BagIt specification when the zipped bag in unzipped in place) or just referenced for later download (using the BagIt concept of a 'holey' bag with a list of files in a ``fetch.txt`` file) can now be configured for all archivers. These settings allow for managing large datasets by excluding files over a certain size or total data size, which can be useful for archivers with size limitations or to reduce transfer times. See the :ref:`dataverse.bagit.zip.max-file-size`, :ref:`dataverse.bagit.zip.max-data-size`, and :ref:`dataverse.bagit.zip.holey` JVM options for more details.

At present, archiving classes include the DuraCloudSubmitToArchiveCommand, LocalSubmitToArchiveCommand, GoogleCloudSubmitToArchive, and S3SubmitToArchiveCommand , which all extend the AbstractSubmitToArchiveCommand and use the configurable mechanisms discussed below. (A DRSSubmitToArchiveCommand, which works with Harvard's DRS also exists and, while specific to DRS, is a useful example of how Archivers can support single-version-only semantics and support archiving only from specified collections (with collection specific parameters)).
At present, archiving classes include the DuraCloudSubmitToArchiveCommand, LocalSubmitToArchiveCommand, GoogleCloudSubmitToArchive, and S3SubmitToArchiveCommand , which all extend the AbstractSubmitToArchiveCommand and use the configurable mechanisms discussed below. (A DRSSubmitToArchiveCommand, which works with Harvard's DRS also exists and, while specific to DRS, is a useful example of how Archivers can support single-version-only semantics and support archiving only from specified collections (with collection specific parameters)).

All current options support the :ref:`Archival Status API` calls and the same status is available in the dataset page version table (for contributors/those who could view the unpublished dataset, with more detail available to superusers).

Two settings that can be used with all current Archivers are:

- \:BagGeneratorThreads - the number of threads to use when adding data files to the zipped bag. The default is 2. Values of 4 or more may increase performance on larger machines but may cause problems if file access is throttled
- \:ArchiveOnlyIfEarlierVersionsAreArchived - when true, requires dataset versions to be archived in order by confirming that all prior versions have been successfully archived before allowing a new version to be archived. Default is false
- \:ArchiveOnlyIfEarlierVersionsAreArchived - when true, requires dataset versions to be archived in order by confirming that all prior versions have been successfully archived before allowing a new version to be archived. Default is false

These must be included in the \:ArchiverSettings for the Archiver to work

Archival Bags are created per dataset version. By default, if a version is republished (via the superuser-only 'Update Current Version' publication option in the UI/API), a new archival bag is not created for the version.
If the archiver used is capable of deleting existing bags (Google, S3, and File Archivers) superusers can trigger a manual update of the archival bag, and, if the :ref:`dataverse.bagit.archive-on-version-update` flag is set to true, this will be done automatically when 'Update Current Version' is used.

Expand Down Expand Up @@ -3740,7 +3740,7 @@ i.e via the Update-Current-Version publication option. Setting the flag true onl
dataverse.files.globus-monitoring-server
++++++++++++++++++++++++++++++++++++++++

This setting is required in conjunction with the :ref:`dataverse.feature.globus-use-experimental-async-framework` feature flag. Setting it to true designates the Dataverse instance to serve as the dedicated polling server. It is needed so that the new framework can be used in a multi-node installation.
This setting is required in conjunction with the :ref:`dataverse.feature.globus-use-experimental-async-framework` feature flag. Setting it to true designates the Dataverse instance to serve as the dedicated polling server. It is needed so that the new framework can be used in a multi-node installation.

.. _dataverse.csl.common-styles:

Expand Down Expand Up @@ -3949,6 +3949,13 @@ dataverse.feature.api-bearer-auth-provide-missing-claims

Enables sending missing user claims in the request JSON provided during OIDC user registration, when these claims are not returned by the identity provider and are required for registration. 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.**

.. _dataverse.feature.oidc-user-property-sync:

dataverse.feature.oidc-user-property-sync
+++++++++++++++++++++++++++++++++++++++++

Enables synchronization of user properties from the OIDC identity provider to the Dataverse user during authentication. When enabled, first name, last name, email address, and email verification state are updated from OIDC claims on each authenticated request. Updates are only applied if values have changed. The email verification state is mapped from the optional ``email_verified`` claim to Dataverse's internal ``emailConfirmed`` timestamp.

.. _dataverse.feature.api-bearer-auth-handle-tos-acceptance-in-idp:

dataverse.feature.api-bearer-auth-handle-tos-acceptance-in-idp
Expand Down Expand Up @@ -4725,7 +4732,7 @@ In the following example, the harvester is instructed to sleep for 900 milliseco

``curl -X PUT -d "{\"harvarddv\": 0.9, \"default\": 0}" "http://localhost:8080/api/admin/settings/:HarvestingClientCallRateLimit"``

Please note that the default in the example above is there for illustrative purposes and is otherwise redundant, since no sleep interval is the default behavior anyway.
Please note that the default in the example above is there for illustrative purposes and is otherwise redundant, since no sleep interval is the default behavior anyway.

.. _:ZipUploadFilesLimit:

Expand Down Expand Up @@ -5417,7 +5424,7 @@ For examples, see the specific configuration above in :ref:`BagIt Export`.
++++++++++++++++++++++++++++++++++++++++

This setting, if true, only allows creation of an archival Bag for a dataset version if all prior versions have been successfully archived. The default is false (any version can be archived independently as long as other settings allow it)

:ArchiverSettings
+++++++++++++++++

Expand Down
1 change: 1 addition & 0 deletions docker-compose-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ services:
DATAVERSE_FEATURE_API_BEARER_AUTH: "1"
DATAVERSE_FEATURE_INDEX_HARVESTED_METADATA_SOURCE: "1"
DATAVERSE_FEATURE_API_BEARER_AUTH_PROVIDE_MISSING_CLAIMS: "1"
DATAVERSE_FEATURE_OIDC_USER_PROPERTY_SYNC: "1"
DATAVERSE_MAIL_SYSTEM_EMAIL: "dataverse@localhost"
DATAVERSE_MAIL_MTA_HOST: "smtp"
DATAVERSE_AUTH_OIDC_ENABLED: "1"
Expand Down
15 changes: 15 additions & 0 deletions src/main/java/edu/harvard/iq/dataverse/UserServiceBean.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import jakarta.ejb.TransactionAttributeType;
import jakarta.inject.Named;
import jakarta.persistence.EntityManager;
import jakarta.persistence.NoResultException;
import jakarta.persistence.PersistenceContext;
import jakarta.persistence.Query;
import org.apache.commons.lang3.StringUtils;
Expand Down Expand Up @@ -537,4 +538,18 @@ public AuthenticatedUser updateLastApiUseTime(AuthenticatedUser user) {
user.setLastApiUseTime(new Timestamp(new Date().getTime()));
return save(user);
}

public AuthenticatedUser findFresh(Long id) {
try {
return em.createQuery(
"SELECT u FROM AuthenticatedUser u " +
"LEFT JOIN FETCH u.authenticatedUserLookup " +
"WHERE u.id = :id", AuthenticatedUser.class)
.setParameter("id", id)
.setHint("jakarta.persistence.cache.retrieveMode", "BYPASS")
.getSingleResult();
} catch (NoResultException e) {
return null;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import jakarta.validation.ValidatorFactory;
import org.apache.commons.lang3.StringUtils;

/**
* AuthenticationService is for general authentication-related operations.
Expand Down Expand Up @@ -1024,24 +1025,106 @@ public AuthenticatedUser lookupUserByOIDCBearerToken(String bearerToken) throws
authenticatedUser = lookupUser(ShibAuthenticationProvider.PROVIDER_ID, userPersistentId);
if (authenticatedUser != null) {
logger.log(Level.FINE, "Shibboleth user found for the given bearer token");
return authenticatedUser;
return syncUserProperties(authenticatedUser, oAuth2UserRecord);
}
} else if (FeatureFlags.API_BEARER_AUTH_USE_OAUTH_USER_ON_ID_MATCH.enabled() && oAuth2UserRecord.hasOAuthAttributes()) {
OAuthUserLookupParams userLookupParams = OAuthUserLookupParamsFactory.getOAuthUserLookupParams(oAuth2UserRecord.getIdp(), oAuth2UserRecord.getOidcUserId());
authenticatedUser = lookupUser(userLookupParams.getProviderId(), userLookupParams.getLookupUserId());
if (authenticatedUser != null) {
logger.log(Level.FINE, "OAuth user found for the given bearer token");
return authenticatedUser;
return syncUserProperties(authenticatedUser, oAuth2UserRecord);
}
} else if (FeatureFlags.API_BEARER_AUTH_USE_BUILTIN_USER_ON_ID_MATCH.enabled() && oAuth2UserRecord.hasBuiltinAttributes()) {
authenticatedUser = lookupUser(BuiltinAuthenticationProvider.PROVIDER_ID, oAuth2UserRecord.getUsername());
if (authenticatedUser != null) {
logger.log(Level.FINE, "Builtin user found for the given bearer token");
return authenticatedUser;
return syncUserProperties(authenticatedUser, oAuth2UserRecord);
}
}

return lookupUser(oAuth2UserRecord.getUserRecordIdentifier());
authenticatedUser = lookupUser(oAuth2UserRecord.getUserRecordIdentifier());
// fallback
if (authenticatedUser != null) {
authenticatedUser = syncUserProperties(authenticatedUser, oAuth2UserRecord);
}
return authenticatedUser;
}

private AuthenticatedUser syncUserProperties(AuthenticatedUser user, OAuth2UserRecord record) {
if (!FeatureFlags.OIDC_USER_PROPERTY_SYNC.enabled()) {
logger.fine("OIDC user property sync is disabled (feature flag not raised)");
return user;
}

boolean changed = false;

if (record.getDisplayInfo() != null) {
String newFirstName = StringUtils.trimToNull(record.getDisplayInfo().getFirstName());
String newLastName = StringUtils.trimToNull(record.getDisplayInfo().getLastName());

if (newFirstName != null && !newFirstName.equals(user.getFirstName())) {
user.setFirstName(newFirstName);
changed = true;
}

if (newLastName != null && !newLastName.equals(user.getLastName())) {
user.setLastName(newLastName);
changed = true;
}
}

String newEmail = StringUtils.trimToNull(resolveEmail(record));
if (newEmail != null && !equalsIgnoreCaseSafe(newEmail, user.getEmail())) {
user.setEmail(newEmail);
changed = true;
}

Boolean emailVerified = record.getEmailVerified();
if (emailVerified != null) {
if (emailVerified && user.getEmailConfirmed() == null) {
user.setEmailConfirmed(new Timestamp(System.currentTimeMillis()));
changed = true;
} else if (!emailVerified && user.getEmailConfirmed() != null) {
user.setEmailConfirmed(null);
changed = true;
}
}

if (changed) {
return userService.save(user);
}

return user;
}

private String resolveEmail(OAuth2UserRecord record) {
if (record.getDisplayInfo() != null) {
String displayEmail = StringUtils.trimToNull(record.getDisplayInfo().getEmailAddress());
if (displayEmail != null) {
return displayEmail;
}
}
// fallback
return StringUtils.trimToNull(getFirstEmail(record));
}


private String getFirstEmail(OAuth2UserRecord record) {
List<String> emails = record.getAvailableEmailAddresses();
if (emails == null || emails.isEmpty()) {
return null;
}
return emails.get(0);
}

/**
* Null-safe, case-insensitive comparison for email addresses.
*/
private boolean equalsIgnoreCaseSafe(String a, String b) {
if (a == null || b == null) {
return a == b;
}
return a.equalsIgnoreCase(b);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,17 @@ public String init() {
}

if (session.getUser(true).isAuthenticated()) {
setCurrentUser((AuthenticatedUser) session.getUser());
userAuthProvider = authenticationService.lookupProvider(currentUser);
AuthenticatedUser sessionUser = (AuthenticatedUser) session.getUser();
AuthenticatedUser freshUser = userService.findFresh(sessionUser.getId());
if (freshUser != null) {
// Update properties on the session instance directly instead of replacing it
// (via using Session.setUser()) to avoid session ID change which would invalidate the ViewScoped bean.
sessionUser.setFirstName(freshUser.getFirstName());
sessionUser.setLastName(freshUser.getLastName());
sessionUser.setEmail(freshUser.getEmail());
}
setCurrentUser(freshUser != null ? freshUser : sessionUser);
userAuthProvider = authenticationService.lookupProvider(sessionUser);
notificationsList = userNotificationService.findByUser(currentUser.getId());
notificationTypeList = Arrays.asList(Type.values()).stream()
.filter(x -> !Type.CONFIRMEMAIL.equals(x) && x.hasDescription() && !settingsWrapper.isAlwaysMuted(x))
Expand Down Expand Up @@ -609,7 +618,7 @@ public boolean isEmailGrandfathered() {
}

public AuthenticationProvider getUserAuthProvider() {
if ( userAuthProvider == null ) {
if (userAuthProvider == null && currentUser != null) {
userAuthProvider = authenticationService.lookupProvider(currentUser);
}
return userAuthProvider;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,9 @@ protected OAuth2UserRecord getUserRecord(@NotNull String responseBody, @NotNull
parsed.username,
OAuth2TokenData.from(accessToken),
parsed.displayInfo,
parsed.emails);
parsed.emails,
null
);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ public void init() throws IOException {
accessToken.setAccessToken("qwe-addssd-iiiiie");
setNewUser(new OAuth2UserRecord(authProviderId, eppn, randomUsername, accessToken,
new AuthenticatedUserDisplayInfo(firstName, lastName, email, "myAffiliation", "myPosition"),
extraEmails));
extraEmails, null));
}
}

Expand Down
Loading