From 4697002b1e9d7d9185daf8cc2f7efbba026afe5b Mon Sep 17 00:00:00 2001 From: GPortas Date: Wed, 23 Jul 2025 17:18:57 +0100 Subject: [PATCH 01/13] Stash: new feature flag that enables API OIDC authentication of existing OAuth users WIP --- .../AuthenticationServiceBean.java | 7 +++ .../authorization/GitHubUserSearcher.java | 15 +++++++ .../authorization/GoogleUserSearcher.java | 15 +++++++ .../authorization/OAuthUserSearcher.java | 14 ++++++ .../OAuthUserSearcherFactory.java | 44 +++++++++++++++++++ .../authorization/ORCIDUserSearcher.java | 15 +++++++ .../providers/oauth2/OAuth2UserRecord.java | 14 +++++- .../providers/oauth2/impl/GitHubOAuth2AP.java | 6 ++- .../providers/oauth2/impl/GoogleOAuth2AP.java | 6 ++- .../oauth2/oidc/OIDCAuthProvider.java | 5 +++ .../iq/dataverse/settings/FeatureFlags.java | 2 + 11 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 src/main/java/edu/harvard/iq/dataverse/authorization/GitHubUserSearcher.java create mode 100644 src/main/java/edu/harvard/iq/dataverse/authorization/GoogleUserSearcher.java create mode 100644 src/main/java/edu/harvard/iq/dataverse/authorization/OAuthUserSearcher.java create mode 100644 src/main/java/edu/harvard/iq/dataverse/authorization/OAuthUserSearcherFactory.java create mode 100644 src/main/java/edu/harvard/iq/dataverse/authorization/ORCIDUserSearcher.java 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 b43bc0e0bb0..c3bc6007a17 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java @@ -1005,6 +1005,13 @@ public AuthenticatedUser lookupUserByOIDCBearerToken(String bearerToken) throws 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.getShibIdp() != null) { + OAuthUserSearcher searcher = OAuthUserSearcherFactory.getSearcher(oAuth2UserRecord.getShibIdp(), oAuth2UserRecord.getOidcUserId()); + authenticatedUser = searcher.searchAuthenticatedUser(); + if (authenticatedUser != null) { + logger.log(Level.FINE, "Builtin 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) { diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/GitHubUserSearcher.java b/src/main/java/edu/harvard/iq/dataverse/authorization/GitHubUserSearcher.java new file mode 100644 index 00000000000..ebc01ab63ed --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/GitHubUserSearcher.java @@ -0,0 +1,15 @@ +package edu.harvard.iq.dataverse.authorization; + +import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; + +public class GitHubUserSearcher extends OAuthUserSearcher { + + public GitHubUserSearcher(String userId) { + super(userId); + } + + @Override + public AuthenticatedUser searchAuthenticatedUser() { + return null; + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/GoogleUserSearcher.java b/src/main/java/edu/harvard/iq/dataverse/authorization/GoogleUserSearcher.java new file mode 100644 index 00000000000..b11f03e0083 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/GoogleUserSearcher.java @@ -0,0 +1,15 @@ +package edu.harvard.iq.dataverse.authorization; + +import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; + +public class GoogleUserSearcher extends OAuthUserSearcher { + + public GoogleUserSearcher(String userId) { + super(userId); + } + + @Override + public AuthenticatedUser searchAuthenticatedUser() { + return null; + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/OAuthUserSearcher.java b/src/main/java/edu/harvard/iq/dataverse/authorization/OAuthUserSearcher.java new file mode 100644 index 00000000000..2f0fdb53a4d --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/OAuthUserSearcher.java @@ -0,0 +1,14 @@ +package edu.harvard.iq.dataverse.authorization; + +import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; + +abstract class OAuthUserSearcher { + + protected String userId; + + public OAuthUserSearcher(String userId) { + this.userId = userId; + } + + abstract public AuthenticatedUser searchAuthenticatedUser(); +} diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/OAuthUserSearcherFactory.java b/src/main/java/edu/harvard/iq/dataverse/authorization/OAuthUserSearcherFactory.java new file mode 100644 index 00000000000..8c1e1cd1070 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/OAuthUserSearcherFactory.java @@ -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 OAuthUserSearcher} instances based on an identity provider. + * This is a non-instantiable utility class. + */ +public final class OAuthUserSearcherFactory { + + /** + * A map linking provider IDs to their corresponding user searcher constructor. + */ + private static final Map> PROVIDER_MAP = Map.of( + GoogleOAuth2AP.PROVIDER_ID, GoogleUserSearcher::new, + GitHubOAuth2AP.PROVIDER_ID, GitHubUserSearcher::new, + OrcidOAuth2AP.PROVIDER_ID, ORCIDUserSearcher::new + ); + + private OAuthUserSearcherFactory() { + // Prevent instantiation of this utility class. + } + + /** + * Creates an instance of an {@link OAuthUserSearcher} 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 OAuthUserSearcher}. + * @throws IllegalArgumentException if the identity provider is not supported. + */ + public static OAuthUserSearcher getSearcher(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)); + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/ORCIDUserSearcher.java b/src/main/java/edu/harvard/iq/dataverse/authorization/ORCIDUserSearcher.java new file mode 100644 index 00000000000..4039c32c61c --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/ORCIDUserSearcher.java @@ -0,0 +1,15 @@ +package edu.harvard.iq.dataverse.authorization; + +import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; + +public class ORCIDUserSearcher extends OAuthUserSearcher { + + public ORCIDUserSearcher(String userId) { + super(userId); + } + + @Override + public AuthenticatedUser searchAuthenticatedUser() { + return null; + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/OAuth2UserRecord.java b/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/OAuth2UserRecord.java index 3f75e882d82..d50d7da8859 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/OAuth2UserRecord.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/OAuth2UserRecord.java @@ -32,6 +32,12 @@ public class OAuth2UserRecord implements Serializable { private final String shibUniquePersistentIdentifier; private final String shibIdp; + /** + * For brokered users coming from another OIDC provider + */ + private final String oidcUserId; + public static final String OIDC_USER_ID_CLAIM = "oidc"; + private final AuthenticatedUserDisplayInfo displayInfo; private final List availableEmailAddresses; private final OAuth2TokenData tokenData; @@ -47,7 +53,7 @@ public OAuth2UserRecord( AuthenticatedUserDisplayInfo displayInfo, List availableEmailAddresses ) { - this(serviceId, idInService, username, null, null, tokenData, displayInfo, availableEmailAddresses); + this(serviceId, idInService, username, null, null, null, tokenData, displayInfo, availableEmailAddresses); } /** @@ -59,6 +65,7 @@ public OAuth2UserRecord( String username, String shibUniquePersistentIdentifier, String shibIdp, + String oidcUserId, OAuth2TokenData tokenData, AuthenticatedUserDisplayInfo displayInfo, List availableEmailAddresses @@ -68,6 +75,7 @@ public OAuth2UserRecord( this.username = username; this.shibUniquePersistentIdentifier = shibUniquePersistentIdentifier; this.shibIdp = shibIdp; + this.oidcUserId = oidcUserId; this.tokenData = tokenData; this.displayInfo = displayInfo; this.availableEmailAddresses = availableEmailAddresses; @@ -93,6 +101,10 @@ public String getShibIdp() { return shibIdp; } + public String getOidcUserId() { + return oidcUserId; + } + public List getAvailableEmailAddresses() { return availableEmailAddresses; } diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/impl/GitHubOAuth2AP.java b/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/impl/GitHubOAuth2AP.java index 5b201ce77db..457a91bc932 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/impl/GitHubOAuth2AP.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/impl/GitHubOAuth2AP.java @@ -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; diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/impl/GoogleOAuth2AP.java b/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/impl/GoogleOAuth2AP.java index 913eb038d8e..a0889a5bfd3 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/impl/GoogleOAuth2AP.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/impl/GoogleOAuth2AP.java @@ -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; diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthProvider.java b/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthProvider.java index 4085777f180..27b5baeb2f4 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthProvider.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthProvider.java @@ -232,8 +232,12 @@ public OAuth2UserRecord getUserRecord(UserInfo userInfo) { Object shibUniqueIdObj = userInfo.getClaim(ShibUtil.uniquePersistentIdentifier); Object shibIdpObj = userInfo.getClaim(ShibUtil.shibIdpAttribute); + // Extract OIDC user id claim if present + Object oidcUserIdObj = userInfo.getClaim(OAuth2UserRecord.OIDC_USER_ID_CLAIM); + String shibUniqueId = (shibUniqueIdObj != null) ? shibUniqueIdObj.toString() : null; String shibIdp = (shibIdpObj != null) ? shibIdpObj.toString() : null; + String oidcUserId = (oidcUserIdObj != null) ? oidcUserIdObj.toString() : null; // Build display info from user attributes AuthenticatedUserDisplayInfo displayInfo = new AuthenticatedUserDisplayInfo( @@ -250,6 +254,7 @@ public OAuth2UserRecord getUserRecord(UserInfo userInfo) { userInfo.getPreferredUsername(), shibUniqueId, shibIdp, + oidcUserId, null, displayInfo, null 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 b0aaae7b0d4..68cb1125d4d 100644 --- a/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java +++ b/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java @@ -85,6 +85,8 @@ public enum FeatureFlags { */ API_BEARER_AUTH_USE_SHIB_USER_ON_ID_MATCH("api-bearer-auth-use-shib-user-on-id-match"), + 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, From 12a079d0ec4069c51d4b7396a585a7fc17fc82c8 Mon Sep 17 00:00:00 2001 From: GPortas Date: Wed, 23 Jul 2025 18:06:33 +0100 Subject: [PATCH 02/13] Added: ORCIDUserLookupParams logic and new naming --- .../AuthenticationServiceBean.java | 4 +- .../authorization/GitHubUserLookupParams.java | 15 ++++++ .../authorization/GitHubUserSearcher.java | 15 ------ .../authorization/GoogleUserLookupParams.java | 15 ++++++ .../authorization/GoogleUserSearcher.java | 15 ------ .../authorization/OAuthUserLookupParams.java | 16 ++++++ ...java => OAuthUserLookupParamsFactory.java} | 20 ++++---- .../authorization/OAuthUserSearcher.java | 14 ------ .../authorization/ORCIDUserLookupParams.java | 49 +++++++++++++++++++ .../authorization/ORCIDUserSearcher.java | 15 ------ 10 files changed, 107 insertions(+), 71 deletions(-) create mode 100644 src/main/java/edu/harvard/iq/dataverse/authorization/GitHubUserLookupParams.java delete mode 100644 src/main/java/edu/harvard/iq/dataverse/authorization/GitHubUserSearcher.java create mode 100644 src/main/java/edu/harvard/iq/dataverse/authorization/GoogleUserLookupParams.java delete mode 100644 src/main/java/edu/harvard/iq/dataverse/authorization/GoogleUserSearcher.java create mode 100644 src/main/java/edu/harvard/iq/dataverse/authorization/OAuthUserLookupParams.java rename src/main/java/edu/harvard/iq/dataverse/authorization/{OAuthUserSearcherFactory.java => OAuthUserLookupParamsFactory.java} (60%) delete mode 100644 src/main/java/edu/harvard/iq/dataverse/authorization/OAuthUserSearcher.java create mode 100644 src/main/java/edu/harvard/iq/dataverse/authorization/ORCIDUserLookupParams.java delete mode 100644 src/main/java/edu/harvard/iq/dataverse/authorization/ORCIDUserSearcher.java 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 c3bc6007a17..fb42cf95972 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java @@ -1006,8 +1006,8 @@ public AuthenticatedUser lookupUserByOIDCBearerToken(String bearerToken) throws return authenticatedUser; } } else if (FeatureFlags.API_BEARER_AUTH_USE_OAUTH_USER_ON_ID_MATCH.enabled() && oAuth2UserRecord.getShibIdp() != null) { - OAuthUserSearcher searcher = OAuthUserSearcherFactory.getSearcher(oAuth2UserRecord.getShibIdp(), oAuth2UserRecord.getOidcUserId()); - authenticatedUser = searcher.searchAuthenticatedUser(); + OAuthUserLookupParams searcher = OAuthUserLookupParamsFactory.getSearcher(oAuth2UserRecord.getShibIdp(), oAuth2UserRecord.getOidcUserId()); + authenticatedUser = lookupUser(searcher.getProviderId(), searcher.userId); if (authenticatedUser != null) { logger.log(Level.FINE, "Builtin user found for the given bearer token"); return authenticatedUser; diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/GitHubUserLookupParams.java b/src/main/java/edu/harvard/iq/dataverse/authorization/GitHubUserLookupParams.java new file mode 100644 index 00000000000..0296a1fa730 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/GitHubUserLookupParams.java @@ -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; + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/GitHubUserSearcher.java b/src/main/java/edu/harvard/iq/dataverse/authorization/GitHubUserSearcher.java deleted file mode 100644 index ebc01ab63ed..00000000000 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/GitHubUserSearcher.java +++ /dev/null @@ -1,15 +0,0 @@ -package edu.harvard.iq.dataverse.authorization; - -import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; - -public class GitHubUserSearcher extends OAuthUserSearcher { - - public GitHubUserSearcher(String userId) { - super(userId); - } - - @Override - public AuthenticatedUser searchAuthenticatedUser() { - return null; - } -} diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/GoogleUserLookupParams.java b/src/main/java/edu/harvard/iq/dataverse/authorization/GoogleUserLookupParams.java new file mode 100644 index 00000000000..be3762a3e44 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/GoogleUserLookupParams.java @@ -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; + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/GoogleUserSearcher.java b/src/main/java/edu/harvard/iq/dataverse/authorization/GoogleUserSearcher.java deleted file mode 100644 index b11f03e0083..00000000000 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/GoogleUserSearcher.java +++ /dev/null @@ -1,15 +0,0 @@ -package edu.harvard.iq.dataverse.authorization; - -import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; - -public class GoogleUserSearcher extends OAuthUserSearcher { - - public GoogleUserSearcher(String userId) { - super(userId); - } - - @Override - public AuthenticatedUser searchAuthenticatedUser() { - return null; - } -} diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/OAuthUserLookupParams.java b/src/main/java/edu/harvard/iq/dataverse/authorization/OAuthUserLookupParams.java new file mode 100644 index 00000000000..802b40ce755 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/OAuthUserLookupParams.java @@ -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 getAuthenticatedUserId() { + return userId; + } + + public abstract String getProviderId(); +} diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/OAuthUserSearcherFactory.java b/src/main/java/edu/harvard/iq/dataverse/authorization/OAuthUserLookupParamsFactory.java similarity index 60% rename from src/main/java/edu/harvard/iq/dataverse/authorization/OAuthUserSearcherFactory.java rename to src/main/java/edu/harvard/iq/dataverse/authorization/OAuthUserLookupParamsFactory.java index 8c1e1cd1070..2ccf966a4e0 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/OAuthUserSearcherFactory.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/OAuthUserLookupParamsFactory.java @@ -8,33 +8,33 @@ import java.util.function.Function; /** - * A factory for creating {@link OAuthUserSearcher} instances based on an identity provider. + * A factory for creating {@link OAuthUserLookupParamsFactory} instances based on an identity provider. * This is a non-instantiable utility class. */ -public final class OAuthUserSearcherFactory { +public final class OAuthUserLookupParamsFactory { /** * A map linking provider IDs to their corresponding user searcher constructor. */ - private static final Map> PROVIDER_MAP = Map.of( - GoogleOAuth2AP.PROVIDER_ID, GoogleUserSearcher::new, - GitHubOAuth2AP.PROVIDER_ID, GitHubUserSearcher::new, - OrcidOAuth2AP.PROVIDER_ID, ORCIDUserSearcher::new + private static final Map> PROVIDER_MAP = Map.of( + GoogleOAuth2AP.PROVIDER_ID, GoogleUserLookupParams::new, + GitHubOAuth2AP.PROVIDER_ID, GitHubUserLookupParams::new, + OrcidOAuth2AP.PROVIDER_ID, ORCIDUserLookupParams::new ); - private OAuthUserSearcherFactory() { + private OAuthUserLookupParamsFactory() { // Prevent instantiation of this utility class. } /** - * Creates an instance of an {@link OAuthUserSearcher} based on the identity provider claim. + * 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 OAuthUserSearcher}. + * @return A new instance of a concrete {@link OAuthUserLookupParams}. * @throws IllegalArgumentException if the identity provider is not supported. */ - public static OAuthUserSearcher getSearcher(String idpClaim, String userId) { + public static OAuthUserLookupParams getSearcher(String idpClaim, String userId) { return PROVIDER_MAP.keySet().stream() .filter(idpClaim::contains) .findFirst() diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/OAuthUserSearcher.java b/src/main/java/edu/harvard/iq/dataverse/authorization/OAuthUserSearcher.java deleted file mode 100644 index 2f0fdb53a4d..00000000000 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/OAuthUserSearcher.java +++ /dev/null @@ -1,14 +0,0 @@ -package edu.harvard.iq.dataverse.authorization; - -import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; - -abstract class OAuthUserSearcher { - - protected String userId; - - public OAuthUserSearcher(String userId) { - this.userId = userId; - } - - abstract public AuthenticatedUser searchAuthenticatedUser(); -} diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/ORCIDUserLookupParams.java b/src/main/java/edu/harvard/iq/dataverse/authorization/ORCIDUserLookupParams.java new file mode 100644 index 00000000000..3a8fad5b93e --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/ORCIDUserLookupParams.java @@ -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 getAuthenticatedUserId() { + return extractIdFromUrl(userId); + } + + @Override + public String getProviderId() { + return OrcidOAuth2AP.PROVIDER_ID; + } + + /** + * Extracts the ORCID iD from a full ORCID URL. + *

+ * 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; + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/ORCIDUserSearcher.java b/src/main/java/edu/harvard/iq/dataverse/authorization/ORCIDUserSearcher.java deleted file mode 100644 index 4039c32c61c..00000000000 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/ORCIDUserSearcher.java +++ /dev/null @@ -1,15 +0,0 @@ -package edu.harvard.iq.dataverse.authorization; - -import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; - -public class ORCIDUserSearcher extends OAuthUserSearcher { - - public ORCIDUserSearcher(String userId) { - super(userId); - } - - @Override - public AuthenticatedUser searchAuthenticatedUser() { - return null; - } -} From 182fef363960d9c69de37f96c0b5f89b29da373f Mon Sep 17 00:00:00 2001 From: GPortas Date: Wed, 23 Jul 2025 18:08:22 +0100 Subject: [PATCH 03/13] Changed: vars naming in lookupUserByOIDCBearerToken --- .../iq/dataverse/authorization/AuthenticationServiceBean.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 fb42cf95972..fceadaf6a4f 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java @@ -1006,8 +1006,8 @@ public AuthenticatedUser lookupUserByOIDCBearerToken(String bearerToken) throws return authenticatedUser; } } else if (FeatureFlags.API_BEARER_AUTH_USE_OAUTH_USER_ON_ID_MATCH.enabled() && oAuth2UserRecord.getShibIdp() != null) { - OAuthUserLookupParams searcher = OAuthUserLookupParamsFactory.getSearcher(oAuth2UserRecord.getShibIdp(), oAuth2UserRecord.getOidcUserId()); - authenticatedUser = lookupUser(searcher.getProviderId(), searcher.userId); + OAuthUserLookupParams userLookupParams = OAuthUserLookupParamsFactory.getSearcher(oAuth2UserRecord.getShibIdp(), oAuth2UserRecord.getOidcUserId()); + authenticatedUser = lookupUser(userLookupParams.getProviderId(), userLookupParams.userId); if (authenticatedUser != null) { logger.log(Level.FINE, "Builtin user found for the given bearer token"); return authenticatedUser; From b88fcc4b994c3abc6c723ddb4f3ffbac9aa1e72f Mon Sep 17 00:00:00 2001 From: GPortas Date: Thu, 24 Jul 2025 13:34:31 +0100 Subject: [PATCH 04/13] Fixed: log message typo --- .../iq/dataverse/authorization/AuthenticationServiceBean.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 fceadaf6a4f..8f2a60b6327 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java @@ -1009,7 +1009,7 @@ public AuthenticatedUser lookupUserByOIDCBearerToken(String bearerToken) throws OAuthUserLookupParams userLookupParams = OAuthUserLookupParamsFactory.getSearcher(oAuth2UserRecord.getShibIdp(), oAuth2UserRecord.getOidcUserId()); authenticatedUser = lookupUser(userLookupParams.getProviderId(), userLookupParams.userId); if (authenticatedUser != null) { - logger.log(Level.FINE, "Builtin user found for the given bearer token"); + 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()) { From 9f5c713050d3ce0fe8e9a6a06364b91136900fb8 Mon Sep 17 00:00:00 2001 From: GPortas Date: Thu, 24 Jul 2025 13:45:00 +0100 Subject: [PATCH 05/13] Refactor: naming tweaks --- .../iq/dataverse/authorization/AuthenticationServiceBean.java | 2 +- .../iq/dataverse/authorization/OAuthUserLookupParams.java | 2 +- .../dataverse/authorization/OAuthUserLookupParamsFactory.java | 4 ++-- .../iq/dataverse/authorization/ORCIDUserLookupParams.java | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) 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 8f2a60b6327..2768be65a33 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java @@ -1006,7 +1006,7 @@ public AuthenticatedUser lookupUserByOIDCBearerToken(String bearerToken) throws return authenticatedUser; } } else if (FeatureFlags.API_BEARER_AUTH_USE_OAUTH_USER_ON_ID_MATCH.enabled() && oAuth2UserRecord.getShibIdp() != null) { - OAuthUserLookupParams userLookupParams = OAuthUserLookupParamsFactory.getSearcher(oAuth2UserRecord.getShibIdp(), oAuth2UserRecord.getOidcUserId()); + OAuthUserLookupParams userLookupParams = OAuthUserLookupParamsFactory.getOAuthUserLookupParams(oAuth2UserRecord.getShibIdp(), oAuth2UserRecord.getOidcUserId()); authenticatedUser = lookupUser(userLookupParams.getProviderId(), userLookupParams.userId); if (authenticatedUser != null) { logger.log(Level.FINE, "OAuth user found for the given bearer token"); diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/OAuthUserLookupParams.java b/src/main/java/edu/harvard/iq/dataverse/authorization/OAuthUserLookupParams.java index 802b40ce755..60a8c7f5377 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/OAuthUserLookupParams.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/OAuthUserLookupParams.java @@ -8,7 +8,7 @@ public OAuthUserLookupParams(String userId) { this.userId = userId; } - public String getAuthenticatedUserId() { + public String getLookupUserId() { return userId; } diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/OAuthUserLookupParamsFactory.java b/src/main/java/edu/harvard/iq/dataverse/authorization/OAuthUserLookupParamsFactory.java index 2ccf966a4e0..e9a5aff6d48 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/OAuthUserLookupParamsFactory.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/OAuthUserLookupParamsFactory.java @@ -8,7 +8,7 @@ import java.util.function.Function; /** - * A factory for creating {@link OAuthUserLookupParamsFactory} instances based on an identity provider. + * A factory for creating {@link OAuthUserLookupParams} instances based on an identity provider. * This is a non-instantiable utility class. */ public final class OAuthUserLookupParamsFactory { @@ -34,7 +34,7 @@ private OAuthUserLookupParamsFactory() { * @return A new instance of a concrete {@link OAuthUserLookupParams}. * @throws IllegalArgumentException if the identity provider is not supported. */ - public static OAuthUserLookupParams getSearcher(String idpClaim, String userId) { + public static OAuthUserLookupParams getOAuthUserLookupParams(String idpClaim, String userId) { return PROVIDER_MAP.keySet().stream() .filter(idpClaim::contains) .findFirst() diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/ORCIDUserLookupParams.java b/src/main/java/edu/harvard/iq/dataverse/authorization/ORCIDUserLookupParams.java index 3a8fad5b93e..34517bb080a 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/ORCIDUserLookupParams.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/ORCIDUserLookupParams.java @@ -12,7 +12,7 @@ public ORCIDUserLookupParams(String userId) { } @Override - public String getAuthenticatedUserId() { + public String getLookupUserId() { return extractIdFromUrl(userId); } From 1168c01435cb42d2ecdb70ae096c0230baade47e Mon Sep 17 00:00:00 2001 From: GPortas Date: Thu, 24 Jul 2025 13:51:01 +0100 Subject: [PATCH 06/13] Fixed: API_BEARER_AUTH_USE_OAUTH_USER_ON_ID_MATCH condition in lookupUserByOIDCBearerToken --- .../iq/dataverse/authorization/AuthenticationServiceBean.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 2768be65a33..a0795563524 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java @@ -1005,9 +1005,9 @@ public AuthenticatedUser lookupUserByOIDCBearerToken(String bearerToken) throws 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.getShibIdp() != null) { + } else if (FeatureFlags.API_BEARER_AUTH_USE_OAUTH_USER_ON_ID_MATCH.enabled() && oAuth2UserRecord.getOidcUserId() != null) { OAuthUserLookupParams userLookupParams = OAuthUserLookupParamsFactory.getOAuthUserLookupParams(oAuth2UserRecord.getShibIdp(), oAuth2UserRecord.getOidcUserId()); - authenticatedUser = lookupUser(userLookupParams.getProviderId(), userLookupParams.userId); + authenticatedUser = lookupUser(userLookupParams.getProviderId(), userLookupParams.getLookupUserId()); if (authenticatedUser != null) { logger.log(Level.FINE, "OAuth user found for the given bearer token"); return authenticatedUser; From bce5d50d187e72863476dde14c4c532f232bd458 Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 25 Jul 2025 16:00:11 +0100 Subject: [PATCH 07/13] Added: tweaks for API_BEARER_AUTH_USE_OAUTH_USER_ON_ID_MATCH, and changed the expected received idp claim name for both Shib and OAuth users --- .../AuthenticationServiceBean.java | 6 ++--- .../providers/oauth2/OAuth2UserRecord.java | 25 +++++++++++++------ .../oauth2/oidc/OIDCAuthProvider.java | 12 +++++---- .../iq/dataverse/settings/FeatureFlags.java | 11 ++++++++ .../AuthenticationServiceBeanTest.java | 2 +- 5 files changed, 40 insertions(+), 16 deletions(-) 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 a0795563524..891eb62fd97 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java @@ -999,14 +999,14 @@ 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.getOidcUserId() != null) { - OAuthUserLookupParams userLookupParams = OAuthUserLookupParamsFactory.getOAuthUserLookupParams(oAuth2UserRecord.getShibIdp(), oAuth2UserRecord.getOidcUserId()); + } 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"); diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/OAuth2UserRecord.java b/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/OAuth2UserRecord.java index d50d7da8859..8b9857af825 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/OAuth2UserRecord.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/OAuth2UserRecord.java @@ -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; /** @@ -30,14 +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; - public static final String OIDC_USER_ID_CLAIM = "oidc"; + private final String idp; private final AuthenticatedUserDisplayInfo displayInfo; private final List availableEmailAddresses; private final OAuth2TokenData tokenData; @@ -64,7 +71,7 @@ public OAuth2UserRecord( String idInService, String username, String shibUniquePersistentIdentifier, - String shibIdp, + String idp, String oidcUserId, OAuth2TokenData tokenData, AuthenticatedUserDisplayInfo displayInfo, @@ -74,7 +81,7 @@ 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; @@ -97,8 +104,8 @@ public String getShibUniquePersistentIdentifier() { return shibUniquePersistentIdentifier; } - public String getShibIdp() { - return shibIdp; + public String getIdp() { + return idp; } public String getOidcUserId() { @@ -122,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 diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthProvider.java b/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthProvider.java index 27b5baeb2f4..3e62598ee79 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthProvider.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthProvider.java @@ -228,15 +228,17 @@ 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); + 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 @@ -253,7 +255,7 @@ public OAuth2UserRecord getUserRecord(UserInfo userInfo) { userInfo.getSubject().getValue(), userInfo.getPreferredUsername(), shibUniqueId, - shibIdp, + idp, oidcUserId, null, displayInfo, 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 68cb1125d4d..62f9fed5393 100644 --- a/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java +++ b/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java @@ -85,6 +85,17 @@ 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. + * + *

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-oauth-user-on-id-match" + * @since Dataverse @TODO: + */ API_BEARER_AUTH_USE_OAUTH_USER_ON_ID_MATCH("api-bearer-auth-use-oauth-user-on-id-match"), /** 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 853cfece280..361172773fd 100644 --- a/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java @@ -231,7 +231,7 @@ private void setUpOIDCProviderWhichValidatesToken(boolean includeShibAttributes) if (includeShibAttributes) { Mockito.when(oAuth2UserRecordStub.hasShibAttributes()).thenReturn(true); - Mockito.when(oAuth2UserRecordStub.getShibIdp()).thenReturn("testIdp"); + Mockito.when(oAuth2UserRecordStub.getIdp()).thenReturn("testIdp"); Mockito.when(oAuth2UserRecordStub.getShibUniquePersistentIdentifier()).thenReturn("testPersistentId"); } From fb0e559128d36ca46f119c5589a73943262e05bc Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 25 Jul 2025 16:31:24 +0100 Subject: [PATCH 08/13] Added: unit test for lookupUserByOIDCBearerToken with api-bearer-auth-use-oauth-user-on-id-match turned on --- .../AuthenticationServiceBeanTest.java | 42 +++++++++++++++++-- 1 file changed, 39 insertions(+), 3 deletions(-) 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 361172773fd..f0158213290 100644 --- a/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java @@ -7,6 +7,7 @@ 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.impl.OrcidOAuth2AP; import edu.harvard.iq.dataverse.authorization.providers.oauth2.oidc.OIDCAuthProvider; import edu.harvard.iq.dataverse.authorization.providers.shib.ShibAuthenticationProvider; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; @@ -34,6 +35,7 @@ public class AuthenticationServiceBeanTest { private AuthenticationServiceBean sut; private static final String TEST_BEARER_TOKEN = "Bearer test"; + private static final String TEST_ORCID_USER_ID = "0000-0000-0000-0000"; @BeforeEach public void setUp() { @@ -181,7 +183,7 @@ void testLookupUserByOIDCBearerToken_oneProvider_validToken_userIsPresentAsBuilt @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-bearer-auth-use-shib-user-on-id-match") void testLookupUserByOIDCBearerToken_oneProvider_validToken_userIsPresentAsShibboleth_useShibUserOnIdMatchFeatureFlagEnabled() throws ParseException, IOException, AuthorizationException, OAuth2Exception { // Given a single OIDC provider that returns a valid user identifier - setUpOIDCProviderWhichValidatesToken(true); + setUpOIDCProviderWhichValidatesToken(true, false); // Spy on the SUT to verify method calls AuthenticationServiceBean spySut = Mockito.spy(sut); @@ -207,6 +209,36 @@ void testLookupUserByOIDCBearerToken_oneProvider_validToken_userIsPresentAsShibb assertEquals("testIdp|testPersistentId", userIdCaptor.getAllValues().get(0)); } + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-bearer-auth-use-oauth-user-on-id-match") + void testLookupUserByOIDCBearerToken_oneProvider_validToken_userIsPresentAsORCIDOAuth_useOAuthUserOnIdMatchFeatureFlagEnabled() throws ParseException, IOException, AuthorizationException, OAuth2Exception { + // Given a single OIDC provider that returns a valid user identifier with OAuth attributes + setUpOIDCProviderWhichValidatesToken(false, true); + + // 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(OrcidOAuth2AP.PROVIDER_ID, providerIdCaptor.getAllValues().get(0)); + assertEquals(TEST_ORCID_USER_ID, userIdCaptor.getAllValues().get(0)); + } + private void setupAuthenticatedUserQueryWithNoResult() { TypedQuery queryStub = Mockito.mock(TypedQuery.class); Mockito.when(queryStub.getSingleResult()).thenThrow(new NoResultException()); @@ -214,10 +246,10 @@ private void setupAuthenticatedUserQueryWithNoResult() { } private void setUpOIDCProviderWhichValidatesToken() throws ParseException, IOException, OAuth2Exception { - setUpOIDCProviderWhichValidatesToken(false); + setUpOIDCProviderWhichValidatesToken(false, false); } - private void setUpOIDCProviderWhichValidatesToken(boolean includeShibAttributes) throws ParseException, IOException, OAuth2Exception { + private void setUpOIDCProviderWhichValidatesToken(boolean includeShibAttributes, boolean includeOAuthAttributes) throws ParseException, IOException, OAuth2Exception { OIDCAuthProvider oidcAuthProviderStub = stubOIDCAuthProvider("OIDC"); BearerAccessToken token = BearerAccessToken.parse(TEST_BEARER_TOKEN); @@ -233,6 +265,10 @@ private void setUpOIDCProviderWhichValidatesToken(boolean includeShibAttributes) Mockito.when(oAuth2UserRecordStub.hasShibAttributes()).thenReturn(true); Mockito.when(oAuth2UserRecordStub.getIdp()).thenReturn("testIdp"); Mockito.when(oAuth2UserRecordStub.getShibUniquePersistentIdentifier()).thenReturn("testPersistentId"); + } else if (includeOAuthAttributes) { + Mockito.when(oAuth2UserRecordStub.hasOAuthAttributes()).thenReturn(true); + Mockito.when(oAuth2UserRecordStub.getIdp()).thenReturn("http://orcid.org/oauth/authorize"); + Mockito.when(oAuth2UserRecordStub.getOidcUserId()).thenReturn("http://orcid.org/" + TEST_ORCID_USER_ID); } UserRecordIdentifier userRecordIdentifierStub = Mockito.mock(UserRecordIdentifier.class); From 12b62c40b8f9e382612922858a2c65a9f318c0c7 Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 25 Jul 2025 16:46:30 +0100 Subject: [PATCH 09/13] Added: missing tests to AuthenticationServiceBeanTest --- .../AuthenticationServiceBeanTest.java | 51 +++++++++++++++---- 1 file changed, 40 insertions(+), 11 deletions(-) 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 f0158213290..80b1c99c642 100644 --- a/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBeanTest.java @@ -7,6 +7,8 @@ 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.impl.GitHubOAuth2AP; +import edu.harvard.iq.dataverse.authorization.providers.oauth2.impl.GoogleOAuth2AP; import edu.harvard.iq.dataverse.authorization.providers.oauth2.impl.OrcidOAuth2AP; import edu.harvard.iq.dataverse.authorization.providers.oauth2.oidc.OIDCAuthProvider; import edu.harvard.iq.dataverse.authorization.providers.shib.ShibAuthenticationProvider; @@ -21,12 +23,16 @@ import jakarta.persistence.TypedQuery; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.mockito.ArgumentCaptor; import org.mockito.Mockito; import java.io.IOException; import java.util.Map; import java.util.Optional; +import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.*; @@ -36,6 +42,8 @@ public class AuthenticationServiceBeanTest { private AuthenticationServiceBean sut; private static final String TEST_BEARER_TOKEN = "Bearer test"; private static final String TEST_ORCID_USER_ID = "0000-0000-0000-0000"; + private static final String TEST_GOOGLE_USER_ID = "111111111111111111111"; + private static final String TEST_GITHUB_USER_ID = "11111111"; @BeforeEach public void setUp() { @@ -183,7 +191,7 @@ void testLookupUserByOIDCBearerToken_oneProvider_validToken_userIsPresentAsBuilt @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-bearer-auth-use-shib-user-on-id-match") void testLookupUserByOIDCBearerToken_oneProvider_validToken_userIsPresentAsShibboleth_useShibUserOnIdMatchFeatureFlagEnabled() throws ParseException, IOException, AuthorizationException, OAuth2Exception { // Given a single OIDC provider that returns a valid user identifier - setUpOIDCProviderWhichValidatesToken(true, false); + setUpOIDCProviderWhichValidatesToken(true, null); // Spy on the SUT to verify method calls AuthenticationServiceBean spySut = Mockito.spy(sut); @@ -209,11 +217,20 @@ void testLookupUserByOIDCBearerToken_oneProvider_validToken_userIsPresentAsShibb assertEquals("testIdp|testPersistentId", userIdCaptor.getAllValues().get(0)); } - @Test + private static Stream oAuthProvider() { + return Stream.of( + Arguments.of(OrcidOAuth2AP.PROVIDER_ID, TEST_ORCID_USER_ID), + Arguments.of(GoogleOAuth2AP.PROVIDER_ID, TEST_GOOGLE_USER_ID), + Arguments.of(GitHubOAuth2AP.PROVIDER_ID, TEST_GITHUB_USER_ID) + ); + } + + @ParameterizedTest + @MethodSource("oAuthProvider") @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-bearer-auth-use-oauth-user-on-id-match") - void testLookupUserByOIDCBearerToken_oneProvider_validToken_userIsPresentAsORCIDOAuth_useOAuthUserOnIdMatchFeatureFlagEnabled() throws ParseException, IOException, AuthorizationException, OAuth2Exception { + void testLookupUserByOIDCBearerToken_oneProvider_validToken_userIsPresentAsOAuth_useOAuthUserOnIdMatchFeatureFlagEnabled(String providerId, String expectedUserId) throws ParseException, IOException, AuthorizationException, OAuth2Exception { // Given a single OIDC provider that returns a valid user identifier with OAuth attributes - setUpOIDCProviderWhichValidatesToken(false, true); + setUpOIDCProviderWhichValidatesToken(false, providerId); // Spy on the SUT to verify method calls AuthenticationServiceBean spySut = Mockito.spy(sut); @@ -235,8 +252,8 @@ void testLookupUserByOIDCBearerToken_oneProvider_validToken_userIsPresentAsORCID Mockito.verify(spySut, Mockito.times(1)).lookupUser(providerIdCaptor.capture(), userIdCaptor.capture()); // Assert that lookupUser is called with expected parameters - assertEquals(OrcidOAuth2AP.PROVIDER_ID, providerIdCaptor.getAllValues().get(0)); - assertEquals(TEST_ORCID_USER_ID, userIdCaptor.getAllValues().get(0)); + assertEquals(providerId, providerIdCaptor.getAllValues().get(0)); + assertEquals(expectedUserId, userIdCaptor.getAllValues().get(0)); } private void setupAuthenticatedUserQueryWithNoResult() { @@ -246,10 +263,10 @@ private void setupAuthenticatedUserQueryWithNoResult() { } private void setUpOIDCProviderWhichValidatesToken() throws ParseException, IOException, OAuth2Exception { - setUpOIDCProviderWhichValidatesToken(false, false); + setUpOIDCProviderWhichValidatesToken(false, null); } - private void setUpOIDCProviderWhichValidatesToken(boolean includeShibAttributes, boolean includeOAuthAttributes) throws ParseException, IOException, OAuth2Exception { + private void setUpOIDCProviderWhichValidatesToken(boolean includeShibAttributes, String providerIdToIncludeClaimsFor) throws ParseException, IOException, OAuth2Exception { OIDCAuthProvider oidcAuthProviderStub = stubOIDCAuthProvider("OIDC"); BearerAccessToken token = BearerAccessToken.parse(TEST_BEARER_TOKEN); @@ -265,10 +282,22 @@ private void setUpOIDCProviderWhichValidatesToken(boolean includeShibAttributes, Mockito.when(oAuth2UserRecordStub.hasShibAttributes()).thenReturn(true); Mockito.when(oAuth2UserRecordStub.getIdp()).thenReturn("testIdp"); Mockito.when(oAuth2UserRecordStub.getShibUniquePersistentIdentifier()).thenReturn("testPersistentId"); - } else if (includeOAuthAttributes) { + } else if (providerIdToIncludeClaimsFor != null) { Mockito.when(oAuth2UserRecordStub.hasOAuthAttributes()).thenReturn(true); - Mockito.when(oAuth2UserRecordStub.getIdp()).thenReturn("http://orcid.org/oauth/authorize"); - Mockito.when(oAuth2UserRecordStub.getOidcUserId()).thenReturn("http://orcid.org/" + TEST_ORCID_USER_ID); + switch (providerIdToIncludeClaimsFor) { + case OrcidOAuth2AP.PROVIDER_ID -> { + Mockito.when(oAuth2UserRecordStub.getIdp()).thenReturn("http://orcid.org/oauth/authorize"); + Mockito.when(oAuth2UserRecordStub.getOidcUserId()).thenReturn("http://orcid.org/" + TEST_ORCID_USER_ID); + } + case GoogleOAuth2AP.PROVIDER_ID -> { + Mockito.when(oAuth2UserRecordStub.getIdp()).thenReturn("http://google.com/accounts/o8/id"); + Mockito.when(oAuth2UserRecordStub.getOidcUserId()).thenReturn(TEST_GOOGLE_USER_ID); + } + case GitHubOAuth2AP.PROVIDER_ID -> { + Mockito.when(oAuth2UserRecordStub.getIdp()).thenReturn("http://github.com/login/oauth/authorize"); + Mockito.when(oAuth2UserRecordStub.getOidcUserId()).thenReturn(TEST_GITHUB_USER_ID); + } + } } UserRecordIdentifier userRecordIdentifierStub = Mockito.mock(UserRecordIdentifier.class); From 2ce4dc56a592cb65dfde0d855e70968db5dc4940 Mon Sep 17 00:00:00 2001 From: GPortas Date: Fri, 25 Jul 2025 16:49:05 +0100 Subject: [PATCH 10/13] Added: docs for api-bearer-auth-use-oauth-user-on-id-match --- doc/sphinx-guides/source/installation/config.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/doc/sphinx-guides/source/installation/config.rst b/doc/sphinx-guides/source/installation/config.rst index c2840f3456f..0fed3ca5ae7 100644 --- a/doc/sphinx-guides/source/installation/config.rst +++ b/doc/sphinx-guides/source/installation/config.rst @@ -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.** + - ``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`` From 5a430704f3aafa08c3944540166c69509f6583c1 Mon Sep 17 00:00:00 2001 From: GPortas Date: Sat, 26 Jul 2025 05:48:26 +0100 Subject: [PATCH 11/13] Added: release notes for #11645 --- .../11645-existing-oauth-external-users-api-auth.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/release-notes/11645-existing-oauth-external-users-api-auth.md diff --git a/doc/release-notes/11645-existing-oauth-external-users-api-auth.md b/doc/release-notes/11645-existing-oauth-external-users-api-auth.md new file mode 100644 index 00000000000..4202f2882a5 --- /dev/null +++ b/doc/release-notes/11645-existing-oauth-external-users-api-auth.md @@ -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. From adf0e9fa712de0fa9c52fbf1b250d3e6a49dcf68 Mon Sep 17 00:00:00 2001 From: Guillermo Portas Date: Fri, 22 Aug 2025 10:24:21 +0100 Subject: [PATCH 12/13] Update doc/release-notes/11645-existing-oauth-external-users-api-auth.md Co-authored-by: Philip Durbin --- .../11645-existing-oauth-external-users-api-auth.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/doc/release-notes/11645-existing-oauth-external-users-api-auth.md b/doc/release-notes/11645-existing-oauth-external-users-api-auth.md index 4202f2882a5..4afc5a38fb2 100644 --- a/doc/release-notes/11645-existing-oauth-external-users-api-auth.md +++ b/doc/release-notes/11645-existing-oauth-external-users-api-auth.md @@ -1 +1,6 @@ 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. + +## New Settings + +- dataverse.feature.api-bearer-auth-use-oauth-user-on-id-match + From 5cca889d04a92b271b6184d1aefa26349803beb1 Mon Sep 17 00:00:00 2001 From: Guillermo Portas Date: Fri, 22 Aug 2025 10:25:16 +0100 Subject: [PATCH 13/13] Update src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java Co-authored-by: Philip Durbin --- .../java/edu/harvard/iq/dataverse/settings/FeatureFlags.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 62f9fed5393..bbbbc7aee30 100644 --- a/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java +++ b/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java @@ -94,7 +94,7 @@ public enum FeatureFlags { * {@link #API_BEARER_AUTH} is enabled.

* * @apiNote Raise flag by setting "dataverse.feature.api-bearer-auth-use-oauth-user-on-id-match" - * @since Dataverse @TODO: + * @since Dataverse 6.8: */ API_BEARER_AUTH_USE_OAUTH_USER_ON_ID_MATCH("api-bearer-auth-use-oauth-user-on-id-match"),