Skip to content

Commit 20aaf5a

Browse files
authored
Merge pull request #11622 from IQSS/11605-existing-external-users-api-auth
OIDC-Brokered Shibboleth users API auth
2 parents 317186f + 6bcfde8 commit 20aaf5a

File tree

10 files changed

+218
-59
lines changed

10 files changed

+218
-59
lines changed

conf/keycloak/builtin-users-spi/src/main/java/edu/harvard/iq/keycloak/auth/spi/services/DataverseUserService.java

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,16 @@ public DataverseUser getUserById(String id) {
2727

2828
DataverseBuiltinUser builtinUser = em.find(DataverseBuiltinUser.class, persistenceId);
2929
if (builtinUser == null) {
30-
logger.debugf("User not found for external ID: %s", persistenceId);
30+
logger.debugf("Builtin user not found for external ID: %s", persistenceId);
3131
return null;
3232
}
3333

34-
DataverseAuthenticatedUser authenticatedUser = getAuthenticatedUserByUsername(builtinUser.getUsername());
34+
String username = builtinUser.getUsername();
35+
DataverseAuthenticatedUser authenticatedUser = getAuthenticatedUserByUsername(username);
36+
if (authenticatedUser == null) {
37+
logger.debugf("Authenticated user not found by username: %s", username);
38+
return null;
39+
}
3540

3641
return new DataverseUser(authenticatedUser, builtinUser);
3742
}
@@ -43,11 +48,15 @@ public DataverseUser getUserByUsername(String username) {
4348
.getResultList();
4449

4550
if (users.isEmpty()) {
46-
logger.debugf("User not found by username: %s", username);
51+
logger.debugf("Builtin user not found by username: %s", username);
4752
return null;
4853
}
4954

5055
DataverseAuthenticatedUser authenticatedUser = getAuthenticatedUserByUsername(username);
56+
if (authenticatedUser == null) {
57+
logger.debugf("Authenticated user not found by username: %s", username);
58+
return null;
59+
}
5160

5261
return new DataverseUser(authenticatedUser, users.get(0));
5362
}
@@ -59,7 +68,7 @@ public DataverseUser getUserByEmail(String email) {
5968
.getResultList();
6069

6170
if (authUsers.isEmpty()) {
62-
logger.debugf("User not found by email: %s", email);
71+
logger.debugf("Authenticated user not found by email: %s", email);
6372
return null;
6473
}
6574

@@ -68,6 +77,11 @@ public DataverseUser getUserByEmail(String email) {
6877
.setParameter("username", username)
6978
.getResultList();
7079

80+
if (builtinUsers.isEmpty()) {
81+
logger.debugf("Builtin user not found by username: %s", username);
82+
return null;
83+
}
84+
7185
return new DataverseUser(authUsers.get(0), builtinUsers.get(0));
7286
}
7387

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Implemented a new feature flag ``dataverse.feature.api-bearer-auth-use-shib-user-on-id-match``, which supports the use of the new Dataverse client in instances that have historically allowed login via Shibboleth. Specifically, with this flag enabled, when an OIDC bridge is configured to allow OIDC login with validation by the bridged Shibboleth providers, users with existing Shibboleth-based accounts in Dataverse can log in to those accounts, thereby maintaining access to their existing content and retaining their roles. (For security reasons, Dataverse's current support for direct login via Shibboleth cannot be used in browser-based clients.)

doc/sphinx-guides/source/installation/config.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3726,6 +3726,9 @@ please find all known feature flags below. Any of these flags can be activated u
37263726
* - api-bearer-auth-use-builtin-user-on-id-match
37273727
- Allows the use of a built-in user account when an identity match is found during API bearer authentication. This feature enables automatic association of an incoming IdP identity with an existing built-in user account, bypassing the need for additional user registration steps. This feature only works when the feature flag ``api-bearer-auth`` is also enabled. **Caution: Enabling this feature flag exposes the installation to potential user impersonation issues depending on the specifics of the IdP configured (For example, if it is configured such that an attacker can create a new account in the IdP, or configured social login account, matching a Dataverse built-in account).**
37283728
- ``Off``
3729+
* - api-bearer-auth-use-shib-user-on-id-match
3730+
- 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.**
3731+
- ``Off``
37293732
* - avoid-expensive-solr-join
37303733
- 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`.
37313734
- ``Off``

src/main/java/edu/harvard/iq/dataverse/Shib.java

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -76,21 +76,6 @@ public class Shib implements java.io.Serializable {
7676
private final String loginpage = "/loginpage.xhtml";
7777
private final String identityProviderProblem = "Problem with Identity Provider";
7878

79-
/**
80-
* We only have one field in which to store a unique
81-
* useridentifier/persistentuserid so we have to jam the the "entityId" for
82-
* a Shibboleth Identity Provider (IdP) and the unique persistent identifier
83-
* per user into the same field and a separator between these two would be
84-
* nice, in case we ever want to answer questions like "How many users
85-
* logged in from Harvard's Identity Provider?".
86-
*
87-
* A pipe ("|") is used as a separator because it's considered "unwise" to
88-
* use in a URL and the "entityId" for a Shibboleth Identity Provider (IdP)
89-
* looks like a URL:
90-
* http://stackoverflow.com/questions/1547899/which-characters-make-a-url-invalid
91-
*/
92-
private String persistentUserIdSeparator = "|";
93-
9479
/**
9580
* The Shibboleth Identity Provider (IdP), an "entityId" which often but not
9681
* always looks like a URL.
@@ -248,7 +233,7 @@ else if (ShibAffiliationOrder.equals("firstAffiliation")) {
248233
// emailAddress = "willFailBeanValidation"; // for testing createAuthenticatedUser exceptions
249234
displayInfo = new AuthenticatedUserDisplayInfo(firstName, lastName, emailAddress, affiliation, null);
250235

251-
userPersistentId = shibIdp + persistentUserIdSeparator + shibUserIdentifier;
236+
userPersistentId = ShibUtil.createUserPersistentIdentifier(shibIdp, shibUserIdentifier);
252237
ShibAuthenticationProvider shibAuthProvider = new ShibAuthenticationProvider();
253238
AuthenticatedUser au = authSvc.lookupUser(shibAuthProvider.getId(), userPersistentId);
254239
if (au != null) {

src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import edu.harvard.iq.dataverse.authorization.providers.oauth2.OAuth2UserRecord;
1515
import edu.harvard.iq.dataverse.authorization.providers.oauth2.impl.OrcidOAuth2AP;
1616
import edu.harvard.iq.dataverse.authorization.providers.oauth2.oidc.OIDCAuthProvider;
17+
import edu.harvard.iq.dataverse.authorization.providers.shib.ShibUtil;
1718
import edu.harvard.iq.dataverse.search.IndexServiceBean;
1819
import edu.harvard.iq.dataverse.actionlogging.ActionLogRecord;
1920
import edu.harvard.iq.dataverse.actionlogging.ActionLogServiceBean;
@@ -995,10 +996,23 @@ public AuthenticatedUser lookupUserByOIDCBearerToken(String bearerToken) throws
995996
// TODO: Get the identifier from an invalidating cache to avoid lookup bursts of the same token.
996997
// Tokens in the cache should be removed after some (configurable) time.
997998
OAuth2UserRecord oAuth2UserRecord = verifyOIDCBearerTokenAndGetOAuth2UserRecord(bearerToken);
998-
if (FeatureFlags.API_BEARER_AUTH_USE_BUILTIN_USER_ON_ID_MATCH.enabled()) {
999-
AuthenticatedUser builtinAuthenticatedUser = lookupUser(BuiltinAuthenticationProvider.PROVIDER_ID, oAuth2UserRecord.getUsername());
1000-
return (builtinAuthenticatedUser != null) ? builtinAuthenticatedUser : lookupUser(oAuth2UserRecord.getUserRecordIdentifier());
999+
AuthenticatedUser authenticatedUser;
1000+
if (FeatureFlags.API_BEARER_AUTH_USE_SHIB_USER_ON_ID_MATCH.enabled() && oAuth2UserRecord.hasShibAttributes()) {
1001+
logger.log(Level.FINE, "OAuth2UserRecord has Shibboleth attributes");
1002+
String userPersistentId = ShibUtil.createUserPersistentIdentifier(oAuth2UserRecord.getShibIdp(), oAuth2UserRecord.getShibUniquePersistentIdentifier());
1003+
authenticatedUser = lookupUser(ShibAuthenticationProvider.PROVIDER_ID, userPersistentId);
1004+
if (authenticatedUser != null) {
1005+
logger.log(Level.FINE, "Shibboleth user found for the given bearer token");
1006+
return authenticatedUser;
1007+
}
1008+
} else if (FeatureFlags.API_BEARER_AUTH_USE_BUILTIN_USER_ON_ID_MATCH.enabled()) {
1009+
authenticatedUser = lookupUser(BuiltinAuthenticationProvider.PROVIDER_ID, oAuth2UserRecord.getUsername());
1010+
if (authenticatedUser != null) {
1011+
logger.log(Level.FINE, "Builtin user found for the given bearer token");
1012+
return authenticatedUser;
1013+
}
10011014
}
1015+
10021016
return lookupUser(oAuth2UserRecord.getUserRecordIdentifier());
10031017
}
10041018

src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/OAuth2UserRecord.java

Lines changed: 76 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,39 +2,75 @@
22

33
import edu.harvard.iq.dataverse.authorization.AuthenticatedUserDisplayInfo;
44
import edu.harvard.iq.dataverse.authorization.UserRecordIdentifier;
5+
6+
import java.io.Serializable;
57
import java.util.List;
68

79
/**
810
* Describes a single user on a remote IDP that uses OAuth2.
911
* Normally generated by {@link AbstractOAuth2Idp}.
10-
*
12+
*
1113
* @author michael
1214
*/
13-
public class OAuth2UserRecord implements java.io.Serializable {
14-
15+
public class OAuth2UserRecord implements Serializable {
16+
1517
private final String serviceId;
16-
17-
/** An immutable value, probably a number. Not a username that may change. */
18+
19+
/**
20+
* An immutable value, probably a number. Not a username that may change.
21+
*/
1822
private final String idInService;
1923

20-
/** A potentially mutable String that is easier on the eye than a number. */
24+
/**
25+
* A potentially mutable String that is easier on the eye than a number.
26+
*/
2127
private final String username;
22-
28+
29+
/**
30+
* For users originally coming from a Shibboleth IdP
31+
*/
32+
private final String shibUniquePersistentIdentifier;
33+
private final String shibIdp;
34+
2335
private final AuthenticatedUserDisplayInfo displayInfo;
24-
2536
private final List<String> availableEmailAddresses;
26-
2737
private final OAuth2TokenData tokenData;
28-
29-
public OAuth2UserRecord(String aServiceId, String anIdInService, String aUsername,
30-
OAuth2TokenData someTokenData, AuthenticatedUserDisplayInfo aDisplayInfo,
31-
List<String> someAvailableEmailAddresses) {
32-
serviceId = aServiceId;
33-
idInService = anIdInService;
34-
username = aUsername;
35-
tokenData = someTokenData;
36-
displayInfo = aDisplayInfo;
37-
availableEmailAddresses = someAvailableEmailAddresses;
38+
39+
/**
40+
* Constructor for users without Shibboleth attributes.
41+
*/
42+
public OAuth2UserRecord(
43+
String serviceId,
44+
String idInService,
45+
String username,
46+
OAuth2TokenData tokenData,
47+
AuthenticatedUserDisplayInfo displayInfo,
48+
List<String> availableEmailAddresses
49+
) {
50+
this(serviceId, idInService, username, null, null, tokenData, displayInfo, availableEmailAddresses);
51+
}
52+
53+
/**
54+
* Full constructor for OAuth2 user records.
55+
*/
56+
public OAuth2UserRecord(
57+
String serviceId,
58+
String idInService,
59+
String username,
60+
String shibUniquePersistentIdentifier,
61+
String shibIdp,
62+
OAuth2TokenData tokenData,
63+
AuthenticatedUserDisplayInfo displayInfo,
64+
List<String> availableEmailAddresses
65+
) {
66+
this.serviceId = serviceId;
67+
this.idInService = idInService;
68+
this.username = username;
69+
this.shibUniquePersistentIdentifier = shibUniquePersistentIdentifier;
70+
this.shibIdp = shibIdp;
71+
this.tokenData = tokenData;
72+
this.displayInfo = displayInfo;
73+
this.availableEmailAddresses = availableEmailAddresses;
3874
}
3975

4076
public String getServiceId() {
@@ -49,10 +85,18 @@ public String getUsername() {
4985
return username;
5086
}
5187

88+
public String getShibUniquePersistentIdentifier() {
89+
return shibUniquePersistentIdentifier;
90+
}
91+
92+
public String getShibIdp() {
93+
return shibIdp;
94+
}
95+
5296
public List<String> getAvailableEmailAddresses() {
5397
return availableEmailAddresses;
5498
}
55-
99+
56100
public AuthenticatedUserDisplayInfo getDisplayInfo() {
57101
return displayInfo;
58102
}
@@ -61,12 +105,19 @@ public OAuth2TokenData getTokenData() {
61105
return tokenData;
62106
}
63107

64-
@Override
65-
public String toString() {
66-
return "OAuth2UserRecord{" + "serviceId=" + serviceId + ", idInService=" + idInService + '}';
67-
}
68-
69108
public UserRecordIdentifier getUserRecordIdentifier() {
70109
return new UserRecordIdentifier(serviceId, idInService);
71110
}
111+
112+
public boolean hasShibAttributes() {
113+
return shibIdp != null && shibUniquePersistentIdentifier != null;
114+
}
115+
116+
@Override
117+
public String toString() {
118+
return "OAuth2UserRecord{" +
119+
"serviceId='" + serviceId + '\'' +
120+
", idInService='" + idInService + '\'' +
121+
'}';
122+
}
72123
}

src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/oidc/OIDCAuthProvider.java

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,11 @@
3333
import com.nimbusds.openid.connect.sdk.op.OIDCProviderConfigurationRequest;
3434
import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata;
3535
import edu.harvard.iq.dataverse.authorization.AuthenticatedUserDisplayInfo;
36-
import edu.harvard.iq.dataverse.authorization.UserRecordIdentifier;
3736
import edu.harvard.iq.dataverse.authorization.exceptions.AuthorizationSetupException;
3837
import edu.harvard.iq.dataverse.authorization.providers.oauth2.AbstractOAuth2AuthenticationProvider;
3938
import edu.harvard.iq.dataverse.authorization.providers.oauth2.OAuth2Exception;
4039
import edu.harvard.iq.dataverse.authorization.providers.oauth2.OAuth2UserRecord;
40+
import edu.harvard.iq.dataverse.authorization.providers.shib.ShibUtil;
4141
import edu.harvard.iq.dataverse.settings.JvmSettings;
4242
import edu.harvard.iq.dataverse.util.BundleUtil;
4343

@@ -47,11 +47,8 @@
4747
import java.time.temporal.ChronoUnit;
4848
import java.util.Arrays;
4949
import java.util.List;
50-
import java.util.Map;
5150
import java.util.Optional;
52-
import java.util.concurrent.ConcurrentHashMap;
5351
import java.util.concurrent.ExecutionException;
54-
import java.util.logging.Level;
5552
import java.util.logging.Logger;
5653

5754
/**
@@ -231,16 +228,34 @@ public OAuth2UserRecord getUserRecord(String code, String state, String redirect
231228
* @return the usable user record for processing ing {@link edu.harvard.iq.dataverse.authorization.providers.oauth2.OAuth2LoginBackingBean}
232229
*/
233230
public OAuth2UserRecord getUserRecord(UserInfo userInfo) {
231+
// Extract Shibboleth attributes if present
232+
Object shibUniqueIdObj = userInfo.getClaim(ShibUtil.uniquePersistentIdentifier);
233+
Object shibIdpObj = userInfo.getClaim(ShibUtil.shibIdpAttribute);
234+
235+
String shibUniqueId = (shibUniqueIdObj != null) ? shibUniqueIdObj.toString() : null;
236+
String shibIdp = (shibIdpObj != null) ? shibIdpObj.toString() : null;
237+
238+
// Build display info from user attributes
239+
AuthenticatedUserDisplayInfo displayInfo = new AuthenticatedUserDisplayInfo(
240+
userInfo.getGivenName(),
241+
userInfo.getFamilyName(),
242+
userInfo.getEmailAddress(),
243+
"",
244+
""
245+
);
246+
234247
return new OAuth2UserRecord(
235-
this.getId(),
236-
userInfo.getSubject().getValue(),
237-
userInfo.getPreferredUsername(),
238-
null,
239-
new AuthenticatedUserDisplayInfo(userInfo.getGivenName(), userInfo.getFamilyName(), userInfo.getEmailAddress(), "", ""),
240-
null
248+
this.getId(),
249+
userInfo.getSubject().getValue(),
250+
userInfo.getPreferredUsername(),
251+
shibUniqueId,
252+
shibIdp,
253+
null,
254+
displayInfo,
255+
null
241256
);
242257
}
243-
258+
244259
/**
245260
* Retrieve the Access Token from provider. Encapsulate for testing.
246261
* @param grant

src/main/java/edu/harvard/iq/dataverse/authorization/providers/shib/ShibUtil.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,4 +405,23 @@ public static void printAttributes(HttpServletRequest request) {
405405
logger.fine("shib values: " + shibValues);
406406
}
407407

408+
/**
409+
* Creates a persistent identifier for a user authenticated via a Shibboleth Identity Provider (IdP).
410+
*
411+
* <p>This method combines the IdP's entity ID and the user's unique identifier into a single string,
412+
* using a pipe character ("|") as a separator. This is necessary because there is only one field
413+
* available to store the full identifier.</p>
414+
*
415+
* <p>The pipe character is chosen because it's considered "unwise" to use in URLs, and the
416+
* Shibboleth IdP entity ID often resembles a URL. Using this separator allows for future parsing,
417+
* such as answering questions like "How many users logged in from Harvard's Identity Provider?"</p>
418+
*
419+
* @param shibIdp the entity ID of the Shibboleth Identity Provider
420+
* @param shibUserIdentifier the unique persistent identifier for the user from the IdP
421+
* @return a combined string containing both the IdP and user identifier, separated by a pipe
422+
*/
423+
public static String createUserPersistentIdentifier(String shibIdp, String shibUserIdentifier) {
424+
String persistentUserIdSeparator = "|";
425+
return shibIdp + persistentUserIdSeparator + shibUserIdentifier;
426+
}
408427
}

src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,20 @@ public enum FeatureFlags {
7171
* @since Dataverse @6.7:
7272
*/
7373
API_BEARER_AUTH_USE_BUILTIN_USER_ON_ID_MATCH("api-bearer-auth-use-builtin-user-on-id-match"),
74+
75+
/**
76+
* Allows the use of a Shibboleth user account when an identity match is found during API bearer authentication.
77+
* This feature enables automatic association of an incoming IdP identity with an existing Shibboleth user account,
78+
* bypassing the need for additional user registration steps.
79+
*
80+
* <p>The value of this feature flag is only considered when the feature flag
81+
* {@link #API_BEARER_AUTH} is enabled.</p>
82+
*
83+
* @apiNote Raise flag by setting "dataverse.feature.api-bearer-auth-use-shib-user-on-id-match"
84+
* @since Dataverse @TODO:
85+
*/
86+
API_BEARER_AUTH_USE_SHIB_USER_ON_ID_MATCH("api-bearer-auth-use-shib-user-on-id-match"),
87+
7488
/**
7589
* For published (public) objects, don't use a join when searching Solr.
7690
* Experimental! Requires a reindex with the following feature flag enabled,

0 commit comments

Comments
 (0)