Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

API OIDC authentication mechanism #10905

Open
wants to merge 17 commits into
base: develop
Choose a base branch
from
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
"factoryAlias":"oidc",
"title":"<a title - shown in UI>",
"subtitle":"<a subtitle - currently unused in UI>",
"factoryData":"type: oidc | issuer: <issuer url> | clientId: <client id> | clientSecret: <client secret> | pkceEnabled: <true/false> | pkceMethod: <PLAIN/S256/...>",
"factoryData":"type: oidc | issuer: <issuer url> | clientId: <client id> | clientSecret: <client secret>",
"enabled":true
}
39 changes: 4 additions & 35 deletions doc/sphinx-guides/source/installation/oidc.rst
Original file line number Diff line number Diff line change
Expand Up @@ -69,26 +69,6 @@ After adding a provider, the Log In page will by default show the "builtin" prov
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!).


.. _oidc-pkce:

Enabling PKCE Security
^^^^^^^^^^^^^^^^^^^^^^

Many providers these days support or even require the usage of `PKCE <https://oauth.net/2/pkce/>`_ to safeguard against
some attacks and enable public clients that cannot have a secure secret to still use OpenID Connect (or OAuth2).

The Dataverse-built OIDC client can be configured to use PKCE and the method to use when creating the code challenge can be specified.
See also `this explanation of the flow <https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow-with-proof-key-for-code-exchange-pkce>`_
for details on how this works.

As we are using the `Nimbus SDK <https://connect2id.com/products/nimbus-oauth-openid-connect-sdk>`_ as our client
library, we support the standard ``PLAIN`` and ``S256`` (SHA-256) code challenge methods. "SHA-256 method" is the default
as recommend in `RFC7636 <https://datatracker.ietf.org/doc/html/rfc7636#section-4.2>`_. If your provider needs some
other method, please open an issue.

The provisioning sections below contain in the example the parameters you may use to configure PKCE.

Provision a Provider
--------------------

Expand All @@ -106,9 +86,6 @@ requires fewer extra steps and allows you to keep more configuration in a single
Provision via REST API
^^^^^^^^^^^^^^^^^^^^^^

Note: you may omit the PKCE related settings from ``factoryData`` below if you don't plan on using PKCE - default is
disabled.

Please create a :download:`my-oidc-provider.json <../_static/installation/files/root/auth-providers/oidc.json>` file, replacing every ``<...>`` with your values:

.. literalinclude:: /_static/installation/files/root/auth-providers/oidc.json
Expand Down Expand Up @@ -163,14 +140,6 @@ The following options are available:
- The base URL of the OpenID Connect (OIDC) server as explained above.
- Y
- \-
* - ``dataverse.auth.oidc.pkce.enabled``
- Set to ``true`` to enable :ref:`PKCE <oidc-pkce>` in auth flow.
- N
- ``false``
* - ``dataverse.auth.oidc.pkce.method``
- Set code challenge method. The default value is the current best practice in the literature.
- N
- ``S256``
* - ``dataverse.auth.oidc.title``
- The UI visible name for this provider in login options.
- N
Expand All @@ -179,12 +148,12 @@ The following options are available:
- A subtitle, currently not displayed by the UI.
- N
- ``OpenID Connect``
* - ``dataverse.auth.oidc.pkce.max-cache-size``
- Tune the maximum size of all OIDC providers' verifier cache (the number of outstanding PKCE-enabled auth responses).
* - ``dataverse.auth.oidc.bearer.max-cache-size``
- Tune the maximum size of all OIDC providers' bearer token cache.
- N
- 10000
* - ``dataverse.auth.oidc.pkce.max-cache-age``
- Tune the maximum age, in seconds, of all OIDC providers' verifier cache entries. Default is 5 minutes, equivalent to lifetime
* - ``dataverse.auth.oidc.bearer.max-cache-age``
- Tune the maximum age, in seconds, of all OIDC providers' bearer cache entries. Default is 5 minutes, equivalent to lifetime
of many OIDC access tokens.
- N
- 300
3 changes: 3 additions & 0 deletions docker-compose-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,15 @@ services:
SKIP_DEPLOY: "${SKIP_DEPLOY}"
DATAVERSE_JSF_REFRESH_PERIOD: "1"
DATAVERSE_FEATURE_API_BEARER_AUTH: "1"
ErykKul marked this conversation as resolved.
Show resolved Hide resolved
DATAVERSE_AUTH_OIDC_BEARER_CACHE_MAXSIZE: "10000"
DATAVERSE_AUTH_OIDC_BEARER_CACHE_MAXAGE: "300"
DATAVERSE_MAIL_SYSTEM_EMAIL: "dataverse@localhost"
DATAVERSE_MAIL_MTA_HOST: "smtp"
DATAVERSE_AUTH_OIDC_ENABLED: "1"
DATAVERSE_AUTH_OIDC_CLIENT_ID: test
DATAVERSE_AUTH_OIDC_CLIENT_SECRET: 94XHrfNRwXsjqTqApRrwWmhDLDHpIYV8
DATAVERSE_AUTH_OIDC_AUTH_SERVER_URL: http://keycloak.mydomain.com:8090/realms/test
DATAVERSE_AUTH_OIDC_REDIRECT_URI: http://localhost:8080/api/v1/callback/token
DATAVERSE_SPI_EXPORTERS_DIRECTORY: "/dv/exporters"
# These two oai settings are here to get HarvestingServerIT to pass
dataverse_oai_server_maxidentifiers: "2"
Expand Down
2 changes: 2 additions & 0 deletions docker/compose/demo/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ services:
DATAVERSE_DB_PASSWORD: secret
DATAVERSE_DB_USER: dataverse
DATAVERSE_FEATURE_API_BEARER_AUTH: "1"
DATAVERSE_AUTH_OIDC_BEARER_CACHE_MAXSIZE: "10000"
DATAVERSE_AUTH_OIDC_BEARER_CACHE_MAXAGE: "300"
DATAVERSE_MAIL_SYSTEM_EMAIL: "Demo Dataverse <[email protected]>"
DATAVERSE_MAIL_MTA_HOST: "smtp"
JVM_ARGS: -Ddataverse.files.storage-driver-id=file1
Expand Down
3 changes: 3 additions & 0 deletions run_dev_env.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/bin/bash

