Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Implemented a new feature flag ``dataverse.feature.api-bearer-auth-use-oauth-user-on-id-match``, which supports the use of the new Dataverse client in instances that have historically allowed login via GitHub, ORCID, or Google. Specifically, with this flag enabled, when an OIDC bridge is configured to allow OIDC login with validation by the bridged OAuth providers, users with existing GitHub, ORCID, or Google accounts in Dataverse can log in to those accounts, thereby maintaining access to their existing content and retaining their roles.
Comment thread
GPortas marked this conversation as resolved.
3 changes: 3 additions & 0 deletions doc/sphinx-guides/source/installation/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3729,6 +3729,9 @@ please find all known feature flags below. Any of these flags can be activated u
* - api-bearer-auth-use-shib-user-on-id-match
- Allows the use of a Shibboleth 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 Shibboleth 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 flag could result in impersonation risks if (and only if) used with a misconfigured IdP.**
- ``Off``
* - api-bearer-auth-use-oauth-user-on-id-match
- Allows the use of an OAuth user account (GitHub, Google, or ORCID) when an identity match is found during API bearer authentication. This feature enables automatic association of an incoming IdP identity with an existing OAuth 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 flag could result in impersonation risks if (and only if) used with a misconfigured IdP.**
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm confused. What is the impersonation risk? 🤔

Copy link
Copy Markdown
Contributor Author

@GPortas GPortas Aug 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the OIDC claims include an identity provider ID and a user ID that match an authenticated user in Dataverse, but the provider that issued the claims is not actually the one that originally created that user, this would represent an impersonation issue. However, as mentioned in the docs, this scenario can only occur if the IdP is misconfigured.

