diff --git a/doc/sphinx-guides/source/developers/configuration.rst b/doc/sphinx-guides/source/developers/configuration.rst index fb15fea7900..4f550e59f90 100644 --- a/doc/sphinx-guides/source/developers/configuration.rst +++ b/doc/sphinx-guides/source/developers/configuration.rst @@ -109,3 +109,17 @@ always like ``dataverse..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 ` in the :ref:`configuration guide `, +please add yours to the list. \ No newline at end of file diff --git a/doc/sphinx-guides/source/installation/config.rst b/doc/sphinx-guides/source/installation/config.rst index 2c576b03989..a2142578459 100644 --- a/doc/sphinx-guides/source/installation/config.rst +++ b/doc/sphinx-guides/source/installation/config.rst @@ -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 `, 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 diff --git a/doc/sphinx-guides/source/installation/oidc.rst b/doc/sphinx-guides/source/installation/oidc.rst index a40ef758dc7..42c7cc2e7fc 100644 --- a/doc/sphinx-guides/source/installation/oidc.rst +++ b/doc/sphinx-guides/source/installation/oidc.rst @@ -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 ` to enable it. \ No newline at end of file diff --git a/src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java b/src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java index e919ecf786d..fcb7fcdeb02 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java @@ -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; @@ -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; @@ -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; @@ -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; @@ -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; @@ -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"; @@ -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); @@ -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()); + + // 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)); @@ -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 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(); + } + } + + + /** + *

Verify an OIDC access token by dealing the access token for a UserInfo object from the provider

+ *

+ * 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. + *

+ * + * @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 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 ) { @@ -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() ); diff --git a/src/main/java/edu/harvard/iq/dataverse/api/ApiBlockingFilter.java b/src/main/java/edu/harvard/iq/dataverse/api/ApiBlockingFilter.java index 6bf852d25f7..d6941469366 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/ApiBlockingFilter.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/ApiBlockingFilter.java @@ -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); 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 a9c44010950..01dbbb649b2 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 @@ -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(); } /** @@ -74,7 +74,9 @@ 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 @@ -82,9 +84,9 @@ public OIDCAuthProvider(String aClientId, String aClientSecret, String issuerEnd * @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."); @@ -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); @@ -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} */ diff --git a/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java b/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java new file mode 100644 index 00000000000..8d89efd6608 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java @@ -0,0 +1,40 @@ +package edu.harvard.iq.dataverse.settings; + +/** + *

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.

+ * + *

The current implementation reuses {@link JvmSettings} to interpret any + * boolean values + * (true == case-insensitive one of "true", "1", "YES", "Y", "ON") and hook into the usual settings system + * (any MicroProfile Config Source available).

+ * + * 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); + } + +} diff --git a/src/main/java/edu/harvard/iq/dataverse/settings/JvmSettings.java b/src/main/java/edu/harvard/iq/dataverse/settings/JvmSettings.java index e409607346b..2023f2df6e1 100644 --- a/src/main/java/edu/harvard/iq/dataverse/settings/JvmSettings.java +++ b/src/main/java/edu/harvard/iq/dataverse/settings/JvmSettings.java @@ -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 = ".";