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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions doc/sphinx-guides/source/developers/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,17 @@ always like ``dataverse.<scope/....>.newname...=old.property.name``. Note this d
aliases.

Details can be found in ``edu.harvard.iq.dataverse.settings.source.AliasConfigSource``

Adding a Feature Flag
^^^^^^^^^^^^^^^^^^^^^

Some parts of our codebase might be opt-in only. Experimental or optional features can be switched on using our
usual configuration mechanism, a JVM setting.

Feature flags are implemented in the enumeration ``edu.harvard.iq.dataverse.settings.FeatureFlags``, which allows for
convenient usage of it anywhere in the codebase. When adding a flag, please add a setting in ``JvmSettings`` (see above,
also mind to use the appropriate scope ``dataverse.feature``), think of a default status, add some Javadocs about the
flagged feature and add a ``@since`` tag to make it easier to identify when a flag has been introduced.

We want to maintain a list of all :ref:`feature flags <feature-flags>` in the :ref:`configuration guide <feature-flags>`,
please add yours to the list.
28 changes: 28 additions & 0 deletions doc/sphinx-guides/source/installation/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1769,6 +1769,34 @@ production context! Rely on password alias, secrets directory or cloud based sou



.. _feature-flags:

Feature Flags
-------------

Certain features might be deactivated because they are experimental and/or opt-in. If you want to enable these, please
find all known feature flags below. Any of these flags can be activated using a boolean value
(case-insensitive, one of "true", "1", "YES", "Y", "ON") for the setting.

.. list-table::
:widths: 35 50 15
:header-rows: 1
:align: left

* - Flag Name
- Description
- Default status
* - ``dataverse.feature.api-oidc-access``
- When using an :doc:`OIDC authentication provider <oidc>`, also enable using access tokens from it for API
authentication. Useful to integrate services or SPAs with the API when using cross-service logins.
Not usable for users from other authentication providers!
- Disabled

**Note:** Can be set via any `supported MicroProfile Config API source`_, e.g. the environment variable
``DATAVERSE_FEATURE_API_OIDC_ACCESS``.



.. _:ApplicationServerSettings:

Application Server Settings
Expand Down
6 changes: 6 additions & 0 deletions doc/sphinx-guides/source/installation/oidc.rst
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,9 @@ configuration option. For details, see :doc:`config`.
In contrast to our :doc:`oauth2`, you can use multiple providers by creating distinct configurations enabled by
the same technology and without modifying the Dataverse Software code base (standards for the win!).

Limitations
-----------

Before Dataverse release 5.13, there was no option to use an Open ID Connect authentication provider with
the "Authentication Code Flow" to access the API. As of this version, there is builtin experimental support for this.
Please see the corresponding :ref:`feature flag <feature-flags>` to enable it.
120 changes: 117 additions & 3 deletions src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
package edu.harvard.iq.dataverse.api;

import com.nimbusds.oauth2.sdk.ErrorObject;
import com.nimbusds.oauth2.sdk.ParseException;
import com.nimbusds.oauth2.sdk.http.HTTPResponse;
import com.nimbusds.oauth2.sdk.token.BearerAccessToken;
import com.nimbusds.openid.connect.sdk.UserInfoRequest;
import com.nimbusds.openid.connect.sdk.UserInfoResponse;
import com.nimbusds.openid.connect.sdk.claims.UserInfo;
import edu.harvard.iq.dataverse.DataFile;
import edu.harvard.iq.dataverse.DataFileServiceBean;
import edu.harvard.iq.dataverse.Dataset;
Expand All @@ -25,10 +32,12 @@
import edu.harvard.iq.dataverse.UserNotificationServiceBean;
import edu.harvard.iq.dataverse.UserServiceBean;
import edu.harvard.iq.dataverse.actionlogging.ActionLogServiceBean;
import edu.harvard.iq.dataverse.authorization.AuthenticationProvider;
import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean;
import edu.harvard.iq.dataverse.authorization.DataverseRole;
import edu.harvard.iq.dataverse.authorization.RoleAssignee;
import edu.harvard.iq.dataverse.authorization.groups.GroupServiceBean;
import edu.harvard.iq.dataverse.authorization.providers.oauth2.oidc.OIDCAuthProvider;
import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser;
import edu.harvard.iq.dataverse.authorization.users.GuestUser;
import edu.harvard.iq.dataverse.authorization.users.PrivateUrlUser;
Expand All @@ -46,6 +55,7 @@
import edu.harvard.iq.dataverse.privateurl.PrivateUrlServiceBean;
import edu.harvard.iq.dataverse.locality.StorageSiteServiceBean;
import edu.harvard.iq.dataverse.search.savedsearch.SavedSearchServiceBean;
import edu.harvard.iq.dataverse.settings.FeatureFlags;
import edu.harvard.iq.dataverse.settings.JvmSettings;
import edu.harvard.iq.dataverse.settings.SettingsServiceBean;
import edu.harvard.iq.dataverse.util.BundleUtil;
Expand All @@ -54,15 +64,19 @@
import edu.harvard.iq.dataverse.util.json.JsonParser;
import edu.harvard.iq.dataverse.util.json.NullSafeJsonBuilder;
import edu.harvard.iq.dataverse.validation.PasswordValidatorServiceBean;