- ``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``
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -999,12 +999,19 @@ public AuthenticatedUser lookupUserByOIDCBearerToken(String bearerToken) throws
AuthenticatedUser authenticatedUser;
if (FeatureFlags.API_BEARER_AUTH_USE_SHIB_USER_ON_ID_MATCH.enabled() && oAuth2UserRecord.hasShibAttributes()) {
logger.log(Level.FINE, "OAuth2UserRecord has Shibboleth attributes");
String userPersistentId = ShibUtil.createUserPersistentIdentifier(oAuth2UserRecord.getShibIdp(), oAuth2UserRecord.getShibUniquePersistentIdentifier());
String userPersistentId = ShibUtil.createUserPersistentIdentifier(oAuth2UserRecord.getIdp(), oAuth2UserRecord.getShibUniquePersistentIdentifier());
authenticatedUser = lookupUser(ShibAuthenticationProvider.PROVIDER_ID, userPersistentId);
if (authenticatedUser != null) {
logger.log(Level.FINE, "Shibboleth user found for the given bearer token");
return authenticatedUser;
}
} 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;
}
} else if (FeatureFlags.API_BEARER_AUTH_USE_BUILTIN_USER_ON_ID_MATCH.enabled()) {
authenticatedUser = lookupUser(BuiltinAuthenticationProvider.PROVIDER_ID, oAuth2UserRecord.getUsername());
if (authenticatedUser != null) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package edu.harvard.iq.dataverse.authorization;

import edu.harvard.iq.dataverse.authorization.providers.oauth2.impl.GitHubOAuth2AP;

public class GitHubUserLookupParams extends OAuthUserLookupParams {

public GitHubUserLookupParams(String userId) {
super(userId);
}

@Override
public String getProviderId() {
return GitHubOAuth2AP.PROVIDER_ID;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package edu.harvard.iq.dataverse.authorization;

import edu.harvard.iq.dataverse.authorization.providers.oauth2.impl.GoogleOAuth2AP;

public class GoogleUserLookupParams extends OAuthUserLookupParams {

public GoogleUserLookupParams(String userId) {
super(userId);
}

@Override
public String getProviderId() {
return GoogleOAuth2AP.PROVIDER_ID;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package edu.harvard.iq.dataverse.authorization;

abstract class OAuthUserLookupParams {

protected String userId;

public OAuthUserLookupParams(String userId) {
this.userId = userId;
}

public String getLookupUserId() {
return userId;
}

public abstract String getProviderId();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package edu.harvard.iq.dataverse.authorization;

import edu.harvard.iq.dataverse.authorization.providers.oauth2.impl.GitHubOAuth2AP;
import edu.harvard.iq.dataverse.authorization.providers.oauth2.impl.GoogleOAuth2AP;
import edu.harvard.iq.dataverse.authorization.providers.oauth2.impl.OrcidOAuth2AP;

import java.util.Map;
import java.util.function.Function;

/**
* A factory for creating {@link OAuthUserLookupParams} instances based on an identity provider.
* This is a non-instantiable utility class.
*/
public final class OAuthUserLookupParamsFactory {

/**
* A map linking provider IDs to their corresponding user searcher constructor.
*/
private static final Map<String, Function<String, OAuthUserLookupParams>> PROVIDER_MAP = Map.of(
GoogleOAuth2AP.PROVIDER_ID, GoogleUserLookupParams::new,
GitHubOAuth2AP.PROVIDER_ID, GitHubUserLookupParams::new,
OrcidOAuth2AP.PROVIDER_ID, ORCIDUserLookupParams::new
);

private OAuthUserLookupParamsFactory() {
// Prevent instantiation of this utility class.
}

/**
* Creates an instance of an {@link OAuthUserLookupParams} based on the identity provider claim.
*
* @param idpClaim The identity provider claim value (e.g., "https://accounts.google.com").
* @param userId The user identifier from the OAuth provider.
* @return A new instance of a concrete {@link OAuthUserLookupParams}.
* @throws IllegalArgumentException if the identity provider is not supported.
*/
public static OAuthUserLookupParams getOAuthUserLookupParams(String idpClaim, String userId) {
return PROVIDER_MAP.keySet().stream()
.filter(idpClaim::contains)
.findFirst()
.map(providerId -> PROVIDER_MAP.get(providerId).apply(userId))
.orElseThrow(() -> new IllegalArgumentException("Unsupported OAuth provider: " + idpClaim));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package edu.harvard.iq.dataverse.authorization;

import edu.harvard.iq.dataverse.authorization.providers.oauth2.impl.OrcidOAuth2AP;

public class ORCIDUserLookupParams extends OAuthUserLookupParams {

private static final String ORCID_BASE_URL = "http://orcid.org/";
private static final String ORCID_BASE_URL_HTTPS = "https://orcid.org/";

public ORCIDUserLookupParams(String userId) {
super(userId);
}

@Override
public String getLookupUserId() {
return extractIdFromUrl(userId);
}

@Override
public String getProviderId() {
return OrcidOAuth2AP.PROVIDER_ID;
}

/**
* Extracts the ORCID iD from a full ORCID URL.
* <p>
* This method checks if the provided string starts with "http://orcid.org/" or "https://orcid.org/"
* and, if so, returns the trailing part of the string. If the string does not
* match the base URL, it is returned as-is, assuming it might already be the ID.
*
* @param orcidUrlOrId The full ORCID URL (e.g., "http://orcid.org/0009-0007-1267-8782")
* or an ORCID iD itself.
* @return The extracted ORCID iD (e.g., "0009-0007-1267-8782"), or the original string if it's not a URL.
* Returns null if the input is null.
*/
private static String extractIdFromUrl(String orcidUrlOrId) {
if (orcidUrlOrId == null) {
return null;
}
if (orcidUrlOrId.startsWith(ORCID_BASE_URL)) {
return orcidUrlOrId.substring(ORCID_BASE_URL.length());
}
if (orcidUrlOrId.startsWith(ORCID_BASE_URL_HTTPS)) {
return orcidUrlOrId.substring(ORCID_BASE_URL_HTTPS.length());
}
// If it's not a URL, assume it's already the ID.
return orcidUrlOrId;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@
*/
public class OAuth2UserRecord implements Serializable {

/**
* The following claim names are expected to be received when using the
* CILogon org.cilogon.userinfo scope specification. For more details, see
* https://www.cilogon.org/oidc
*/
public static final String OIDC_USER_ID_CLAIM_NAME = "oidc";
public static final String IDP_CLAIM_NAME = "idp";

private final String serviceId;

/**
Expand All @@ -30,8 +38,13 @@ public class OAuth2UserRecord implements Serializable {
* For users originally coming from a Shibboleth IdP
*/
private final String shibUniquePersistentIdentifier;
private final String shibIdp;

/**
* For brokered users coming from another OIDC provider
*/
private final String oidcUserId;

private final String idp;
private final AuthenticatedUserDisplayInfo displayInfo;
private final List<String> availableEmailAddresses;
private final OAuth2TokenData tokenData;
Expand All @@ -47,7 +60,7 @@ public OAuth2UserRecord(
AuthenticatedUserDisplayInfo displayInfo,
List<String> availableEmailAddresses
) {
this(serviceId, idInService, username, null, null, tokenData, displayInfo, availableEmailAddresses);
this(serviceId, idInService, username, null, null, null, tokenData, displayInfo, availableEmailAddresses);
}

/**
Expand All @@ -58,7 +71,8 @@ public OAuth2UserRecord(
String idInService,
String username,
String shibUniquePersistentIdentifier,
String shibIdp,
String idp,
String oidcUserId,
OAuth2TokenData tokenData,
AuthenticatedUserDisplayInfo displayInfo,
List<String> availableEmailAddresses
Expand All @@ -67,7 +81,8 @@ public OAuth2UserRecord(
this.idInService = idInService;
this.username = username;
this.shibUniquePersistentIdentifier = shibUniquePersistentIdentifier;
this.shibIdp = shibIdp;
this.idp = idp;
this.oidcUserId = oidcUserId;
this.tokenData = tokenData;
this.displayInfo = displayInfo;
this.availableEmailAddresses = availableEmailAddresses;
Expand All @@ -89,8 +104,12 @@ public String getShibUniquePersistentIdentifier() {
return shibUniquePersistentIdentifier;
}

public String getShibIdp() {
return shibIdp;
public String getIdp() {
return idp;
}

public String getOidcUserId() {
return oidcUserId;
}

public List<String> getAvailableEmailAddresses() {
Expand All @@ -110,7 +129,11 @@ public UserRecordIdentifier getUserRecordIdentifier() {
}

public boolean hasShibAttributes() {
return shibIdp != null && shibUniquePersistentIdentifier != null;
return idp != null && shibUniquePersistentIdentifier != null;
}

public boolean hasOAuthAttributes() {
return idp != null && oidcUserId != null;
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@
* @author michael
*/
public class GitHubOAuth2AP extends AbstractOAuth2AuthenticationProvider {


public static final String PROVIDER_ID = "github";

public GitHubOAuth2AP(String aClientId, String aClientSecret) {
id = "github";
id = PROVIDER_ID;
title = BundleUtil.getStringFromBundle("auth.providers.title.github");
clientId = aClientId;
clientSecret = aClientSecret;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@
* @author michael
*/
public class GoogleOAuth2AP extends AbstractOAuth2AuthenticationProvider {


public static final String PROVIDER_ID = "google";

public GoogleOAuth2AP(String aClientId, String aClientSecret) {
id = "google";
id = PROVIDER_ID;
title = BundleUtil.getStringFromBundle("auth.providers.title.google");
clientId = aClientId;
clientSecret = aClientSecret;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,12 +228,18 @@ public OAuth2UserRecord getUserRecord(String code, String state, String redirect
* @return the usable user record for processing ing {@link edu.harvard.iq.dataverse.authorization.providers.oauth2.OAuth2LoginBackingBean}
*/
public OAuth2UserRecord getUserRecord(UserInfo userInfo) {
// Extract Shibboleth attributes if present
// Extract Shibboleth persistent identifier claim if present
Object shibUniqueIdObj = userInfo.getClaim(ShibUtil.uniquePersistentIdentifier);
Object shibIdpObj = userInfo.getClaim(ShibUtil.shibIdpAttribute);

// Extract idp claim if present
Object idpObj = userInfo.getClaim(OAuth2UserRecord.IDP_CLAIM_NAME);

// Extract OIDC user id claim if present
Object oidcUserIdObj = userInfo.getClaim(OAuth2UserRecord.OIDC_USER_ID_CLAIM_NAME);

String shibUniqueId = (shibUniqueIdObj != null) ? shibUniqueIdObj.toString() : null;
String shibIdp = (shibIdpObj != null) ? shibIdpObj.toString() : null;
String idp = (idpObj != null) ? idpObj.toString() : null;
String oidcUserId = (oidcUserIdObj != null) ? oidcUserIdObj.toString() : null;

// Build display info from user attributes
AuthenticatedUserDisplayInfo displayInfo = new AuthenticatedUserDisplayInfo(
Expand All @@ -249,7 +255,8 @@ public OAuth2UserRecord getUserRecord(UserInfo userInfo) {
userInfo.getSubject().getValue(),
userInfo.getPreferredUsername(),
shibUniqueId,
shibIdp,
idp,
oidcUserId,
null,
displayInfo,
null
Expand Down
13 changes: 13 additions & 0 deletions src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,19 @@ public enum FeatureFlags {
*/
API_BEARER_AUTH_USE_SHIB_USER_ON_ID_MATCH("api-bearer-auth-use-shib-user-on-id-match"),

/**
* Allows the use of an OAuth user account (GitHub, Google, or ORCID) when an identity match is found during API bearer authentication.
* This feature enables automatic association of an incoming IdP identity with an existing OAuth user account,
* bypassing the need for additional user registration steps.
*
* <p>The value of this feature flag is only considered when the feature flag
* {@link #API_BEARER_AUTH} is enabled.</p>
*
* @apiNote Raise flag by setting "dataverse.feature.api-bearer-auth-use-oauth-user-on-id-match"
* @since Dataverse @TODO:
Comment thread
GPortas marked this conversation as resolved.
Outdated
*/
API_BEARER_AUTH_USE_OAUTH_USER_ON_ID_MATCH("api-bearer-auth-use-oauth-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,
Expand Down
Loading