mvn -Pct clean package docker:run
16 changes: 15 additions & 1 deletion src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
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.UserRecordIdentifier;
import edu.harvard.iq.dataverse.authorization.groups.GroupServiceBean;
import edu.harvard.iq.dataverse.authorization.providers.oauth2.OIDCLoginBackingBean;
import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser;
import edu.harvard.iq.dataverse.authorization.users.User;
import edu.harvard.iq.dataverse.confirmemail.ConfirmEmailServiceBean;
Expand Down Expand Up @@ -240,6 +242,9 @@ String getWrappedMessageWhenJson() {
@Context
protected HttpServletRequest httpRequest;

@EJB
OIDCLoginBackingBean oidcLoginBackingBean;

/**
* For pretty printing (indenting) of JSON output.
*/
Expand Down Expand Up @@ -322,7 +327,16 @@ protected AuthenticatedUser getRequestAuthenticatedUserOrDie(ContainerRequestCon
if (requestUser.isAuthenticated()) {
return (AuthenticatedUser) requestUser;
} else {
throw new WrappedResponse(authenticatedUserRequired());
final UserRecordIdentifier userRecordIdentifier = oidcLoginBackingBean.getUserRecordIdentifier();
if (userRecordIdentifier == null) {
throw new WrappedResponse(authenticatedUserRequired());
}
final AuthenticatedUser authUser = authSvc.lookupUser(userRecordIdentifier);
if (authUser != null) {
return authUser;
} else {
throw new WrappedResponse(authenticatedUserRequired());
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package edu.harvard.iq.dataverse.api;

import java.io.IOException;

import fish.payara.security.annotations.LogoutDefinition;
import fish.payara.security.annotations.OpenIdAuthenticationDefinition;
import fish.payara.security.openid.api.OpenIdConstant;
import jakarta.annotation.security.DeclareRoles;
import jakarta.ejb.EJB;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.HttpConstraint;
import jakarta.servlet.annotation.ServletSecurity;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

/**
* OIDC login implementation
*/
@WebServlet("/oidc/login")
@OpenIdAuthenticationDefinition(
providerURI = "#{openIdConfigBean.providerURI}",
clientId = "#{openIdConfigBean.clientId}",
clientSecret = "#{openIdConfigBean.clientSecret}",
redirectURI = "#{openIdConfigBean.redirectURI}",
scope = {OpenIdConstant.OPENID_SCOPE, OpenIdConstant.EMAIL_SCOPE, OpenIdConstant.PROFILE_SCOPE},
logout = @LogoutDefinition(redirectURI = "#{openIdConfigBean.logoutURI}")
)
@DeclareRoles("all")
@ServletSecurity(@HttpConstraint(rolesAllowed = "all"))
public class OpenIDAuthentication extends HttpServlet {
@EJB
OpenIDConfigBean openIdConfigBean;

@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
response.getWriter().println("This content is unreachable as the required role is not assigned to anyone, therefore, this content should never become visible in a browser");
}
}
92 changes: 92 additions & 0 deletions src/main/java/edu/harvard/iq/dataverse/api/OpenIDCallback.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package edu.harvard.iq.dataverse.api;

import static edu.harvard.iq.dataverse.util.json.NullSafeJsonBuilder.jsonObjectBuilder;

import java.net.URI;

import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean;
import edu.harvard.iq.dataverse.authorization.UserRecordIdentifier;
import edu.harvard.iq.dataverse.authorization.providers.oauth2.OIDCLoginBackingBean;
import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser;
import edu.harvard.iq.dataverse.util.SystemConfig;
import fish.payara.security.openid.api.OpenIdContext;
import jakarta.ejb.EJB;
import jakarta.ejb.Stateless;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.Response;

@Stateless
@Path("callback")
public class OpenIDCallback extends AbstractApiBean {
@Inject
OpenIdContext openIdContext;

@Inject
protected AuthenticationServiceBean authSvc;

@EJB
OpenIDConfigBean openIdConfigBean;

@EJB
OIDCLoginBackingBean oidcLoginBackingBean;

/**
* Callback URL for the OIDC log in. It redirects to either JSF, SPA or API
* after log in according to the target config.
*
* @param crc
* @return
*/
@Path("token")
Copy link
Member

Choose a reason for hiding this comment

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

Docs please! (API Guide.)

@GET
public Response token(@Context ContainerRequestContext crc) {
switch (openIdConfigBean.getTarget()) {
case "JSF":
return Response
.seeOther(URI.create(SystemConfig.getDataverseSiteUrlStatic() + "/oauth2/callback.xhtml"))
.build();
case "SPA":
return Response.seeOther(crc.getUriInfo().getBaseUri().resolve("spa/")).build();
case "API":
return Response.seeOther(crc.getUriInfo().getBaseUri().resolve("callback/session")).build();
default:
return Response.seeOther(crc.getUriInfo().getBaseUri().resolve("spa/")).build();
}
}

/**
* Retrieve OIDC session and tokens (it is also where API target login redirects
* to)
*
* @param crc
* @return
*/
@Path("session")
Copy link
Member

Choose a reason for hiding this comment

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

Docs here too, please.

@GET
public Response session(@Context ContainerRequestContext crc) {
final UserRecordIdentifier userRecordIdentifier = oidcLoginBackingBean.getUserRecordIdentifier();
if (userRecordIdentifier == null) {
return notFound("user record identifier not found");
}
final AuthenticatedUser authUser = authSvc.lookupUser(userRecordIdentifier);
if (authUser != null) {
oidcLoginBackingBean.storeBearerToken();
try {
return ok(
jsonObjectBuilder()
.add("user", authUser.toJson())
.add("session", crc.getCookies().get("JSESSIONID").getValue())
.add("accessToken", openIdContext.getAccessToken().getToken())
.add("identityToken", openIdContext.getIdentityToken().getToken()));
} catch (Exception e) {
return badRequest(e.getMessage());
}
} else {
return notFound("user with record identifier " + userRecordIdentifier + " not found");
}
}
}
55 changes: 55 additions & 0 deletions src/main/java/edu/harvard/iq/dataverse/api/OpenIDConfigBean.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package edu.harvard.iq.dataverse.api;

import edu.harvard.iq.dataverse.settings.JvmSettings;
import edu.harvard.iq.dataverse.util.SystemConfig;
import jakarta.ejb.Stateless;
import jakarta.inject.Named;

@Stateless
@Named("openIdConfigBean")
public class OpenIDConfigBean implements java.io.Serializable {
private String target = "API";
private String providerURI = JvmSettings.OIDC_AUTH_SERVER_URL.lookupOptional().orElse(null);
private String clientId = JvmSettings.OIDC_CLIENT_ID.lookupOptional().orElse(null);
private String clientSecret = JvmSettings.OIDC_CLIENT_SECRET.lookupOptional().orElse(null);

public String getProviderURI() {
return providerURI;
}

public String getClientId() {
return clientId;
}

public String getClientSecret() {
return clientSecret;
}

public String getRedirectURI() {
return SystemConfig.getDataverseSiteUrlStatic() + "/api/v1/callback/token";
}

public String getLogoutURI() {
return SystemConfig.getDataverseSiteUrlStatic();
}

public String getTarget() {
return this.target;
}

public void setClientId(final String clientId) {
this.clientId = clientId;
}

public void setTarget(final String target) {
this.target = target;
}

public void setProviderURI(final String providerURI) {
this.providerURI = providerURI;
}

public void setClientSecret(final String clientSecret) {
this.clientSecret = clientSecret;
}
}
Loading
Loading