import java.io.IOException;
import java.io.StringReader;
import java.net.URI;
import java.util.Arrays;
import java.util.Collections;
import java.util.Enumeration;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.Callable;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Collectors;
import javax.ejb.EJB;
import javax.ejb.EJBException;
import javax.json.Json;
Expand All @@ -78,6 +92,7 @@
import javax.persistence.PersistenceContext;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.ResponseBuilder;
Expand All @@ -92,6 +107,7 @@ public abstract class AbstractApiBean {

private static final Logger logger = Logger.getLogger(AbstractApiBean.class.getName());
private static final String DATAVERSE_KEY_HEADER_NAME = "X-Dataverse-key";
private static final String OIDC_AUTH_SCHEME = "Bearer";
private static final String PERSISTENT_ID_KEY=":persistentId";
private static final String ALIAS_KEY=":alias";
public static final String STATUS_ERROR = "ERROR";
Expand Down Expand Up @@ -364,7 +380,7 @@ protected AuthenticatedUser findUserByApiToken( String apiKey ) {
protected User findUserOrDie() throws WrappedResponse {
final String requestApiKey = getRequestApiKey();
final String requestWFKey = getRequestWorkflowInvocationID();
if (requestApiKey == null && requestWFKey == null && getRequestParameter(UrlSignerUtil.SIGNED_URL_TOKEN)==null) {
if (requestApiKey == null && requestWFKey == null && getRequestParameter(UrlSignerUtil.SIGNED_URL_TOKEN)==null && !(FeatureFlags.API_OIDC_ACCESS.enabled() && getOidcBearerToken(httpRequest).isPresent())) {
return GuestUser.get();
}
PrivateUrlUser privateUrlUser = privateUrlSvc.getPrivateUrlUserFromToken(requestApiKey);
Expand Down Expand Up @@ -426,6 +442,23 @@ private AuthenticatedUser findAuthenticatedUserOrDie( String key, String wfid )
if (authUser != null) {
return authUser;
}

} else if (FeatureFlags.API_OIDC_ACCESS.enabled() && getOidcBearerToken(httpRequest).isPresent()) {
UserInfo userInfo = verifyOidcBearerToken(getOidcBearerToken(httpRequest).get());

// TODO: Only usable for OIDC users for now, just look it up via the subject.
// This will need to be modified to provide mappings somehow for existing non-OIDC-users.
// TODO: If we keep the current login infrastructure alive, we should introduce a common static
// method in OIDCAuthProvider to create the identifier in both places.
AuthenticatedUser authUser = authSvc.getAuthenticatedUser(userInfo.getSubject().getValue());
Copy link
Member

Choose a reason for hiding this comment

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

FWIW: Using authSvc.getAuthenticatedUserByEmail(userInfo.getEmail().toString()); should work. This isn't checking to see if the account was created using OIDC though.


// TODO: this is code dup par excellence. Needs refactoring. Maybe fine for Proof-of-Concept.
if (authUser != null) {
authUser = userSvc.updateLastApiUseTime(authUser);
return authUser;
} else {
throw new WrappedResponse(badOidcUser(userInfo.getSubject().getValue()));
}
}
//Just send info about the apiKey - workflow users will learn about invocationId elsewhere
throw new WrappedResponse(badApiKey(null));
Expand All @@ -451,7 +484,84 @@ private AuthenticatedUser getAuthenticatedUserFromSignedUrl() {
}
return authUser;
}


/**
* Retrieve the raw, encoded token value from the Authorization Bearer HTTP header as defined in RFC 6750
* @param request The HTTP request coming in
* @return An {@link Optional} either empty if not present or the raw token from the header
*/
Optional<String> getOidcBearerToken(HttpServletRequest request) {
String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);

if (authHeader != null && authHeader.toLowerCase().startsWith(OIDC_AUTH_SCHEME.toLowerCase() + " ")) {
return Optional.of(authHeader);
} else {
return Optional.empty();
}
}


/**
* <p>Verify an OIDC access token by dealing the access token for a UserInfo object from the provider</p>
* <p>
* TODO: This is a proof of concept, providing value for IQSS#9229 and first steps for our SPA move. It ...
* - will need more tweaks (see inline comments),
* - should be extended to support JWT access tokens to avoid the extra detour to the OIDC provider,
* - needs to be moved to a distinct place when we head for authentication filters in future iterations.
* </p>
*
* @param token The string containing the encoded JWT
* @return
*/
UserInfo verifyOidcBearerToken(String token) throws WrappedResponse {
try {
BearerAccessToken accessToken = BearerAccessToken.parse(token);

// Get list of all authentication providers using Open ID Connect
List<OIDCAuthProvider> providers = authSvc.getAuthenticationProviderIdsOfType(OIDCAuthProvider.class).stream()
.map(providerId -> (OIDCAuthProvider) authSvc.getAuthenticationProvider(providerId))
.collect(Collectors.toUnmodifiableList());

// Iterate over all OIDC providers if multiple.
for (OIDCAuthProvider provider : providers) {

// Retrieve data of the user accessing the API from the provider.
// No need to introspect the token here, the userInfoRequest also validates the token, as the provider
// is the source of truth.
try {
HTTPResponse response = new UserInfoRequest(provider.getUserInfoEndpointURI(), accessToken)
.toHTTPRequest()
.send();

UserInfoResponse infoResponse = UserInfoResponse.parse(response);

// If error, throw 401 error exception
if (! infoResponse.indicatesSuccess() ) {
ErrorObject error = infoResponse.toErrorResponse().getErrorObject();
logger.log(Level.FINE,
"UserInfo could not be retrieved by access token from provider {0}: {1}",
new String[]{provider.getId(), error.getDescription()});
// Success, simply return the user info
} else {
return infoResponse.toSuccessResponse().getUserInfo();
}
} catch (ParseException | IOException e) {
logger.log(Level.WARNING,
"Could not retrieve user info for provider " + provider.getId() + ", skipping", e);
}
}
} catch (ParseException e) {
logger.log(Level.FINE, "Could not parse bearer access token", e);
throw new WrappedResponse(error(Status.UNAUTHORIZED, "Could not parse bearer access token"));
}

// No UserInfo returned means we have an invalid access token. (It could also mean we have no OIDC
// provider, but this would also mean this is an invalid request, as there will be no user available...)
// TODO: Should this include more details about the request?
logger.log(Level.FINE, "Unauthorized bearer access token detected");
throw new WrappedResponse(error(Status.UNAUTHORIZED, "Unauthorized bearer access token"));
}

protected Dataverse findDataverseOrDie( String dvIdtf ) throws WrappedResponse {
Dataverse dv = findDataverse(dvIdtf);
if ( dv == null ) {
Expand Down Expand Up @@ -866,6 +976,10 @@ protected Response badWFKey( String wfId ) {
String message = (wfId != null ) ? "Bad workflow invocationId " : "Please provide an invocationId query parameter (?invocationId=XXX) or via the HTTP header " + DATAVERSE_WORKFLOW_INVOCATION_HEADER_NAME;
return error(Status.UNAUTHORIZED, message );
}

protected Response badOidcUser(String oicdUserId ) {
return error(Status.UNAUTHORIZED, "OIDC user with identifier " + oicdUserId + " is unknown");
}

protected Response permissionError( PermissionException pe ) {
return permissionError( pe.getMessage() );
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ public void doFilter(ServletRequest sr, ServletResponse sr1, FilterChain fc) thr
if (settingsSvc.isTrueForKey(SettingsServiceBean.Key.AllowCors, true )) {
((HttpServletResponse) sr1).addHeader("Access-Control-Allow-Origin", "*");
((HttpServletResponse) sr1).addHeader("Access-Control-Allow-Methods", "PUT, GET, POST, DELETE, OPTIONS");
((HttpServletResponse) sr1).addHeader("Access-Control-Allow-Headers", "Accept, Content-Type, X-Dataverse-Key, Range");
((HttpServletResponse) sr1).addHeader("Access-Control-Allow-Headers", "Accept, Content-Type, X-Dataverse-Key, Range, Authorization");
((HttpServletResponse) sr1).addHeader("Access-Control-Expose-Headers", "Accept-Ranges, Content-Range, Content-Encoding");
}
fc.doFilter(sr, sr1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ public OIDCAuthProvider(String aClientId, String aClientSecret, String issuerEnd
this.clientSecret = aClientSecret; // nedded for state creation
this.clientAuth = new ClientSecretBasic(new ClientID(aClientId), new Secret(aClientSecret));
this.issuer = new Issuer(issuerEndpointURL);
getMetadata();
setupMetadata();
}

/**
Expand All @@ -74,17 +74,19 @@ public OIDCAuthProvider(String aClientId, String aClientSecret, String issuerEnd
* @return false
*/
@Override
public boolean isDisplayIdentifier() { return false; }
public boolean isDisplayIdentifier() {
return false;
}

/**
* Setup metadata from OIDC provider during creation of the provider representation
* @return The OIDC provider metadata, if successfull
* @throws IOException when sth. goes wrong with the retrieval
* @throws ParseException when the metadata is not parsable
*/
void getMetadata() throws AuthorizationSetupException {
void setupMetadata() throws AuthorizationSetupException {
try {
this.idpMetadata = getMetadata(this.issuer);
this.idpMetadata = fetchMetadata(this.issuer);
} catch (IOException ex) {
logger.severe("OIDC provider metadata at \"+issuerEndpointURL+\" not retrievable: "+ex.getMessage());
throw new AuthorizationSetupException("OIDC provider metadata at "+this.issuer.getValue()+" not retrievable.");
Expand All @@ -106,7 +108,7 @@ void getMetadata() throws AuthorizationSetupException {
* @throws IOException when sth. goes wrong with the retrieval
* @throws ParseException when the metadata is not parsable
*/
OIDCProviderMetadata getMetadata(Issuer issuer) throws IOException, ParseException {
OIDCProviderMetadata fetchMetadata(Issuer issuer) throws IOException, ParseException {
// Will resolve the OpenID provider metadata automatically
OIDCProviderConfigurationRequest request = new OIDCProviderConfigurationRequest(issuer);

Expand All @@ -118,6 +120,18 @@ OIDCProviderMetadata getMetadata(Issuer issuer) throws IOException, ParseExcepti
return OIDCProviderMetadata.parse(httpResponse.getContentAsJSONObject());
}


/**
* Retrieve the user info endpoint of this provider, used by the OIDC API access feature to use access tokens.
* Returned URI is immutable and will not harm the provider setup.
*
* @return The {@link URI} of the UserInfo endpoint.
*/
public URI getUserInfoEndpointURI() {
return this.idpMetadata.getUserInfoEndpointURI();
}


/**
* TODO: remove when refactoring package and {@link AbstractOAuth2AuthenticationProvider}
*/
Expand Down
40 changes: 40 additions & 0 deletions src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package edu.harvard.iq.dataverse.settings;

/**
* <p>This enum holds so-called "feature flags" aka "feature gates", etc. It can be used throughout the application
* to avoid activating or using experimental functionality or feature previews that are opt-in.</p>
*
* <p>The current implementation reuses {@link JvmSettings} to interpret any
* <a href="https://download.eclipse.org/microprofile/microprofile-config-3.0/microprofile-config-spec-3.0.html#_built_in_converters">boolean values</a>
* (true == case-insensitive one of "true", "1", "YES", "Y", "ON") and hook into the usual settings system
* (any MicroProfile Config Source available).</p>
*
* If you add any new flags, please add a setting in JvmSettings, think of a default status, add some Javadocs
* about the flagged feature and add a "@since" tag to make it easier to identify when a flag has been introduced.
*
*/
public enum FeatureFlags {

/**
* Enabling will unblock access to the API with an OIDC access token in addition to other available methods.
* @apiNote Raise flag by setting "dataverse.feature.api-oidc-access"
* @since Dataverse 5.13
* @see JvmSettings#FLAG_API_OIDC_ACCESS
*/
API_OIDC_ACCESS(JvmSettings.FLAG_API_OIDC_ACCESS, false),

;

final JvmSettings setting;
final boolean defaultStatus;

FeatureFlags(JvmSettings setting, boolean defaultStatus) {
this.setting = setting;
this.defaultStatus = defaultStatus;
}

public boolean enabled() {
return setting.lookupOptional(Boolean.class).orElse(defaultStatus);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ public enum JvmSettings {
SCOPE_API(PREFIX, "api"),
API_SIGNING_SECRET(SCOPE_API, "signing-secret"),

// FEATURE FLAGS SETTINGS
SCOPE_FLAGS(PREFIX, "feature"),
FLAG_API_OIDC_ACCESS(SCOPE_FLAGS, "api-oidc-access"),

;

private static final String SCOPE_SEPARATOR = ".";
Expand Down